Skip to main content

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}