命令行工具的输出不是随便打印几行文本。对 shell、CI 和其他程序来说,它的接口通常是三件事:
stdout -> 正常数据
stderr -> 诊断信息、错误信息
exit code -> 成功或失败
C 里通常写:
printf("%s\n", result);
fprintf(stderr, "tool: %s\n", error);
return 1;
Rust 里也有同样的边界,只是表达方式不止一种。
stdout 写正常结果
简单输出用 println!:
println!("{count}\t{path}");
它写到 stdout,适合给用户或管道消费的正常结果:
tool input.txt > result.txt
如果输出很多行,或者你想显式处理写入错误,可以用 std::io::Write:
use std::io::{self, Write};
fn write_report(rows: &[String]) -> io::Result<()> {
let stdout = io::stdout();
let mut out = stdout.lock();
for row in rows {
writeln!(out, "{row}")?;
}
Ok(())
}
这比到处 println! 更适合库化的输出逻辑,也能处理 pipe 断开这类写入错误。
stderr 写错误和诊断
简单错误输出用 eprintln!:
eprintln!("tool: failed to read {path}: {err}");
它写到 stderr。这样用户可以把正常结果重定向到文件,同时仍然在终端看到错误:
tool input.txt > result.txt
使用 stderr 的常见内容:
- usage 和参数错误
- 文件读写失败
- 调试、警告、进度信息
- 人读的错误消息
不要把正常机器可消费的数据混进 stderr,也不要把错误消息混进 stdout。命令行工具一旦被脚本调用,这些流就是协议。
main 可以返回 ExitCode
C 里 main 返回整数:
int main(void)
{
if (failed) {
return 1;
}
return 0;
}
Rust 里可以显式返回 ExitCode:
use std::process::ExitCode;
fn main() -> ExitCode {
if let Err(err) = run() {
eprintln!("tool: {err}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
这比在每个错误点 process::exit(1) 更容易维护。run 仍然是普通函数,能测试、能复用;main 只负责把最终结果变成进程退出状态。
process::exit 会直接结束进程
第一篇里用过:
std::process::exit(1);
它能工作,但要知道它的含义:直接结束当前进程。
这适合放在很薄的 main 边界,不适合放在库函数里:
fn parse_config(path: &str) -> Config {
// 不要在库函数里直接 process::exit(1)
todo!()
}
库函数应该返回错误:
fn parse_config(path: &str) -> Result<Config, ConfigError> {
let text = std::fs::read_to_string(path)?;
Config::parse(&text)
}
由 CLI 层决定怎么打印、用什么退出码:
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("tool: {err}");
ExitCode::FAILURE
}
}
}
usage 错误和运行错误可以分开
C 工具常用不同退出码:
if (argc != 2) {
fprintf(stderr, "usage: tool <file>\n");
return 2;
}
if (read_failed) {
return 1;
}
Rust 也可以这么做:
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(AppError::Usage(msg)) => {
eprintln!("{msg}");
ExitCode::from(2)
}
Err(err) => {
eprintln!("tool: {err}");
ExitCode::FAILURE
}
}
}
这里 AppError 可以是你自己的错误类型:
#[derive(Debug)]
enum AppError {
Usage(String),
Io(std::io::Error),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Usage(msg) => write!(f, "{msg}"),
AppError::Io(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for AppError {}
第一层不用把错误体系做得很复杂。先把“参数用错”和“运行失败”分开,很多 CLI 就更清楚了。
子进程退出状态要检查
调用外部命令时,Command::status() 成功只表示进程启动并等待成功,不表示子进程业务成功:
use std::process::Command;
let status = Command::new("gzip")
.arg("file.log")
.status()?;
if !status.success() {
eprintln!("gzip failed: {status}");
return ExitCode::FAILURE;
}
如果你捕获输出:
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()?;
if !output.status.success() {
eprintln!("git failed: {}", output.status);
return ExitCode::FAILURE;
}
这对应 C 里检查 system() 或 waitpid() 得到的状态。不要只看 API 调用有没有返回 Err,还要看子进程自己的 exit status。
Broken pipe 不一定要吓用户
命令行工具常被管道截断:
tool huge-file | head
head 提前退出后,tool 继续写 stdout 可能遇到 broken pipe。简单工具用 println! 时,默认行为未必总是你想要的。更可控的写法是显式写 stdout 并处理错误:
use std::io::{self, Write};
fn run() -> io::Result<()> {
let stdout = io::stdout();
let mut out = stdout.lock();
for i in 0..1_000_000 {
writeln!(out, "{i}")?;
}
Ok(())
}
在 main 里可以根据错误类型决定是否安静退出。第一层先知道:大量输出的 CLI,不要把 stdout 写入错误完全忽略。
这一层先记住什么
C 到 Rust 的 CLI 边界对应关系:
printf normal output -> println! / stdout + Write
fprintf(stderr, ...) -> eprintln! / stderr + Write
return 0 -> ExitCode::SUCCESS
return 1 -> ExitCode::FAILURE / ExitCode::from(n)
main 里统一处理错误 -> run() -> Result + main 转 ExitCode
库函数失败 -> 返回 Result,不直接退出进程
system/wait status -> Command status / output.status
CLI 程序最外层可以把错误转成文字和退出码。库函数不要替调用方决定进程什么时候结束。stdout、stderr 和 exit code 是命令行工具的外部契约,应该像 API 一样维护。