fix(cli): 程序名占位符在构造时渲染,两条出口(人类可读 / --json)不再分叉(M82 r3 复审 P2)

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」这种用户数据不被改)。
This commit is contained in:
AskaEth committed 2026-11-02 08:08:16 +08:00
1 parent 2f8bbd1746
commit 8ca1e1e965
2 files changed
+75 -13

No files matched your search

+12 -4
View File
@@ -56,6 +56,14 @@ pub struct CliError {
hint: Option<String>, hint: Option<String>,
} }
/// 把模板里的程序名占位符渲染掉(M82 r3)。
///
/// 渲染放在**构造时**,而不是打印时:错误有两条出口(人类可读 stderr、`--json` 信封),
/// 只在打印处渲染会漏掉 JSON 那条(r2 就是这么漏的,`%prog%` 直接出现在了 JSON 里)。
fn render(text: impl Into<String>) -> String {
crate::cli::program::fill(&text.into())
}
impl CliError { impl CliError {
/// 用法错误(参数不对)。 /// 用法错误(参数不对)。
#[must_use] #[must_use]
@@ -63,7 +71,7 @@ impl CliError {
Self { Self {
kind: ErrorKind::User, kind: ErrorKind::User,
code: "usage", code: "usage",
message: message.into(), message: render(message),
hint: None, hint: None,
} }
} }
@@ -74,7 +82,7 @@ impl CliError {
Self { Self {
kind: ErrorKind::User, kind: ErrorKind::User,
code, code,
message: message.into(), message: render(message),
hint: None, hint: None,
} }
} }
@@ -85,7 +93,7 @@ impl CliError {
Self { Self {
kind: ErrorKind::Internal, kind: ErrorKind::Internal,
code, code,
message: message.into(), message: render(message),
hint: None, hint: None,
} }
} }
@@ -99,7 +107,7 @@ impl CliError {
/// 补一句提示。 /// 补一句提示。
#[must_use] #[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self { pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into()); self.hint = Some(render(hint));
self self
} }
+63 -9
View File
@@ -20,10 +20,13 @@ pub struct Report {
} }
impl Report { impl Report {
/// 构造。 /// 构造(人类可读行在这里就把程序名占位符渲染掉,见 [`crate::cli::program::PLACEHOLDER`])。
#[must_use] #[must_use]
pub fn new(data: Value, human: Vec<String>) -> Self { pub fn new(data: Value, human: Vec<String>) -> 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] #[must_use]
pub fn with_warnings(report: Report, warnings: Vec<String>) -> Self { pub fn with_warnings(report: Report, warnings: Vec<String>) -> 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 { if !quiet {
for line in &outcome.report.human { for line in &outcome.report.human {
writeln!(out, "{}", fill_program(line))?; writeln!(out, "{line}")?;
} }
} }
for warning in &outcome.warnings { for warning in &outcome.warnings {
writeln!(err, "提示: {}", fill_program(warning))?; writeln!(err, "提示: {warning}")?;
} }
Ok(()) Ok(())
} }
@@ -101,9 +110,9 @@ pub fn print_error(
writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?; writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?;
return Ok(()); return Ok(());
} }
writeln!(err, "错误: {}", fill_program(error.message()))?; writeln!(err, "错误: {}", error.message())?;
if let Some(hint) = error.hint() { if let Some(hint) = error.hint() {
writeln!(err, "提示: {}", fill_program(hint))?; writeln!(err, "提示: {hint}")?;
} }
Ok(()) Ok(())
} }
@@ -134,7 +143,10 @@ pub fn progress_line(text: &str) {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn progress_line_never_panics() { fn progress_line_never_panics() {
@@ -142,4 +154,46 @@ mod tests {
progress_line("测试进度 1/2"); progress_line("测试进度 1/2");
progress_line(""); 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<u8> = Vec::new();
let mut err: Vec<u8> = 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`");
}
} }