返回首页

教程导航

返回首页

🏷️ 标签(1)

🗂️ 分类(1)

第 18 章 任务管理 CLI 实战

第 18 章 任务管理 CLI 实战

本章用标准库实现一个可持久化的任务管理命令行程序 tasker。它支持添加、列出、完成和删除任务,并使用模块化结构展示 Lua 项目的组织方式。

需求与命令设计

初始命令:

tasker add <title>
tasker list
tasker done <id>
tasker delete <id>

功能要求:

  • 任务保存到本地文件,重启后仍然存在。

  • ID 单调递增且不复用。

  • 标题不能为空,最长 120 个字符。

  • 完成和删除必须校验任务是否存在。

  • 错误输出到标准错误,正常结果输出到标准输出。

  • 数据文件损坏时拒绝覆盖,并给出明确错误。

项目结构:

tasker/
├── main.lua
├── errors.lua
├── storage.lua
├── tasks.lua
└── data/
    └── tasks.lua

数据结构与存储

任务对象:

{
  id = 1,
  title = "学习 Lua",
  status = "todo",
  createdAt = 1762142400
}

状态只允许 "todo" 和 "done"。数据文件保存为 Lua 表:

return {
  nextId = 4,
  tasks = {
    {
      id = 1,
      title = "学习 Lua",
      status = "todo",
      createdAt = 1762142400
    }
  }
}

storage.lua:

local M = {}

local function encodeString(value)
  return string.format("%q", value)
end

function M.save(path, state)
  local temporary = path .. ".tmp"
  local file, message = io.open(temporary, "w")
  if not file then
    return nil, "无法创建临时文件: " .. message
  end

  file:write("return {\n")
  file:write("  nextId = " .. state.nextId .. ",\n")
  file:write("  tasks = {\n")

  for _, task in ipairs(state.tasks) do
    file:write(string.format(
      "    { id = %d, title = %s, status = %s, createdAt = %d },\n",
      task.id,
      encodeString(task.title),
      encodeString(task.status),
      task.createdAt
    ))
  end

  file:write("  },\n")
  file:write("}\n")

  local writeOk, writeError = file:close()
  if not writeOk then
    os.remove(temporary)
    return nil, "写入失败: " .. tostring(writeError)
  end

  local renameOk, renameError = os.rename(temporary, path)
  if not renameOk then
    os.remove(temporary)
    return nil, "替换数据文件失败: " .. tostring(renameError)
  end

  return true
end

function M.load(path)
  local chunk, message = loadfile(path)
  if not chunk then
    if message:find("No such file", 1, true) then
      return {
        nextId = 1,
        tasks = {}
      }
    end
    return nil, "无法加载数据文件: " .. message
  end

  local ok, state = pcall(chunk)
  if not ok then
    return nil, "数据文件格式错误: " .. tostring(state)
  end

  if type(state) ~= "table" or type(state.tasks) ~= "table" then
    return nil, "数据文件缺少 tasks 表"
  end

  state.nextId = math.tointeger(state.nextId) or 1
  return state
end

return M

这里使用 loadfile 执行程序生成的数据文件。它能读取 Lua 字面量,但也意味着文件具备执行代码能力。数据目录必须只允许当前用户写入;若数据来自外部,应改用受限的数据格式。

参数解析

tasks.lua 负责业务逻辑:

local M = {}

local MAX_TITLE_LENGTH = 120

local function normalizeTitle(title)
  if type(title) ~= "string" then
    return nil, "标题必须是字符串"
  end

  title = title:match("^%s*(.-)%s*$")
  if title == "" then
    return nil, "标题不能为空"
  end
  if utf8.len(title) == nil then
    return nil, "标题不是有效的 UTF-8"
  end
  if utf8.len(title) > MAX_TITLE_LENGTH then
    return nil, "标题不能超过 " .. MAX_TITLE_LENGTH .. " 个字符"
  end
  return title
end

function M.add(state, title)
  local normalized, message = normalizeTitle(title)
  if not normalized then
    return nil, message
  end

  local task = {
    id = state.nextId,
    title = normalized,
    status = "todo",
    createdAt = os.time()
  }

  state.nextId = state.nextId + 1
  state.tasks[#state.tasks + 1] = task
  return task
end

local function findTask(state, id)
  for index, task in ipairs(state.tasks) do
    if task.id == id then
      return task, index
    end
  end
end

function M.complete(state, id)
  local task = findTask(state, id)
  if not task then
    return nil, "找不到任务 " .. tostring(id)
  end
  if task.status == "done" then
    return nil, "任务已经完成"
  end

  task.status = "done"
  return task
end

function M.delete(state, id)
  local _, index = findTask(state, id)
  if not index then
    return nil, "找不到任务 " .. tostring(id)
  end
  return table.remove(state.tasks, index)
end

return M

业务模块实现

命令解析独立放在 main.lua:

local storage = require("storage")
local tasks = require("tasks")

local DATA_PATH = "data/tasks.lua"

local function usage()
  io.stderr:write([[
用法:
  lua main.lua add <title>
  lua main.lua list
  lua main.lua done <id>
  lua main.lua delete <id>
]])
  return 1
end

local function parseId(value)
  local id = tonumber(value)
  if not id or math.type(id) ~= "integer" or id < 1 then
    return nil, "ID 必须是正整数"
  end
  return id
end

local function printTasks(items)
  if #items == 0 then
    print("没有任务")
    return
  end

  for _, task in ipairs(items) do
    local marker = task.status == "done" and "[x]" or "[ ]"
    print(string.format("%s %d  %s", marker, task.id, task.title))
  end
end

local command = arg[1]

if command == nil then
  return usage()
end

local state, loadError = storage.load(DATA_PATH)
if not state then
  io.stderr:write(loadError, "\n")
  return 1
end

local ok, result
local message

if command == "add" then
  if arg[2] == nil then
    return usage()
  end
  ok, result = pcall(tasks.add, state, table.concat(arg, " ", 2))
elseif command == "list" then
  printTasks(state.tasks)
  return 0
elseif command == "done" then
  local id
  id, message = parseId(arg[2])
  if not id then
    io.stderr:write(message, "\n")
    return 1
  end
  ok, result = pcall(tasks.complete, state, id)
elseif command == "delete" then
  local id
  id, message = parseId(arg[2])
  if not id then
    io.stderr:write(message, "\n")
    return 1
  end
  ok, result = pcall(tasks.delete, state, id)
else
  return usage()
end

if not ok then
  io.stderr:write(tostring(result), "\n")
  return 1
end

if result == nil and message then
  io.stderr:write(message, "\n")
  return 1
end

local saved, saveError = storage.save(DATA_PATH, state)
if not saved then
  io.stderr:write(saveError, "\n")
  return 1
end

if command == "add" then
  print(string.format("已添加任务 %d: %s", result.id, result.title))
elseif command == "done" then
  print(string.format("已完成任务 %d: %s", result.id, result.title))
elseif command == "delete" then
  print(string.format("已删除任务 %d: %s", result.id, result.title))
end

return 0

return 只能用于 chunk 或函数。将其作为脚本入口时需要宿主支持;更通用的命令行脚本可以调用 os.exit(code),但测试时不如把主逻辑包装为函数容易。

集成与运行

调整入口,把主逻辑封装为函数:

local function main(arguments)
  -- 解析命令、调用模块并返回状态码
end

if ... == nil then
  os.exit(main(arg))
end

return main

运行:

lua main.lua add "学习 Lua 元表"
lua main.lua list
lua main.lua done 1
lua main.lua delete 1

检查错误路径:

lua main.lua add ""
lua main.lua done 999
lua main.lua unknown

后续扩展

  • 为任务增加优先级、截止日期和标签。

  • 用 JSON 替换 Lua 数据文件。

  • 增加 --data-dir 参数。

  • 增加 list --status todo 过滤。

  • 为存储模块提供内存实现,提升测试速度。

  • 把命令解析抽成纯函数,便于做表驱动测试。

实战的重点不是功能数量,而是把输入校验、业务操作和持久化边界分开。这样才能在需求增长时继续安全修改。

上一章:C API 与嵌入式 Lua | 下一章:测试、调试与性能