Skip to main content

llm_assisted_api_debugging_lab/
report.rs

1//! Human-readable report renderer.
2//!
3//! Pure: takes a [`Diagnosis`], returns a `String`. No I/O. Output
4//! formats — both the full report ([`render_report`]) and the short
5//! summary used by the `diagnose` subcommand ([`render_short`]) — are
6//! pinned by snapshot tests under
7//! `crates/llm-assisted-api-debugging-lab/tests/snapshots/`.
8//!
9//! ## Why no sanitization here
10//!
11//! Unlike [`crate::llm_prompt`], this module deliberately does **not**
12//! pass evidence through a sanitizer. These outputs are read by humans
13//! (in a terminal, in an escalation queue), not fed to a model. A
14//! human reader benefits from seeing the literal log message exactly
15//! as it appeared, including any newlines or backticks; a model would
16//! be vulnerable to that same content as injection. The split is
17//! intentional and load-bearing — see `docs/llm_assisted_workflow.md`
18//! for the threat model.
19
20use crate::diagnose::Diagnosis;
21use crate::evidence::Evidence;
22use std::fmt::Write;
23
24/// Compact one-screen summary used by the `diagnose` subcommand.
25///
26/// Includes case, rule, severity (rank + provenance label only — the
27/// full rationale is left to [`render_report`]), likely cause, the
28/// pinned evidence list, and the reproduction command. Omits
29/// hypotheses, unknowns, next-steps, and the escalation note. The
30/// `diagnose` subcommand is the most-shown surface (first command in
31/// `scripts/run_demo.sh`), so the output is intentionally short.
32pub fn render_short(d: &Diagnosis) -> String {
33    let mut s = String::new();
34    let _ = writeln!(s, "CASE: {}", d.case);
35    let _ = writeln!(s, "RULE: {}", d.rule);
36    let _ = writeln!(
37        s,
38        "SEVERITY: {} ({})",
39        d.severity.as_str(),
40        d.severity_source.label()
41    );
42    let _ = writeln!(s, "LIKELY CAUSE: {}", d.likely_cause);
43    if !d.evidence.is_empty() {
44        s.push_str("EVIDENCE:\n");
45        for e in &d.evidence {
46            let _ = writeln!(s, "- {}", render_evidence(e));
47        }
48    }
49    let _ = writeln!(s, "REPRODUCTION:\n{}", d.reproduction);
50    s
51}
52
53/// Full human-readable report for the `report` subcommand.
54///
55/// Includes everything `render_short` shows plus the severity rationale,
56/// hypotheses, unknowns, ordered next-steps, and the escalation note.
57/// The same `Diagnosis` data is presented; this just shows more of it.
58///
59/// Section order mirrors `render_prompt` so a reader who has seen one
60/// can navigate the other instinctively. Section labels distinguish the
61/// two: report headers are bare (`HYPOTHESES`); prompt headers carry
62/// instructions (`HYPOTHESES (consistent with evidence; may be true or
63/// false):`).
64pub fn render_report(d: &Diagnosis) -> String {
65    let mut s = String::new();
66
67    let _ = writeln!(s, "CASE: {}", d.case);
68    let _ = writeln!(s, "RULE: {}", d.rule);
69    let _ = writeln!(
70        s,
71        "SEVERITY: {} ({}: {})",
72        d.severity.as_str(),
73        d.severity_source.label(),
74        d.severity_source.rationale()
75    );
76    let _ = writeln!(s, "LIKELY CAUSE: {}", d.likely_cause);
77    s.push('\n');
78
79    s.push_str("EVIDENCE:\n");
80    if d.evidence.is_empty() {
81        s.push_str("- (none collected)\n");
82    } else {
83        for e in &d.evidence {
84            let _ = writeln!(s, "- {}", render_evidence(e));
85        }
86    }
87    s.push('\n');
88
89    s.push_str("HYPOTHESES (consistent with evidence; not asserted as fact):\n");
90    if d.hypotheses.is_empty() {
91        s.push_str("- (none)\n");
92    } else {
93        for h in &d.hypotheses {
94            let _ = writeln!(s, "- {h}");
95        }
96    }
97    s.push('\n');
98
99    s.push_str("UNKNOWNS (do not invent answers):\n");
100    if d.unknowns.is_empty() {
101        s.push_str("- (none)\n");
102    } else {
103        for u in &d.unknowns {
104            let _ = writeln!(s, "- {u}");
105        }
106    }
107    s.push('\n');
108
109    let _ = writeln!(s, "REPRODUCTION:\n{}\n", d.reproduction);
110
111    s.push_str("NEXT STEPS:\n");
112    for (i, step) in d.next_steps.iter().enumerate() {
113        let _ = writeln!(s, "{}. {}", i + 1, step);
114    }
115    s.push('\n');
116
117    let _ = write!(s, "ESCALATION NOTE:\n{}\n", d.escalation_note);
118
119    s
120}
121
122/// Format a single [`Evidence`] item as one line of human-readable text.
123///
124/// This is also the input to [`crate::llm_prompt::sanitize_for_prompt`]:
125/// the prompt renderers call `render_evidence` then sanitize the result.
126/// Keeping a single rendering path means a wording change to evidence
127/// surfaces consistently in both human and prompt output.
128///
129/// Pure: no I/O, deterministic for any input variant.
130pub fn render_evidence(e: &Evidence) -> String {
131    match e {
132        Evidence::HttpStatus(code) => format!("HTTP status: {code}"),
133        Evidence::HeaderPresent { name, value } => match value {
134            Some(v) => format!("Request header present: {name} = {v}"),
135            None => format!("Request header present: {name}"),
136        },
137        Evidence::HeaderMissing { name } => {
138            format!("Request header missing: {name}")
139        }
140        Evidence::BodyMutatedBeforeVerification => {
141            "Request body was modified by middleware before verification.".into()
142        }
143        Evidence::SignatureMismatch => "HMAC signature verification failed.".into(),
144        Evidence::ClockDriftSecs {
145            observed,
146            tolerance_secs,
147        } => format!("Clock drift {observed}s exceeds tolerance {tolerance_secs}s."),
148        Evidence::RetryAfterSecs(secs) => format!("Retry-After header: {secs}s"),
149        Evidence::RateLimitObserved {
150            observed_rps,
151            limit_rps,
152        } => format!("Observed rate {observed_rps} rps exceeds account limit {limit_rps} rps."),
153        Evidence::DnsResolutionFailed { host, message } => {
154            format!("DNS resolution failed for {host}: {message}")
155        }
156        Evidence::TlsHandshakeFailed { peer, reason } => {
157            format!("TLS handshake to {peer} failed: {reason}")
158        }
159        Evidence::ConnectionTimeout {
160            elapsed_ms,
161            timeout_ms,
162        } => format!("Client timeout: aborted after {elapsed_ms}ms (timeout {timeout_ms}ms)."),
163        Evidence::JsonValidationError { field, message } => match field {
164            Some(f) => format!("JSON validation error on field `{f}`: {message}"),
165            None => format!("JSON validation error: {message}"),
166        },
167    }
168}