Skip to main content

llm_assisted_api_debugging_lab/
cases.rs

1//! Case fixture model and JSON loader.
2//!
3//! A [`Case`] is a sanitized HTTP transaction plus environmental
4//! [`Context`]. Each variant the diagnoser cares about lives as an
5//! explicit field — the struct is **not** a generic `serde_json::Value`
6//! bag, because we want serde to fail loudly when a fixture changes shape
7//! in a way the code didn't expect.
8//!
9//! Loading is gated on two checks (in order):
10//! 1. The requested name is in [`KNOWN_CASES`] — see [`case_fixture_path`].
11//! 2. The loaded JSON's `name` field matches the requested name — see
12//!    [`load_case`]. A mismatch is a fixture-edit bug
13//!    ([`CaseError::NameMismatch`]).
14//!
15//! Bidirectional consistency between [`KNOWN_CASES`] and the on-disk
16//! `fixtures/cases/*.json` set is enforced by the
17//! `known_cases_matches_on_disk_fixtures` test below.
18
19use serde::Deserialize;
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use thiserror::Error;
23
24/// Every case the binary knows how to load. Adding a new case requires:
25/// 1. A new entry here (alphabetical-ish, but grouped by failure family).
26/// 2. A matching `fixtures/cases/<name>.json` file.
27/// 3. A matching `fixtures/logs/<name>.log` file.
28/// 4. A `[rules.<rule_name>]` section in `prose.toml` if the case
29///    triggers a rule that doesn't already exist.
30///
31/// The `known_cases_matches_on_disk_fixtures` test enforces (1)+(2)
32/// stay in sync; new fixture without a constant entry — or vice versa —
33/// breaks `cargo test`.
34pub const KNOWN_CASES: &[&str] = &[
35    "auth_missing",
36    "bad_payload",
37    "rate_limit",
38    "webhook_signature",
39    "timeout",
40    "dns_config",
41    "tls_failure",
42    "injection_attempt",
43];
44
45/// Errors the case loader can produce.
46///
47/// The variants are designed to support a meaningful exit-code mapping
48/// in any binary that uses this crate. The `llm-assisted-api-debugging-lab` binary in
49/// this repo follows the convention:
50///
51/// - `Unknown` → exit code 2 (caller passed a bad name).
52/// - `Io`, `Parse`, `NameMismatch` → exit code 3 (a fixture file is
53///   broken).
54///
55/// The distinction matters for `set -e`-style shell integration where
56/// the caller wants to react differently to "you misspelled the
57/// argument" vs "the on-disk fixture is corrupt." Library consumers can
58/// adopt the same convention or pick their own.
59#[derive(Debug, Error)]
60pub enum CaseError {
61    #[error("unknown case: {0}")]
62    Unknown(String),
63    #[error("could not read case file {0}: {1}")]
64    Io(PathBuf, #[source] std::io::Error),
65    #[error("could not parse case file {0}: {1}")]
66    Parse(PathBuf, #[source] serde_json::Error),
67    #[error("case file {path} declares name {found:?} but was loaded as {expected:?}")]
68    NameMismatch {
69        path: PathBuf,
70        expected: String,
71        found: String,
72    },
73}
74
75/// One sanitized HTTP transaction, plus environmental context, plus a
76/// pointer to the log file that recorded it.
77///
78/// The shape mirrors what an on-call engineer would attach to an
79/// escalation: the request that went out, the response that came back
80/// (if any), what the caller's own stack observed at the network layer
81/// (`Context`), and a pointer to the relevant log slice.
82#[derive(Debug, Clone, Deserialize)]
83pub struct Case {
84    /// Stable case identifier; must match the JSON file's basename.
85    pub name: String,
86    /// One-line human description of what this fixture exercises.
87    /// Currently consumed only by docs/snapshots; not load-bearing.
88    #[serde(default)]
89    pub description: String,
90    pub request: Request,
91    /// Absent for connection-layer failures (DNS, TLS, timeout) where no
92    /// response was received.
93    #[serde(default)]
94    pub response: Option<Response>,
95    #[serde(default)]
96    pub context: Context,
97    /// Path to the log file relative to the project root. Resolved by
98    /// [`log_path_for`].
99    #[serde(default)]
100    pub log_path: Option<PathBuf>,
101}
102
103/// Sanitized HTTP request as authored in the fixture file.
104#[derive(Debug, Clone, Deserialize)]
105pub struct Request {
106    pub method: String,
107    pub url: String,
108    /// `BTreeMap` rather than `HashMap` so the iteration order is stable
109    /// for snapshot tests. Header lookups are O(log n) but with ~6
110    /// headers per fixture that's irrelevant.
111    #[serde(default)]
112    pub headers: BTreeMap<String, String>,
113    /// One-line summary of the request body. Sensitive values are masked
114    /// with `***` at fixture-authoring time; the diagnoser does not
115    /// further redact.
116    #[serde(default)]
117    pub body_summary: String,
118    #[serde(default)]
119    pub client_unix_ts: Option<i64>,
120    /// Client-side timeout budget. Compared against
121    /// `Context::elapsed_ms_before_abort` to detect timeouts.
122    #[serde(default)]
123    pub timeout_ms: Option<u64>,
124}
125
126/// Sanitized HTTP response. Absent on `Case` (i.e. `Case::response` is
127/// `None`) for connection-layer failures where no response was received.
128#[derive(Debug, Clone, Deserialize)]
129pub struct Response {
130    pub status: u16,
131    #[serde(default)]
132    pub headers: BTreeMap<String, String>,
133    #[serde(default)]
134    pub body_summary: String,
135    #[serde(default)]
136    pub server_unix_ts: Option<i64>,
137    #[serde(default)]
138    pub elapsed_ms: Option<u64>,
139}
140
141/// Environmental observations from the caller's own network stack —
142/// things that don't appear in the HTTP transaction itself.
143///
144/// All fields are `Option` and `#[serde(default)]` so a fixture only
145/// needs to set the ones relevant to its failure mode. Unset fields are
146/// "we didn't observe this," not "we observed it as zero."
147#[derive(Debug, Clone, Default, Deserialize)]
148pub struct Context {
149    /// `Some(false)` means DNS resolution was attempted and failed;
150    /// `Some(true)` or `None` means it either succeeded or wasn't
151    /// reached.
152    #[serde(default)]
153    pub dns_resolved: Option<bool>,
154    #[serde(default)]
155    pub dns_error: Option<String>,
156    #[serde(default)]
157    pub dns_host: Option<String>,
158    /// Time spent in TLS handshake on a successful connection.
159    #[serde(default)]
160    pub tls_handshake_ms: Option<u64>,
161    /// `Some(true)` indicates the handshake was attempted and failed.
162    #[serde(default)]
163    pub tls_handshake_failed: Option<bool>,
164    #[serde(default)]
165    pub tls_failure_reason: Option<String>,
166    #[serde(default)]
167    pub tls_peer: Option<String>,
168    /// Skew between the client's clock and the server's, in seconds.
169    /// Sign indicates direction (negative = client behind server).
170    /// Absolute value is what gets compared to `signature_tolerance_secs`.
171    #[serde(default)]
172    pub client_clock_skew_secs: Option<i64>,
173    /// Set by webhook-receiver fixtures; toggles whether clock-skew
174    /// evidence is even meaningful for this case.
175    #[serde(default)]
176    pub signing_required: Option<bool>,
177    /// HMAC tolerance window in seconds.
178    #[serde(default)]
179    pub signature_tolerance_secs: Option<u64>,
180    /// Set when middleware re-encoded the JSON body between the wire and
181    /// HMAC verification. Drives the `BodyMutatedBeforeVerification`
182    /// evidence variant.
183    #[serde(default)]
184    pub body_mutated_before_verification: Option<bool>,
185    /// Wall-clock time the client spent waiting before giving up on a
186    /// non-arriving response.
187    #[serde(default)]
188    pub elapsed_ms_before_abort: Option<u64>,
189    /// Free-text error string from the caller's network stack. Used for
190    /// rendering only; rules don't read this directly.
191    #[serde(default)]
192    pub connection_error: Option<String>,
193}
194
195/// Look up the on-disk path for a known case name. Returns `Err(Unknown)`
196/// when the name is not in `KNOWN_CASES`.
197pub fn case_fixture_path(fixtures_dir: &Path, name: &str) -> Result<PathBuf, CaseError> {
198    if !KNOWN_CASES.contains(&name) {
199        return Err(CaseError::Unknown(name.to_string()));
200    }
201    Ok(fixtures_dir.join("cases").join(format!("{name}.json")))
202}
203
204/// Load a single case fixture by name from the given fixtures directory.
205///
206/// Asserts that the loaded fixture's `name` field matches the requested
207/// name; a mismatch returns `CaseError::NameMismatch` rather than silently
208/// producing a wrong-cased diagnosis.
209pub fn load_case(fixtures_dir: &Path, name: &str) -> Result<Case, CaseError> {
210    let path = case_fixture_path(fixtures_dir, name)?;
211    let bytes = std::fs::read(&path).map_err(|e| CaseError::Io(path.clone(), e))?;
212    let case: Case =
213        serde_json::from_slice(&bytes).map_err(|e| CaseError::Parse(path.clone(), e))?;
214    if case.name != name {
215        return Err(CaseError::NameMismatch {
216            path,
217            expected: name.to_string(),
218            found: case.name,
219        });
220    }
221    Ok(case)
222}
223
224/// Resolve the log file path for a case, relative to the fixtures directory's
225/// parent (so a case that records `fixtures/logs/foo.log` resolves correctly
226/// regardless of the current working directory).
227pub fn log_path_for(case: &Case, project_root: &Path) -> Option<PathBuf> {
228    case.log_path.as_ref().map(|p| project_root.join(p))
229}
230
231#[cfg(test)]
232mod tests {
233    #![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
234    use super::*;
235
236    fn fixtures_dir() -> PathBuf {
237        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
238    }
239
240    #[test]
241    fn known_cases_all_load() {
242        for name in KNOWN_CASES {
243            let case =
244                load_case(&fixtures_dir(), name).unwrap_or_else(|e| panic!("loading {name}: {e}"));
245            assert_eq!(case.name, *name, "case file name field must match filename");
246        }
247    }
248
249    #[test]
250    fn unknown_case_is_rejected() {
251        let err = load_case(&fixtures_dir(), "not_a_real_case").unwrap_err();
252        assert!(matches!(err, CaseError::Unknown(_)));
253    }
254
255    #[test]
256    fn known_cases_matches_on_disk_fixtures() {
257        let cases_dir = fixtures_dir().join("cases");
258        let mut on_disk: Vec<String> = std::fs::read_dir(&cases_dir)
259            .unwrap_or_else(|e| panic!("reading {}: {e}", cases_dir.display()))
260            .filter_map(|entry| entry.ok())
261            .map(|entry| entry.path())
262            .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json"))
263            .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(str::to_string))
264            .collect();
265        on_disk.sort();
266        let mut declared: Vec<String> = KNOWN_CASES.iter().map(|s| s.to_string()).collect();
267        declared.sort();
268        assert_eq!(
269            declared, on_disk,
270            "KNOWN_CASES must match the set of *.json files under fixtures/cases/"
271        );
272    }
273
274    #[test]
275    fn name_mismatch_is_rejected() {
276        // Write a temporary case file that declares the wrong name.
277        let dir = std::env::temp_dir().join("llm-assisted-api-debugging-lab-name-mismatch");
278        let cases_dir = dir.join("cases");
279        std::fs::create_dir_all(&cases_dir).unwrap();
280        let bogus = cases_dir.join("auth_missing.json");
281        std::fs::write(
282            &bogus,
283            r#"{"name":"rate_limit","request":{"method":"GET","url":"http://x"}}"#,
284        )
285        .unwrap();
286        let err = load_case(&dir, "auth_missing").unwrap_err();
287        assert!(
288            matches!(err, CaseError::NameMismatch { .. }),
289            "expected NameMismatch, got {err:?}"
290        );
291        let _ = std::fs::remove_dir_all(&dir);
292    }
293}