Skip to main content

cmx_core/
error_summary.rs

1use anyhow::Error;
2
3const MAX_LEN: usize = 120;
4const WRAPPER_PREFIXES: &[&str] = &["LLM analysis failed:", "LLM gateway error:"];
5
6/// Reduce a nested gateway/provider error chain to a short, one-line phrase
7/// suitable for CLI degradation notes.
8pub fn summarize_gateway_error(error: &Error) -> String {
9    let flattened = collapse_whitespace(&format!("{error:#}"));
10    let without_wrappers = strip_wrappers(&flattened);
11    let without_body =
12        trim_suffix_at_any(without_wrappers, &[" - {", " - [", " caused by: ", " Caused by: "]);
13
14    let summary = if let Some(openai) = provider_phrase(without_body, "OpenAI API error:") {
15        openai
16    } else if looks_like_ollama_unreachable(without_body) {
17        "Ollama unreachable at localhost:11434".to_string()
18    } else {
19        without_body.to_string()
20    };
21
22    truncate(&summary)
23}
24
25fn collapse_whitespace(input: &str) -> String {
26    input.split_whitespace().collect::<Vec<_>>().join(" ")
27}
28
29fn strip_wrappers(mut text: &str) -> &str {
30    loop {
31        let mut stripped = false;
32        for prefix in WRAPPER_PREFIXES {
33            if let Some(rest) = text.strip_prefix(prefix) {
34                text = rest.trim_start();
35                stripped = true;
36                break;
37            }
38        }
39        if !stripped {
40            return text;
41        }
42    }
43}
44
45fn trim_suffix_at_any<'a>(text: &'a str, delimiters: &[&str]) -> &'a str {
46    let cutoff = delimiters.iter().filter_map(|d| text.find(d)).min().unwrap_or(text.len());
47    text[..cutoff].trim_end_matches([' ', ':'])
48}
49
50fn provider_phrase(text: &str, marker: &str) -> Option<String> {
51    let start = text.find(marker)?;
52    Some(text[start..].trim().to_string())
53}
54
55fn looks_like_ollama_unreachable(text: &str) -> bool {
56    let lower = text.to_ascii_lowercase();
57    (lower.contains("localhost:11434") || lower.contains("ollama"))
58        && (lower.contains("connection refused")
59            || lower.contains("failed to connect")
60            || lower.contains("error sending request")
61            || lower.contains("tcp connect error"))
62}
63
64fn truncate(text: &str) -> String {
65    if text.chars().count() <= MAX_LEN {
66        text.to_string()
67    } else {
68        let head: String = text.chars().take(MAX_LEN).collect();
69        format!("{head}…")
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::summarize_gateway_error;
76
77    #[test]
78    fn summarize_gateway_error_keeps_short_message() {
79        assert_eq!(summarize_gateway_error(&anyhow::anyhow!("short error")), "short error");
80    }
81
82    #[test]
83    fn summarize_gateway_error_strips_openai_json_body() {
84        let error = anyhow::anyhow!(
85            "LLM analysis failed: LLM gateway error: OpenAI API error: 401 Unauthorized - {{ \
86             \"error\": {{ \"message\": \"You didn't provide an API key\", \"type\": \
87             \"invalid_request_error\" }} }}"
88        );
89
90        let summary = summarize_gateway_error(&error);
91        assert_eq!(summary, "OpenAI API error: 401 Unauthorized");
92        assert!(!summary.contains('{'));
93        assert!(!summary.contains('\n'));
94        assert!(!summary.contains("\"error\""));
95    }
96}