一个命令有三个独立结果

taskbook stats 不只有屏幕上看到的文本。自动化调用者同时观察 stdout、stderr 和退出状态。如果输入失败却退出零,流水线会把失败当成成功;如果把提示语和 JSON 写进同一 stdout,下游解析器会拒绝原本正确的统计结果。

Taskbook 的命令合同因此明确为:成功数据写到 stdout,正常成功的 stderr 为空;参数或输入错误退出 2 且 stdout 为空,文件和 IO 错误退出 3。输出写入途中发生 IO 错误仍可能留下部分字节,调用者必须检查退出状态。帮助信息退出零。输出文件默认拒绝覆盖。它们是本工具的约定,不是所有 Unix 命令必须采用的统一数字规范。

本篇前置是资源、包装、测试与数据边界。入口为 examples/ruby/exe/taskbook,实测通过子进程完成,不能用“直接调用库函数通过”替代 CLI 验收。

参数解析只负责入口的语法

OptionParser 将命令行字符串转换成指定的选项值:

1
2
3
4
5
6
7
8
9
10
options = { format: 'json', output_format: 'json', min_priority: 1 }

parser = OptionParser.new do |p|
p.banner = 'Usage: taskbook [filter|stats|export] [options]'
p.on('--input PATH') { |value| options[:input] = value }
p.on('--format FORMAT', %w[json csv]) { |value| options[:format] = value }
p.on('--min-priority N', Integer) { |value| options[:min_priority] = value }
p.on('--tag TAG') { |value| options[:tag] = value }
p.on('-h', '--help') { puts p; exit 0 }
end

枚举值限制格式名称,Integer 转换拒绝无法解析为整数的文本。但 --min-priority 99 仍是一个语法上合法的整数,是否允许由领域函数继续判断。参数解析与业务范围校验分属不同层,不能因为写了 Integer 就删掉 Taskbook.filter 的一到五检查。

帮助处理放在解析过程中,可以不读取 stdin 就退出。否则用户运行 --help 时可能等待标准输入结束,体验和自动化都不可靠。帮助路径也不应要求打开任务文件或连接服务;入口初始化需要足够轻。OptionParser 文档 说明选项声明与转换机制。

本例支持 filter、stats 和 export 三个命令。未显式提供命令而直接写选项时,默认 filter;未知命令和解析结束后的多余参数都拒绝。默认行为越多越需要可预测,本工具只保留一个常用默认值,不猜测用户究竟漏写了命令还是路径。

输入流的所有权延续到命令边界

没有 --input 时读取 stdin,有路径时由入口打开文件:

1
2
3
4
5
6
7
tasks = if options[:input]
File.open(options[:input], 'rb') do |file|
Taskbook::IO.read(file, format: options[:format])
end
else
Taskbook::IO.read($stdin, format: options[:format])
end

文件在入口创建,因此入口的 block 负责关闭;stdin 由进程环境提供,导入器不擅自关闭。这个结构让同一导入函数可以被 CLI、HTTP 和测试使用,不需要为每种入口复制解析规则。

先完整导入并校验,再筛选和生成输出,可以避免坏输入导致半份结果提前流出。对于需要真正流式处理的大型命令,这一策略可能不合适,但必须重新定义部分结果和失败的关系。当前工具的 64 KiB 上限使整批处理简单且可控。

路径按照调用者工作目录解释,符合命令行使用习惯。库内部源码定位则通过 require 关系处理,不能把这两种路径基准混淆。打包后的命令从临时目录启动仍能找到库,是第 22 篇的验收内容。

筛选、统计与导出共享领域函数

入口解析完成后,调用既有筛选 API:

1
2
3
4
5
6
tasks = Taskbook.filter(
tasks,
min_priority: options[:min_priority],
tag: options[:tag],
status: options[:status]
)

stats 把筛选后的集合交给 summary,其他命令把集合交给导出器。命令层不再实现一遍标签匹配和优先级比较,因此修复领域规则时,三个命令能够共享同一行为。

输出格式由 --output-format 选择,输入格式由 --format 选择。二者分开可以完成 JSON 到 CSV 的转换,而不会因为输入格式推断而限制导出能力。统计输出保持 JSON 对象,与任务清单导出的数组是不同协议,调用者应根据命令选择解析方式。

机器输出中不要加入“处理成功,共三条”之类前缀。如果需要人类提示,可以提供专门的人类模式或写入 stderr;本例的默认 stdout 始终保持可解析。字段顺序通常不是 JSON 对象的业务合同,测试检查解码结果,避免仅因格式化变化就报回归。

拒绝覆盖必须由创建动作保证

Taskbook 的文件输出采用排他创建:

1
2
3
4
5
File.open(
options[:output],
File::WRONLY | File::CREAT | File::EXCL,
0o600
) { |file| file.write(output) }

先检查 File.exist? 再打开文件存在竞争窗口;排他创建把“只有不存在才创建”交给底层操作。新文件权限请求限制为所有者读写,最终权限还受系统与目录条件影响。已经存在的路径导致系统调用错误,命令返回约定的 IO 失败码。

拒绝覆盖只解决旧文件保护。新文件写入过程中遇到磁盘错误,仍可能留下不完整的新文件;本工具没有声称输出事务性。若需要替换已有报告并保证读者不见半份内容,可采用第 19 篇的候选文件与重命名策略,同时处理并发与持久性前提。

测试把第二次运行后的文件内容与第一次内容比较,而不只检查存在性。这样能发现先截断再报错的错误实现。工具处理用户文件时,这种结果断言比检查内部调用了哪个 API 更有价值。

错误只在进程入口转成退出码

入口区分可预期输入错误和 IO 失败:

1
2
3
4
5
6
rescue OptionParser::ParseError, ArgumentError => error
warn "taskbook: #{error.message}"
exit 2
rescue SystemCallError, IOError
warn 'taskbook: I/O operation failed'
exit 3

库层不会调用 exit,因此其他程序可以复用库并采用自己的错误策略。CLI 处于进程边界,才拥有结束本次命令的职责。没有捕获所有 Exception,避免把退出请求或程序缺陷一律包装成“输入错误”。

IO 错误对外消息不包含内部路径细节;输入错误消息来自受控的校验规则。若将原始异常完整打印出来,可能暴露环境或数据。另一方面,完全吞掉错误只返回空数组,会使自动化无法分辨空结果与失败。错误流和退出码提供了必要区分。

本例未实现自动重试。文件读取失败、输出路径已存在与输入非法需要不同处理,笼统重试可能重复副作用或一直失败。调用者若需要重试,应根据错误类别和幂等条件决定,不能仅看“退出非零”就重复全部命令。

子进程测试覆盖连接处

验证用参数数组启动解释器,避免额外 shell 展开:

1
2
3
4
5
6
7
8
out, err, status = Open3.capture3(
RbConfig.ruby, '-Ilib', 'exe/taskbook', 'stats',
stdin_data: input
)

raise unless status.success?
raise unless JSON.parse(out)['total'] == 1
raise unless err.empty?

Open3 文档 描述了多流捕获接口。测试还分别调用帮助、合法筛选、非法选项、坏 JSON 和重复输出路径,观察三个输出维度。传入字符串形式的整个 shell 命令会增加引号和转义问题,参数数组更贴近要验证的程序接口。

捕获 stdout 与 stderr 也要避免死锁。自行依次读一个管道、等它结束后才读另一个,大量输出可能堵住子进程。这里采用提供并行捕获能力的库接口,不手写管道调度。需要实时流式日志时再选用更低层 API,并给关闭和等待建立明确策略。

Shell 管道还有额外的退出状态规则,通常取最后一个命令的状态;这属于调用环境,应在使用流水线时另外处理。本篇直接捕获 Taskbook 子进程,避免把 shell 管道成功误认为 Taskbook 成功。关闭下游产生的断管也属于未来更完整 CLI 的边界,并未由当前有限场景穷尽。

默认值和退出约定也需要版本维护

默认最小优先级是一,改变成三不会造成语法错误,却会改变没有传参数的调用者结果。默认值属于公共行为,升级时应和新增选项一样审阅。帮助文本是用户发现合同的入口,行为测试则验证它是否被实现;两者不一致会让自动化建立在错误说明上。

脚本调用者通常优先依赖退出码和机器输出,不应该解析面向人的整句报错。错误文案可以改进语言和细节,稳定的失败分类却需要保留。若未来增加更多退出码,应说明旧调用者将未知非零状态如何处理,避免把某个具体文案当作重试协议。

标准输入也需要数据结束约定。一次命令处理一份完整 JSON 文档,就应该等到 EOF 后解析;要支持逐条持续输入,应采用明确分隔的协议并重新定义每条结果和失败恢复。把多个 JSON 数组直接拼在同一个 stdin,不能自动变成合法批处理格式。

命令执行环境中还可能存在用户别名、shell 函数和 PATH 中的另一个同名命令。测试采用当前解释器加明确文件路径,是为了验证此工程入口;安装后 PATH 发现属于另一个验收场景。报告应写清所用 invocation,不能把开发入口测试当成所有用户 shell 都正确配置的证明。

帮助输出也应进入子进程测试。测试只检查稳定的 Usage 标记和成功退出,不把所有空格宽度锁死;参数名称和实际解析规则仍需人工对照。若帮助开始读取 stdin,空输入帮助场景就能及时暴露行为退化。

运行与练习

1
2
3
cd examples/ruby
bundle exec ruby -Ilib labs/27/run.rb
printf '[]' | bundle exec ruby -Ilib exe/taskbook stats

第一条执行子进程合同测试,第二条展示空清单统计。实际证据在 evidence/27/run.txt;它包含正常结果、帮助、输入失败及旧文件内容保持。

练习一:新增一个未知状态参数,检查参数解析、错误流和退出码是否符合合同。练习二:将 JSON 样本导出为 CSV 文件,再作为 CSV 输入导回 JSON,比较领域记录而非原始字节;第二次写同一路径必须失败,并确认第一次文件未变化。

系列导航

导读 · 上一篇:26:JSON、CSV 与领域输入边界 · 下一篇:28:Net::HTTP、Rack 与本地请求实验 · 完整源码包