use std::io::{self, Write};
use anyhow::Error;
pub(crate) fn format_chain(err: &Error) {
let mut stderr = io::stderr().lock();
let entries: Vec<String> = err
.chain()
.filter(|e| !e.is::<crate::exit::UsageErrorTag>())
.map(ToString::to_string)
.collect();
let mut summary: Option<&str> = None;
let mut prefix_lines: Vec<String> = Vec::new();
let mut caused_by: Vec<&str> = Vec::new();
for entry in &entries {
if let Some(stripped) = entry.strip_prefix("suggestion: ") {
prefix_lines.push(format!("suggestion: {stripped}"));
continue;
}
if let Some(stripped) = entry.strip_prefix("at: ") {
prefix_lines.push(format!("at: {stripped}"));
continue;
}
if summary.is_none() {
summary = Some(entry.as_str());
} else {
caused_by.push(entry.as_str());
}
}
let summary = summary.unwrap_or("operation failed");
let _ = writeln!(stderr, "error: {summary}");
for line in &prefix_lines {
let _ = writeln!(stderr, "{line}");
}
if !caused_by.is_empty() {
let _ = writeln!(stderr, "Caused by:");
for (idx, msg) in caused_by.iter().enumerate() {
let _ = writeln!(stderr, " {idx}: {msg}");
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code: unwrap/expect on synthetic chains is the expected diagnostic"
)]
mod tests {
use super::*;
#[test]
fn chain_walks_top_to_bottom() {
let err: Error = anyhow::anyhow!("missing field `type` at line 7 column 3")
.context("parse config file")
.context("loading listener");
let mut count = 0;
for c in err.chain() {
count += 1;
assert!(!c.to_string().is_empty(), "chain entry {count}");
}
assert_eq!(count, 3);
}
#[test]
fn suggestion_prefix_recognised_for_special_rendering() {
let err: Error = anyhow::anyhow!("aviso admin wipe-all requires explicit confirmation")
.context("suggestion: add --yes (this command is destructive and not reversible)");
let chain: Vec<String> = err.chain().map(ToString::to_string).collect();
assert!(chain.iter().any(|s| s.starts_with("suggestion: ")));
}
#[test]
fn at_prefix_recognised_for_special_rendering() {
let err: Error = anyhow::anyhow!("YAML parse error: missing field `type`")
.context("at: /home/alice/.config/aviso/config.yaml");
let chain: Vec<String> = err.chain().map(ToString::to_string).collect();
assert!(chain.iter().any(|s| s.starts_with("at: ")));
}
#[test]
fn usage_error_tag_is_filtered_from_displayed_chain() {
let err = crate::exit::usage_error("missing --yes for destructive admin command")
.context("running `aviso admin wipe-all`");
let displayed: Vec<String> = err
.chain()
.filter(|e| !e.is::<crate::exit::UsageErrorTag>())
.map(ToString::to_string)
.collect();
assert!(
!displayed.iter().any(|s| s == "usage error"),
"the `usage error` tag string MUST NOT appear in the user-visible chain (it is an internal exit-code marker, not an actionable cause); got: {displayed:?}",
);
assert!(
displayed.iter().any(|s| s.contains("missing --yes")),
"filter must keep the actual user-facing messages: {displayed:?}",
);
}
#[test]
fn usage_error_tag_filter_does_not_break_exit_code_routing() {
let err = crate::exit::usage_error("missing --yes for destructive admin command")
.context("running `aviso admin wipe-all`");
assert_eq!(
crate::exit::exit_code_for_anyhow(&err),
crate::exit::USAGE_ERROR,
"filtering the tag from the DISPLAYED chain must not remove it from the underlying anyhow::Error; exit-code routing relies on downcast and must still see the tag",
);
}
}