use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CaseLoadError {
#[error("could not read case file {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("could not parse case file {path}: {source}")]
Parse {
path: PathBuf,
source: serde_json::Error,
},
#[error("could not resolve case name {0}: no fixture directory found")]
UnknownCase(String),
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Low,
Medium,
High,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Request {
pub method: String,
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub body: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
pub status: u16,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub body: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Context {
#[serde(default)]
pub auth_required: bool,
#[serde(default)]
pub expected_base_url: Option<String>,
#[serde(default)]
pub webhook: Option<WebhookCtx>,
#[serde(default)]
pub idempotency: Option<IdempotencyCtx>,
#[serde(default)]
pub client_deadline_ms: Option<u64>,
#[serde(default)]
pub now_unix: Option<i64>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EnvelopeFormat {
#[default]
Raw,
StripeV1,
SlackV0,
GithubHmac,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookCtx {
pub secret_path: String,
pub signature_header: String,
pub timestamp_header: String,
pub tolerance_seconds: i64,
#[serde(default)]
pub envelope_format: EnvelopeFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdempotencyCtx {
pub header: String,
pub stored_body_sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Case {
pub name: String,
pub description: String,
pub severity: Severity,
pub request: Request,
#[serde(default)]
pub response: Option<Response>,
#[serde(default)]
pub context: Context,
#[serde(default)]
pub expected_rule_id: Option<String>,
#[serde(skip)]
pub log_path: Option<PathBuf>,
#[serde(skip)]
pub fixture_dir: PathBuf,
}
impl Case {
pub fn load(name_or_path: &str, fixtures_root: &Path) -> Result<Self, CaseLoadError> {
let candidate = Path::new(name_or_path);
let json_path = if candidate.is_file() {
candidate.to_path_buf()
} else if candidate.is_dir() {
candidate.join("case.json")
} else {
let dir = fixtures_root.join("cases").join(name_or_path);
if dir.is_dir() {
dir.join("case.json")
} else {
let neg_dir = fixtures_root
.join("cases")
.join("_negatives")
.join(name_or_path);
if neg_dir.is_dir() {
neg_dir.join("case.json")
} else {
if let Some(case) = crate::embedded::load(name_or_path) {
return Ok(case);
}
return Err(CaseLoadError::UnknownCase(name_or_path.to_string()));
}
}
};
let raw = fs::read_to_string(&json_path).map_err(|source| CaseLoadError::Io {
path: json_path.clone(),
source,
})?;
let mut case: Case = serde_json::from_str(&raw).map_err(|source| CaseLoadError::Parse {
path: json_path.clone(),
source,
})?;
case.fixture_dir = json_path.parent().unwrap_or(Path::new(".")).to_path_buf();
let log_candidate = case.fixture_dir.join("server.log");
if log_candidate.is_file() {
case.log_path = Some(log_candidate);
}
Ok(case)
}
pub fn load_log(&self) -> Option<String> {
self.log_path
.as_ref()
.and_then(|p| fs::read_to_string(p).ok())
.or_else(|| crate::embedded::log_for(&self.name, &self.fixture_dir))
}
pub fn load_secret(&self) -> Option<Vec<u8>> {
let webhook = self.context.webhook.as_ref()?;
let path = self.fixture_dir.join(&webhook.secret_path);
fs::read_to_string(path)
.ok()
.map(|raw| raw.trim_end_matches('\n').as_bytes().to_vec())
.or_else(|| crate::embedded::secret_for(&self.name, &self.fixture_dir))
}
}
pub fn list_cases(fixtures_root: &Path) -> Vec<String> {
let cases_dir = fixtures_root.join("cases");
let Ok(entries) = fs::read_dir(&cases_dir) else {
return crate::embedded::positive_names();
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('_') {
None
} else if e.path().join("case.json").is_file() {
Some(name)
} else {
None
}
})
.collect();
names.sort();
names
}
pub fn header<'a>(map: &'a BTreeMap<String, String>, name: &str) -> Option<&'a str> {
let target = name.to_ascii_lowercase();
map.iter()
.find(|(k, _)| k.to_ascii_lowercase() == target)
.map(|(_, v)| v.as_str())
}