第 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过滤。 -
为存储模块提供内存实现,提升测试速度。
-
把命令解析抽成纯函数,便于做表驱动测试。
实战的重点不是功能数量,而是把输入校验、业务操作和持久化边界分开。这样才能在需求增长时继续安全修改。