api_debug_lab/report.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! [`Report`] — what `diagnose` returns and the CLI prints.
4//!
5//! A report is a thin wrapper around a top-confidence [`Diagnosis`]
6//! plus zero or more `also_considered` alternatives. It owns the
7//! curl-shaped reproduction string and the case-level metadata that
8//! is rendered in both the human and JSON formats.
9//!
10//! Every formatter here must produce **byte-stable** output:
11//! re-running `diagnose` against the same case must produce a
12//! `Report` whose [`Report::render`] returns the exact same bytes,
13//! every time, on every machine. The snapshot tests in
14//! `tests/snapshots.rs` enforce this contract.
15
16use crate::cases::{Case, Severity};
17use crate::evidence::Evidence;
18use clap::ValueEnum;
19use serde::{Deserialize, Serialize};
20use std::fmt::Write as _;
21
22/// Output format for [`Report::render`].
23///
24/// The CLI exposes this via `--format`; library callers can pick
25/// whichever they need. Both formats are byte-stable for a given
26/// `Report`.
27#[derive(Debug, Clone, Copy, Default, ValueEnum)]
28pub enum Format {
29 /// Human-readable plain text. The shape a support engineer pastes
30 /// into a ticket: `CASE`, `SEVERITY`, `LIKELY CAUSE`, `CONFIDENCE`,
31 /// `RULE`, `EVIDENCE`, `REPRODUCTION`, `NEXT STEPS`,
32 /// `ESCALATION NOTE`, optional `ALSO CONSIDERED`.
33 #[default]
34 Human,
35 /// Pretty-printed JSON. Keys match the `Report` / `Diagnosis`
36 /// field names; suitable for piping into `jq` or another tool.
37 Json,
38}
39
40/// One firing diagnosis: the rule's claim, its confidence, the
41/// supporting evidence, and the rule's recommended remediation.
42///
43/// Every field is owned (no borrows) so a `Diagnosis` can be cloned
44/// across rule boundaries and serialised cheaply.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[must_use = "a Diagnosis is meaningless until rendered, inspected, or returned"]
47pub struct Diagnosis {
48 /// Stable identifier of the rule that produced this diagnosis
49 /// (e.g. `"auth_missing"`). Matches `Rule::id`.
50 pub rule_id: String,
51 /// One-sentence summary of the rule's claim. Rendered as the
52 /// `LIKELY CAUSE:` line.
53 pub likely_cause: String,
54 /// Posterior confidence in the diagnosis, in `[0.0, 1.0]`. The
55 /// rubric for these values lives in `docs/confidence_model.md`
56 /// and is held accountable by `tests/calibration.rs`.
57 pub confidence: f32,
58 /// Supporting observations. Each is a single sentence with an
59 /// optional source pointer; the `explain` subcommand surfaces the
60 /// pointers so the diagnosis can be audited.
61 pub evidence: Vec<Evidence>,
62 /// Customer-facing remediation steps in priority order.
63 pub next_steps: Vec<String>,
64 /// Engineering-facing escalation paragraph. Names the divergence
65 /// space and the artefacts the on-call engineer needs.
66 pub escalation: String,
67}
68
69/// Top-level result of [`crate::rules::diagnose`].
70///
71/// `primary` is `None` when no rule fired with confidence ≥ 0.6 (the
72/// classification threshold). `also_considered` is sorted by
73/// descending confidence and capped above by the primary's confidence.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75#[must_use = "a Report should be rendered, inspected, or its exit_code propagated"]
76pub struct Report {
77 /// Stable name of the case being diagnosed (matches `Case::name`).
78 pub case_name: String,
79 /// Severity tag from the case (mirrored verbatim).
80 pub severity: Severity,
81 /// Top-confidence diagnosis, if any rule fired above threshold.
82 pub primary: Option<Diagnosis>,
83 /// Other rules that fired, sorted by descending confidence and
84 /// bounded above by `primary.confidence`. Empty when only one
85 /// rule fired (or none did).
86 #[serde(default)]
87 pub also_considered: Vec<Diagnosis>,
88 /// Pre-rendered curl reproduction string. Header order is
89 /// alphabetical; the body is inlined with `--data-raw` so the
90 /// string is the same on every machine (no absolute paths).
91 pub reproduction: String,
92}
93
94impl Report {
95 /// Process exit code the CLI should return for this report.
96 ///
97 /// `0` if a primary diagnosis fired with confidence ≥ 0.60, `1`
98 /// if the case is unclassified or low-confidence. The threshold
99 /// matches the human formatter's "No rule matched" message.
100 /// Higher-level "bad input" errors are mapped to exit code `2`
101 /// by the CLI itself, not here.
102 ///
103 /// # Examples
104 ///
105 /// ```no_run
106 /// use api_debug_lab::{diagnose, Case};
107 /// use std::path::Path;
108 ///
109 /// let case = Case::load("auth_missing", Path::new("fixtures"))?;
110 /// assert_eq!(diagnose(&case).exit_code(), 0);
111 /// # Ok::<(), api_debug_lab::CaseLoadError>(())
112 /// ```
113 #[must_use = "the CLI must propagate this exit code via std::process::ExitCode"]
114 pub fn exit_code(&self) -> i32 {
115 match &self.primary {
116 Some(d) if d.confidence >= 0.6 => 0,
117 _ => 1,
118 }
119 }
120
121 /// Render the report in the requested [`Format`].
122 ///
123 /// Both formats are byte-stable: the same input always produces
124 /// the same output bytes. The JSON format uses pretty-printing
125 /// so diffs in PRs are legible.
126 ///
127 /// # Examples
128 ///
129 /// ```no_run
130 /// use api_debug_lab::{diagnose, Case, Format};
131 /// use std::path::Path;
132 ///
133 /// let case = Case::load("auth_missing", Path::new("fixtures"))?;
134 /// let report = diagnose(&case);
135 /// let human = report.render(Format::Human);
136 /// assert!(human.starts_with("CASE:"));
137 /// # Ok::<(), api_debug_lab::CaseLoadError>(())
138 /// ```
139 pub fn render(&self, format: Format) -> String {
140 match format {
141 Format::Human => self.render_human(),
142 Format::Json => {
143 serde_json::to_string_pretty(self).unwrap_or_else(|_| String::from("{}"))
144 }
145 }
146 }
147
148 /// Build the human-readable plain-text rendering.
149 ///
150 /// Private; callers go through [`Self::render`].
151 fn render_human(&self) -> String {
152 let mut out = String::new();
153 let _ = writeln!(out, "CASE: {}", self.case_name);
154 let _ = writeln!(out, "SEVERITY: {}", self.severity.as_str());
155 match &self.primary {
156 Some(d) => {
157 let _ = writeln!(out, "LIKELY CAUSE: {}", d.likely_cause);
158 let _ = writeln!(out, "CONFIDENCE: {:.2}", d.confidence);
159 let _ = writeln!(out, "RULE: {}", d.rule_id);
160 let _ = writeln!(out);
161 let _ = writeln!(out, "EVIDENCE:");
162 for e in &d.evidence {
163 let _ = writeln!(out, "- {}", e.message);
164 }
165 let _ = writeln!(out);
166 let _ = writeln!(out, "REPRODUCTION:");
167 let _ = writeln!(out, "{}", self.reproduction);
168 let _ = writeln!(out);
169 let _ = writeln!(out, "NEXT STEPS:");
170 for (i, step) in d.next_steps.iter().enumerate() {
171 let _ = writeln!(out, "{}. {}", i + 1, step);
172 }
173 let _ = writeln!(out);
174 let _ = writeln!(out, "ESCALATION NOTE:");
175 let _ = writeln!(out, "{}", d.escalation);
176 }
177 None => {
178 let _ = writeln!(out, "LIKELY CAUSE: unclassified");
179 let _ = writeln!(out, "CONFIDENCE: 0.00");
180 let _ = writeln!(out);
181 let _ = writeln!(
182 out,
183 "No rule matched with confidence \u{2265} 0.60. \
184 Inspect fixtures by hand and consider adding a new rule."
185 );
186 }
187 }
188 if !self.also_considered.is_empty() {
189 let _ = writeln!(out);
190 let _ = writeln!(out, "ALSO CONSIDERED:");
191 for d in &self.also_considered {
192 let _ = writeln!(
193 out,
194 "- {} (confidence {:.2}): {}",
195 d.rule_id, d.confidence, d.likely_cause
196 );
197 }
198 }
199 out
200 }
201}
202
203/// Build a deterministic curl reproduction string for a case.
204///
205/// The output is ready to paste into a shell:
206///
207/// ```text
208/// curl -X POST https://api.acme-co.example/v1/events \
209/// -H "content-type: application/json" \
210/// -H "user-agent: acme-client/0.4.1" \
211/// --data-raw '{"event":"order.created","order_id":"ord_8KZ"}'
212/// ```
213///
214/// Headers are emitted in alphabetical order; the body is inlined
215/// with `--data-raw` (single-quoted with embedded single-quotes
216/// shell-escaped) rather than referenced via `--data-binary @file`.
217/// The latter would bake an absolute path into the output and break
218/// snapshot stability across machines.
219pub fn reproduction(case: &Case) -> String {
220 let mut out = String::new();
221 let _ = write!(out, "curl -X {} {}", case.request.method, case.request.url);
222 let mut keys: Vec<&String> = case.request.headers.keys().collect();
223 keys.sort();
224 for k in keys {
225 let v = &case.request.headers[k];
226 let _ = write!(out, " \\\n -H \"{k}: {v}\"");
227 }
228 if let Some(body) = case.request.body.as_deref() {
229 // Shell-escape only the single-quote, since we wrap the body
230 // in single quotes. This is sufficient for paste-into-bash;
231 // it is not a general-purpose shell escaper.
232 let escaped = body.replace('\'', "'\\''");
233 let _ = write!(out, " \\\n --data-raw '{escaped}'");
234 }
235 out
236}