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}