From 8ca1e1e965794710ae5c98b40af6cf429bd62a90 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 07:10:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(cli):=20=E7=A8=8B=E5=BA=8F=E5=90=8D?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6=E5=9C=A8=E6=9E=84=E9=80=A0=E6=97=B6?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=EF=BC=8C=E4=B8=A4=E6=9D=A1=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E4=BA=BA=E7=B1=BB=E5=8F=AF=E8=AF=BB=20/=20--json?= =?UTF-8?q?=EF=BC=89=E4=B8=8D=E5=86=8D=E5=88=86=E5=8F=89=EF=BC=88M82=20r3?= =?UTF-8?q?=20=E5=A4=8D=E5=AE=A1=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 只在人类可读通道渲染 `%prog%`,JSON 两条出口(错误信封的 message/hint、Report 的 warnings)直接序列化未渲染文本,于是 `--json lst` 吐出 `"hint":"%prog% --help 查看命令列表"` ——连 `ull` 自己的 JSON 都被这轮改动弄坏了。 改成**构造时渲染**,一个入口管住所有出口: - `CliError::usage/user/internal/with_hint` 统一过 `render()`(错误只有这一处成形); - `Report::new` 渲染 human 行,`Outcome::with_warnings` 渲染 warnings; - `print_report` / `print_error` 不再各自渲染(渲染点只有一个,不会再有第三条出口漏掉)。 回归测试三条:`json_error_envelope_never_leaks_the_placeholder`、 `json_warnings_never_leak_the_placeholder`、`human_lines_render_the_placeholder_too` (后者同时断言「账户名恰好叫 ull」这种用户数据不被改)。 --- crates/cli/src/error.rs | 16 ++++++--- crates/cli/src/output.rs | 72 +++++++++++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index ca2f138..9416e32 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -56,6 +56,14 @@ pub struct CliError { hint: Option, } +/// 把模板里的程序名占位符渲染掉(M82 r3)。 +/// +/// 渲染放在**构造时**,而不是打印时:错误有两条出口(人类可读 stderr、`--json` 信封), +/// 只在打印处渲染会漏掉 JSON 那条(r2 就是这么漏的,`%prog%` 直接出现在了 JSON 里)。 +fn render(text: impl Into) -> String { + crate::cli::program::fill(&text.into()) +} + impl CliError { /// 用法错误(参数不对)。 #[must_use] @@ -63,7 +71,7 @@ impl CliError { Self { kind: ErrorKind::User, code: "usage", - message: message.into(), + message: render(message), hint: None, } } @@ -74,7 +82,7 @@ impl CliError { Self { kind: ErrorKind::User, code, - message: message.into(), + message: render(message), hint: None, } } @@ -85,7 +93,7 @@ impl CliError { Self { kind: ErrorKind::Internal, code, - message: message.into(), + message: render(message), hint: None, } } @@ -99,7 +107,7 @@ impl CliError { /// 补一句提示。 #[must_use] pub fn with_hint(mut self, hint: impl Into) -> Self { - self.hint = Some(hint.into()); + self.hint = Some(render(hint)); self } diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 6affb07..f08ba20 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -20,10 +20,13 @@ pub struct Report { } impl Report { - /// 构造。 + /// 构造(人类可读行在这里就把程序名占位符渲染掉,见 [`crate::cli::program::PLACEHOLDER`])。 #[must_use] pub fn new(data: Value, human: Vec) -> Self { - Self { data, human } + Self { + data, + human: human.into_iter().map(|line| fill_program(&line)).collect(), + } } } @@ -46,10 +49,16 @@ impl Outcome { } } - /// 带上环境提示。 + /// 带上环境提示(同样在构造时渲染占位符,JSON 与人类可读两条出口才不会分叉)。 #[must_use] pub fn with_warnings(report: Report, warnings: Vec) -> Self { - Self { report, warnings } + Self { + report, + warnings: warnings + .into_iter() + .map(|line| fill_program(&line)) + .collect(), + } } } @@ -77,11 +86,11 @@ pub fn print_report( } if !quiet { for line in &outcome.report.human { - writeln!(out, "{}", fill_program(line))?; + writeln!(out, "{line}")?; } } for warning in &outcome.warnings { - writeln!(err, "提示: {}", fill_program(warning))?; + writeln!(err, "提示: {warning}")?; } Ok(()) } @@ -101,9 +110,9 @@ pub fn print_error( writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?; return Ok(()); } - writeln!(err, "错误: {}", fill_program(error.message()))?; + writeln!(err, "错误: {}", error.message())?; if let Some(hint) = error.hint() { - writeln!(err, "提示: {}", fill_program(hint))?; + writeln!(err, "提示: {hint}")?; } Ok(()) } @@ -134,7 +143,10 @@ pub fn progress_line(text: &str) { #[cfg(test)] mod tests { - use super::progress_line; + use super::{print_report, progress_line, Outcome, Report}; + use crate::cli::program::{self, PLACEHOLDER}; + use crate::error::CliError; + use serde_json::json; #[test] fn progress_line_never_panics() { @@ -142,4 +154,46 @@ mod tests { progress_line("测试进度 1/2"); progress_line(""); } + + #[test] + fn json_error_envelope_never_leaks_the_placeholder() { + // r2 的回归:JSON 分支直接序列化未渲染的 message/hint,`%prog%` 原样漏给脚本。 + let error = CliError::usage(format!("{PLACEHOLDER} 需要一个值")) + .with_hint(format!("{PLACEHOLDER} --help 查看命令列表")); + let envelope = serde_json::to_string(&error.to_json()).expect("序列化"); + assert!(!envelope.contains(PLACEHOLDER), "{envelope}"); + assert!(envelope.contains(&program::name()), "{envelope}"); + } + + #[test] + fn json_warnings_never_leak_the_placeholder() { + // warnings 是第二条 JSON 出口(以前连人类可读那条都没走到)。 + let report = Report::new( + json!({ "ok": true }), + vec![format!("{PLACEHOLDER} instances list 看看现有实例")], + ); + let outcome = + Outcome::with_warnings(report, vec![format!("用 {PLACEHOLDER} install 装版本")]); + let mut out: Vec = Vec::new(); + let mut err: Vec = Vec::new(); + print_report(&outcome, true, false, &mut out, &mut err).expect("写 JSON"); + let text = String::from_utf8_lossy(&out).into_owned(); + assert!(!text.contains(PLACEHOLDER), "{text}"); + assert!(text.contains(&program::name()), "{text}"); + } + + #[test] + fn human_lines_render_the_placeholder_too() { + let report = Report::new( + json!({}), + vec![format!("用 `{PLACEHOLDER} install` 登记实例")], + ); + assert_eq!( + report.human[0], + format!("用 `{} install` 登记实例", program::name()) + ); + // 用户数据不受影响:账户名恰好叫 ull 的那种 + let report = Report::new(json!({}), vec!["找不到账户 `ull`".to_string()]); + assert_eq!(report.human[0], "找不到账户 `ull`"); + } }