Skip to content

第一步:一个会说话的 CLI ​

"会说话"的 CLI 指三件事:--help 能自我介绍,报错能把原因讲全,退出码能把结果告诉脚本。这一章读 jackdaw 的两个文件:cli.rs 定义命令行长什么样,main.rs 负责接线。读完你能回答:

  • 敲下 jackdaw serve --port 3426,这串字符经过哪几步变成代码里的一个值?谁决定执行哪段逻辑?
  • 配置出错时 jackdaw check 的退出码为什么是 2?约定写在源码的哪里?
  • 拿到一段看似只是"顺手清理"的 AI diff,你按什么顺序读,才能在测试之前就发现它改了行为?

用一个枚举定义整个命令行 ​

没有 clap 的日子,你得手写 argv 解析:--config 取下一个词、--port 自己转数字、参数写错自己拼用法;每个 CLI 重写一遍,帮助文本还会和真实行为漂移。clap——第 9 章生态地图里的那个命令行解析箱(crate)——思路相反:你声明数据,它生成解析器。

jackdaw 的全部命令行就是一个枚举(enum),每个变体是一个子命令:

rust
/// jackdaw — a tiny OpenAI-compatible LLM gateway with routing groups.
#[derive(Debug, Parser)]
#[command(name = "jackdaw", version, about)]
pub enum Cli {
    /// Start the gateway HTTP server.
    Serve {
        /// Config file; default lookup: ./jackdaw.toml then
        /// ~/.config/jackdaw/jackdaw.toml
        #[arg(long, value_name = "PATH")]
        config: Option<PathBuf>,
        /// Override gateway.port from the config
        #[arg(long, value_name = "PORT")]
        port: Option<u16>,
    },
    // ...
    /// Send one prompt through the same routing pipeline as the gateway
    /// (smoke test).
    Ask {
        /// Model reference: <provider>/<model-id> or group/<id>
        model_ref: String,
        /// The prompt; words are joined with spaces
        #[arg(required = true)]
        prompt: Vec<String>,
        /// Config file; default lookup as for `serve`
        #[arg(long, value_name = "PATH")]
        config: Option<PathBuf>,
        /// Wait for the whole answer and print it once (default: print as
        /// it streams)
        #[arg(long)]
        no_stream: bool,
    },
}

读法:变体上的 /// 注释就是 jackdaw --help 里那行子命令说明,字段上的注释是参数的帮助——cli.rs 顶部的模块注释把这一点定为设计原则:帮助文本紧挨着定义,不会漂移。字段怎么变成参数:config: Option<PathBuf>(PathBuf 是标准库的路径类型)配 #[arg(long)],是可缺省的 --config PATH;port: Option<u16> 同理,clap 还替你把字符串转成 u16,转不动就报错。Ask 里,model_ref 没有属性标注,是位置参数;prompt: Vec<String> 标着 required = true——至少给一个词,每个词都收进 Vec;no_stream: bool 是开关,出现即 true。省略的 Models/Groups/Check 三个变体结构同理。#[derive(Parser)] 是派生(derive)宏:编译器替你生成 impl Parser for Cli,解析和帮助文本的拼装都出自这份生成物。

命令分发:main.rs 只做接线 ​

clap 交还一个填好数据的 Cli 值,接下来轮到 jackdaw 决定执行哪段逻辑。看 run:

rust
async fn run(cli: Cli) -> anyhow::Result<()> {
    match cli {
        Cli::Serve { config, port } => {
            let config = config::load(config.as_deref())?;
            jackdaw::gateway::serve(config, port).await?;
        }
        // ...
        Cli::Ask {
            model_ref,
            prompt,
            config,
            no_stream,
        } => {
            let config = config::load(config.as_deref())?;
            jackdaw::ask::run(config, &model_ref, &prompt.join(" "), !no_stream).await?;
        }
    }
    Ok(())
}

读法:从 match cli 看起——对枚举做模式匹配(pattern matching),一个分支对应一个子命令;解构出的 config、port 就是上一节那些字段,由 clap 解析后传进来。各分支套路一致:先用 ?(问号运算符)加载配置(as_deref() 只是把 Option<PathBuf> 换成 Option<&Path> 方便传给 load),再调用库里(jackdaw:: 前缀)的函数干活。Ask 分支多两个小动作:prompt.join(" ") 把命令行上的多个词拼回一个字符串;!no_stream 取反后传下去——开关按用户视角命名为否定式,传给库函数时翻回肯定语义。main.rs 顶部注释把这层分工说得很直白:可复用的都住在库里,这个文件只负责 binary 边缘的接线(main.rs 与 lib.rs 之分,第 8 章讲过)。

anyhow 在 binary 边缘:错误链与退出码 ​

任何子命令都可能失败:配置文件不存在、端口被占。run 的返回类型 anyhow::Result<()>——错误类型固定为 anyhow::Error 的结果类型(Result)——让每个 ? 都能把错误一路冒泡到 main,途中不必层层手动转发。anyhow 的 Error 像个万能信封:装得下任何错误,还能往外追加原因。收尾发生在 main:

rust
#[tokio::main]
async fn main() {
    init_tracing();

    if let Err(error) = run(Cli::parse()).await {
        // `{:#}` prints the whole anyhow chain, one cause per line.
        eprintln!("jackdaw: {error:#}");
        std::process::exit(exit_code(&error));
    }
}

/// 0 success, 2 config/env validation failure, 1 any other runtime error.
/// A config problem is anywhere in the error chain, not only at the root.
fn exit_code(error: &anyhow::Error) -> i32 {
    let is_config_problem = error
        .chain()
        .any(|cause| cause.downcast_ref::<ConfigError>().is_some());
    if is_config_problem { 2 } else { 1 }
}

读法:Cli::parse() 从进程参数构造出 Cli 值交给 run;解析失败(比如 ask 忘了给 prompt)走不到这里——clap 自己把用法错误打到 stderr,以非零码退出。活着出来的错误,{error:#} 把整条因果链一行一个原因地打进 stderr;std::process::exit 再按 exit_code 换算:0 成功、2 配置或环境变量校验失败、1 其他运行时错误——这是写给 shell 脚本和 CI 的契约,文档就长在 exit_code 的注释里。

.chain().any(...) 值得慢读:它沿错误链从头走到尾,任何一层是 ConfigError 就返回 2。为什么不只查最外层?注释第二行直接回答——配置错误可能在链的任何位置,库代码完全可能把 ConfigError 包进别的错误再往外抛,只查根部会漏。#[tokio::main] 和 Tokio 运行时(runtime)是第 7 章的老朋友;init_tracing() 设置日志,第一次读不用深究。ConfigError 怎么定义、thiserror 与 anyhow 怎么分工,第 11 章展开。

实战:读一段 pi 生成的 diff ​

场景:你让 pi(第 2 章认识的 coding agent CLI)帮忙"简化 main.rs 里 exit_code 的布尔判断",它回了一段 diff。这段 diff 编译得过,看起来还更地道——你的任务是读懂它,而不是相信它:

diff
 fn exit_code(error: &anyhow::Error) -> i32 {
-    let is_config_problem = error
-        .chain()
-        .any(|cause| cause.downcast_ref::<ConfigError>().is_some());
-    if is_config_problem { 2 } else { 1 }
+    match error.downcast_ref::<ConfigError>() {
+        Some(_) => 2,
+        None => 1,
+    }
 }

- 和 + 配对着读。先读 - 行:删掉的四行,做的是"沿错误链逐层找 ConfigError,按找没找到返回 2 或 1"。再读 + 行:换上的 error.downcast_ref::<ConfigError>() 只检查最外层——.chain() 没了。然后问关键问题:行为变了吗?变了——ConfigError 被包在链中间时,旧代码返回 2,新代码返回 1。diff 里最危险的从来不是删掉的代码,而是没跟着删掉的语义。

接着仲裁:cargo check/cargo clippy 都会放行,它们回答"能不能编译、写法地不地道",不回答"行为和之前一不一样";cargo test(第 15 章展开)值得跑,但 cli.rs 里那两个测试盯的都是参数解析——这正对应第 3 章说的"必须自己看懂"的时刻。二选一:拒绝这段 diff;或让 pi 补一个测试——构造 ConfigError 在链中间的错误,断言退出码仍是 2——让这次简化变得安全。

小结 ​

命令行是一个枚举:变体是子命令、字段是参数、文档注释就是 --help,派生宏让 clap 生成解析器;main.rs 只接线——Cli::parse() 造值、match 分发、错误统一冒泡,因果链进 stderr,0/1/2 落成给脚本的契约。下一章看"配置"从哪来:jackdaw.toml 如何变成类型化的 Config。

自检 ​

  1. 拿你熟悉的多子命令 CLI(比如 git 或 docker)对照 Cli:开关(如 docker 的 --rm)、带值参数(如 --config PATH)、位置参数(如 docker run 末尾的镜像名),分别对应枚举里的哪类字段?
  2. 预测:依次运行 jackdaw ask group/coder(不给 prompt)和 jackdaw check --config ./no-such.toml,各自会发生什么?输出到哪里,退出码是多少?
  3. exit_code 为什么遍历整条错误链,而不是只查最外层?描述一个"只查外层"会出错的场景。
参考答案
  1. 开关对应 no_stream: bool(出现即 true);带值参数对应 config: Option<PathBuf>(可缺省,不给就是 None);位置参数对应不带属性标注的字段,如 model_ref: String,要收多个值就用 Vec<String>,如 prompt。
  2. 第一条:clap 发现 required = true 的 prompt 为空,把缺参的用法错误打到 stderr,以非零码退出,run 不会被调用。第二条:config::load 读不到文件,错误按约定属于 ConfigError 一类,经 ? 冒泡到 main,{error:#} 把整条链打到 stderr,exit_code 在链中认出它,退出码 2。
  3. 错误在传播途中可能被外层错误包住,ConfigError 沉到链中间;只查外层会把它判成普通错误(退出码 1),脚本从此分不清"改配置就能修好"和"程序真的坏了"。