use crate::output::json::JsonError;
use crate::path_display::display_relative_to_cwd;
use crate::style::Palette;
use std::io;
use std::path::Path;
pub fn render_human_error(
err: &JsonError,
palette: Palette,
writer: &mut dyn io::Write,
) -> io::Result<()> {
if err.diagnostics.is_empty() {
render_single_issue(err, palette, writer)
} else {
render_multi_issue(err, palette, writer)
}
}
fn render_single_issue(
err: &JsonError,
palette: Palette,
writer: &mut dyn io::Write,
) -> io::Result<()> {
let tag = palette.tag.render();
let tag_reset = palette.tag.render_reset();
let message = human_message(err);
match err.field.as_deref() {
Some(f) => {
let field = human_field(f);
writeln!(
writer,
"{tag}[{}]{tag_reset} {}: {}",
err.code, field, message
)?
}
None => writeln!(writer, "{tag}[{}]{tag_reset} {}", err.code, message)?,
}
let dim = palette.dim.render();
let dim_reset = palette.dim.render_reset();
for c in &err.cause {
writeln!(writer, " {dim}cause:{dim_reset} {c}")?;
}
Ok(())
}
fn render_multi_issue(
err: &JsonError,
palette: Palette,
writer: &mut dyn io::Write,
) -> io::Result<()> {
let header = palette.error.render();
let header_reset = palette.error.render_reset();
writeln!(
writer,
"{header}validation failed: {} diagnostic(s){header_reset}",
err.diagnostics.len()
)?;
let dim = palette.dim.render();
let dim_reset = palette.dim.render_reset();
let tag = palette.tag.render();
let tag_reset = palette.tag.render_reset();
for (i, d) in err.diagnostics.iter().enumerate() {
let message = human_message(d);
match d.field.as_deref() {
Some(f) => {
let field = human_field(f);
writeln!(
writer,
" {dim}{}.{dim_reset} {tag}[{}]{tag_reset} {}: {}",
i + 1,
d.code,
field,
message,
)?
}
None => writeln!(
writer,
" {dim}{}.{dim_reset} {tag}[{}]{tag_reset} {}",
i + 1,
d.code,
message,
)?,
}
}
Ok(())
}
fn human_field(field: &str) -> String {
let path = Path::new(field);
if path.is_absolute() {
display_relative_to_cwd(path)
} else {
field.to_owned()
}
}
fn human_message(err: &JsonError) -> String {
let mut message = err.message.clone();
for (absolute, display) in path_replacements(err) {
message = message.replace(&absolute, &display);
}
message
}
fn path_replacements(err: &JsonError) -> Vec<(String, String)> {
let mut replacements = Vec::new();
if let Some(field) = err.field.as_deref() {
push_path_replacement(field, &mut replacements);
}
if let Some(details) = err.details.as_ref().and_then(|v| v.as_object()) {
for (key, value) in details {
if path_detail_key(key)
&& let Some(value) = value.as_str()
{
push_path_replacement(value, &mut replacements);
}
}
}
replacements.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
replacements.dedup_by(|a, b| a.0 == b.0);
replacements
}
fn push_path_replacement(value: &str, replacements: &mut Vec<(String, String)>) {
let path = Path::new(value);
if !path.is_absolute() {
return;
}
let display = display_relative_to_cwd(path);
if display != value {
replacements.push((value.to_owned(), display));
}
}
fn path_detail_key(key: &str) -> bool {
matches!(key, "path" | "index" | "out" | "target_dir")
|| key.ends_with("_path")
|| key.ends_with("_dir")
}
#[cfg(test)]
mod tests {
use super::*;
fn plain() -> Palette {
Palette::default()
}
fn je(code: &str, message: &str, field: Option<&str>) -> JsonError {
JsonError {
code: code.into(),
message: message.into(),
field: field.map(str::to_owned),
details: None,
diagnostics: vec![],
cause: vec![],
}
}
#[test]
fn render_human_error_single_issue_emits_one_line_with_code_field_message() {
let err = je(
"package::canonical_collision",
"name conflicts",
Some("plugin.name"),
);
let mut buf = Vec::new();
render_human_error(&err, plain(), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert_eq!(
s,
"[package::canonical_collision] plugin.name: name conflicts\n",
);
}
#[test]
fn render_human_error_single_issue_omits_field_prefix_when_absent() {
let err = je("usage::missing_subcommand", "subcommand required", None);
let mut buf = Vec::new();
render_human_error(&err, plain(), &mut buf).unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"[usage::missing_subcommand] subcommand required\n",
);
}
#[test]
fn render_human_error_renders_cause_chain_when_present() {
let err = JsonError {
code: "io::read_failed".into(),
message: "failed to read --index /tmp/idx.json".into(),
field: Some("/tmp/idx.json".into()),
details: None,
diagnostics: vec![],
cause: vec!["No such file or directory (os error 2)".into()],
};
let mut buf = Vec::new();
render_human_error(&err, plain(), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.starts_with(
"[io::read_failed] /tmp/idx.json: failed to read --index /tmp/idx.json\n"
));
assert!(s.contains(" cause: No such file or directory (os error 2)\n"));
}
#[test]
fn render_human_error_multi_issue_renders_numbered_block() {
let err = JsonError {
code: "validate::failed".into(),
message: "2 validation diagnostic(s)".into(),
field: None,
details: None,
diagnostics: vec![
je(
"validate::missing_required_file",
"required file \"x\" missing",
Some("x"),
),
je("validate::python_parse", "syntax", Some("__init__.py")),
],
cause: vec![],
};
let mut buf = Vec::new();
render_human_error(&err, plain(), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.starts_with("validation failed: 2 diagnostic(s)\n"));
assert!(s.contains(" 1. [validate::missing_required_file]"));
assert!(s.contains(" 2. [validate::python_parse]"));
}
#[test]
fn render_human_error_schema_reported_does_not_double_prefix_field() {
let err = JsonError {
code: "validate::failed".into(),
message: "1 validation diagnostic(s)".into(),
field: None,
details: None,
diagnostics: vec![je(
"validate::schema_reported",
"plugin name \"X\" must match ...",
Some("plugin.name"),
)],
cause: vec![],
};
let mut buf = Vec::new();
render_human_error(&err, plain(), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert_eq!(s.matches("plugin.name:").count(), 1);
}
#[test]
fn render_human_error_replaces_path_like_detail_values() {
let cwd = std::env::current_dir().unwrap();
let target = cwd.join("target-dir-detail-test");
let target = target.display().to_string();
let err = JsonError {
code: "new::scaffold_failed".into(),
message: format!("failed to create {target}"),
field: None,
details: Some(serde_json::json!({ "target_dir": target })),
diagnostics: vec![],
cause: vec![],
};
let mut buf = Vec::new();
render_human_error(&err, plain(), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("target-dir-detail-test"));
assert!(
!s.contains(&cwd.display().to_string()),
"path-like detail value should be shortened, got: {s}"
);
}
#[test]
fn render_human_error_ignores_unknown_detail_keys() {
let cwd = std::env::current_dir().unwrap();
let value = cwd.join("not-a-path-detail").display().to_string();
let err = JsonError {
code: "usage::invalid_value".into(),
message: format!("invalid value {value}"),
field: Some("plugin.name".into()),
details: Some(serde_json::json!({ "value": value })),
diagnostics: vec![],
cause: vec![],
};
let mut buf = Vec::new();
render_human_error(&err, plain(), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(&cwd.display().to_string()));
assert!(s.contains("plugin.name:"));
}
}