use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub const KNOWN_CASES: &[&str] = &[
"auth_missing",
"bad_payload",
"rate_limit",
"webhook_signature",
"timeout",
"dns_config",
"tls_failure",
"injection_attempt",
];
#[derive(Debug, Error)]
pub enum CaseError {
#[error("unknown case: {0}")]
Unknown(String),
#[error("could not read case file {0}: {1}")]
Io(PathBuf, #[source] std::io::Error),
#[error("could not parse case file {0}: {1}")]
Parse(PathBuf, #[source] serde_json::Error),
#[error("case file {path} declares name {found:?} but was loaded as {expected:?}")]
NameMismatch {
path: PathBuf,
expected: String,
found: String,
},
}
#[derive(Debug, Clone, Deserialize)]
pub struct Case {
pub name: String,
#[serde(default)]
pub description: String,
pub request: Request,
#[serde(default)]
pub response: Option<Response>,
#[serde(default)]
pub context: Context,
#[serde(default)]
pub log_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Request {
pub method: String,
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub body_summary: String,
#[serde(default)]
pub client_unix_ts: Option<i64>,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Response {
pub status: u16,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub body_summary: String,
#[serde(default)]
pub server_unix_ts: Option<i64>,
#[serde(default)]
pub elapsed_ms: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Context {
#[serde(default)]
pub dns_resolved: Option<bool>,
#[serde(default)]
pub dns_error: Option<String>,
#[serde(default)]
pub dns_host: Option<String>,
#[serde(default)]
pub tls_handshake_ms: Option<u64>,
#[serde(default)]
pub tls_handshake_failed: Option<bool>,
#[serde(default)]
pub tls_failure_reason: Option<String>,
#[serde(default)]
pub tls_peer: Option<String>,
#[serde(default)]
pub client_clock_skew_secs: Option<i64>,
#[serde(default)]
pub signing_required: Option<bool>,
#[serde(default)]
pub signature_tolerance_secs: Option<u64>,
#[serde(default)]
pub body_mutated_before_verification: Option<bool>,
#[serde(default)]
pub elapsed_ms_before_abort: Option<u64>,
#[serde(default)]
pub connection_error: Option<String>,
}
pub fn case_fixture_path(fixtures_dir: &Path, name: &str) -> Result<PathBuf, CaseError> {
if !KNOWN_CASES.contains(&name) {
return Err(CaseError::Unknown(name.to_string()));
}
Ok(fixtures_dir.join("cases").join(format!("{name}.json")))
}
pub fn load_case(fixtures_dir: &Path, name: &str) -> Result<Case, CaseError> {
let path = case_fixture_path(fixtures_dir, name)?;
let bytes = std::fs::read(&path).map_err(|e| CaseError::Io(path.clone(), e))?;
let case: Case =
serde_json::from_slice(&bytes).map_err(|e| CaseError::Parse(path.clone(), e))?;
if case.name != name {
return Err(CaseError::NameMismatch {
path,
expected: name.to_string(),
found: case.name,
});
}
Ok(case)
}
pub fn log_path_for(case: &Case, project_root: &Path) -> Option<PathBuf> {
case.log_path.as_ref().map(|p| project_root.join(p))
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
use super::*;
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
}
#[test]
fn known_cases_all_load() {
for name in KNOWN_CASES {
let case =
load_case(&fixtures_dir(), name).unwrap_or_else(|e| panic!("loading {name}: {e}"));
assert_eq!(case.name, *name, "case file name field must match filename");
}
}
#[test]
fn unknown_case_is_rejected() {
let err = load_case(&fixtures_dir(), "not_a_real_case").unwrap_err();
assert!(matches!(err, CaseError::Unknown(_)));
}
#[test]
fn known_cases_matches_on_disk_fixtures() {
let cases_dir = fixtures_dir().join("cases");
let mut on_disk: Vec<String> = std::fs::read_dir(&cases_dir)
.unwrap_or_else(|e| panic!("reading {}: {e}", cases_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json"))
.filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(str::to_string))
.collect();
on_disk.sort();
let mut declared: Vec<String> = KNOWN_CASES.iter().map(|s| s.to_string()).collect();
declared.sort();
assert_eq!(
declared, on_disk,
"KNOWN_CASES must match the set of *.json files under fixtures/cases/"
);
}
#[test]
fn name_mismatch_is_rejected() {
let dir = std::env::temp_dir().join("llm-assisted-api-debugging-lab-name-mismatch");
let cases_dir = dir.join("cases");
std::fs::create_dir_all(&cases_dir).unwrap();
let bogus = cases_dir.join("auth_missing.json");
std::fs::write(
&bogus,
r#"{"name":"rate_limit","request":{"method":"GET","url":"http://x"}}"#,
)
.unwrap();
let err = load_case(&dir, "auth_missing").unwrap_err();
assert!(
matches!(err, CaseError::NameMismatch { .. }),
"expected NameMismatch, got {err:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}