use crate::evidence::Evidence;
use crate::prose::prose;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SeveritySource {
AuthorJudgment { rationale: String },
DerivedFromEvidence { rationale: String },
}
impl SeveritySource {
pub fn rationale(&self) -> &str {
match self {
SeveritySource::AuthorJudgment { rationale }
| SeveritySource::DerivedFromEvidence { rationale } => rationale,
}
}
pub fn label(&self) -> &'static str {
match self {
SeveritySource::AuthorJudgment { .. } => "author judgment",
SeveritySource::DerivedFromEvidence { .. } => "derived from evidence",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnosis {
pub case: String,
pub severity: Severity,
pub severity_source: SeveritySource,
pub likely_cause: String,
pub evidence: Vec<Evidence>,
pub hypotheses: Vec<String>,
pub unknowns: Vec<String>,
pub next_steps: Vec<String>,
pub reproduction: String,
pub escalation_note: String,
pub rule: &'static str,
}
pub fn diagnose(name: &str, evidence: &[Evidence]) -> Diagnosis {
let reproduction = format!("cargo run -p llm-assisted-api-debugging-lab -- diagnose {name}");
if let Some(d) = rule_dns_failure(name, evidence, &reproduction) {
return d;
}
if let Some(d) = rule_tls_failure(name, evidence, &reproduction) {
return d;
}
if let Some(d) = rule_connection_timeout(name, evidence, &reproduction) {
return d;
}
if let Some(d) = rule_webhook_signature(name, evidence, &reproduction) {
return d;
}
if let Some(d) = rule_rate_limit(name, evidence, &reproduction) {
return d;
}
if let Some(d) = rule_auth_missing(name, evidence, &reproduction) {
return d;
}
if let Some(d) = rule_bad_payload(name, evidence, &reproduction) {
return d;
}
rule_unknown(name, evidence, &reproduction)
}
fn from_rule(
name: &str,
rule: &'static str,
severity: Severity,
likely_cause: String,
pinned_evidence: Vec<Evidence>,
reproduction: &str,
) -> Diagnosis {
let p = prose().rule(rule);
Diagnosis {
case: name.into(),
severity,
severity_source: SeveritySource::AuthorJudgment {
rationale: p.severity_rationale.clone(),
},
likely_cause,
evidence: pinned_evidence,
hypotheses: p.hypotheses.clone(),
unknowns: p.unknowns.clone(),
next_steps: p.next_steps.clone(),
reproduction: reproduction.into(),
escalation_note: p.escalation_note.clone(),
rule,
}
}
fn rule_dns_failure(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
let host = ev.iter().find_map(|e| match e {
Evidence::DnsResolutionFailed { host, .. } => Some(host.clone()),
_ => None,
})?;
let pinned = pick(
ev,
&[
|e| matches!(e, Evidence::DnsResolutionFailed { .. }),
|e| matches!(e, Evidence::ConnectionTimeout { .. }),
|e| matches!(e, Evidence::HttpStatus(_)),
],
);
let likely_cause = prose().rule("dns_failure").likely_cause_with_host(&host);
Some(from_rule(
name,
"dns_failure",
Severity::Critical,
likely_cause,
pinned,
reproduction,
))
}
fn rule_tls_failure(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
let peer = ev.iter().find_map(|e| match e {
Evidence::TlsHandshakeFailed { peer, .. } => Some(peer.clone()),
_ => None,
})?;
let pinned = pick(
ev,
&[
|e| matches!(e, Evidence::TlsHandshakeFailed { .. }),
|e| matches!(e, Evidence::DnsResolutionFailed { .. }),
|e| matches!(e, Evidence::HttpStatus(_)),
],
);
let likely_cause = prose().rule("tls_failure").likely_cause_with_peer(&peer);
Some(from_rule(
name,
"tls_failure",
Severity::Critical,
likely_cause,
pinned,
reproduction,
))
}
fn rule_connection_timeout(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
if !ev
.iter()
.any(|e| matches!(e, Evidence::ConnectionTimeout { .. }))
{
return None;
}
let pinned = pick(
ev,
&[
|e| matches!(e, Evidence::ConnectionTimeout { .. }),
|e| matches!(e, Evidence::HttpStatus(_)),
],
);
let likely_cause = prose()
.rule("connection_timeout")
.likely_cause_static()
.to_string();
Some(from_rule(
name,
"connection_timeout",
Severity::High,
likely_cause,
pinned,
reproduction,
))
}
fn rule_webhook_signature(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
if !ev.iter().any(|e| matches!(e, Evidence::SignatureMismatch)) {
return None;
}
let pinned = pick(
ev,
&[
|e| matches!(e, Evidence::HttpStatus(_)),
|e| matches!(e, Evidence::SignatureMismatch),
|e| matches!(e, Evidence::ClockDriftSecs { .. }),
|e| matches!(e, Evidence::BodyMutatedBeforeVerification),
],
);
let likely_cause = prose()
.rule("webhook_signature")
.likely_cause_static()
.to_string();
Some(from_rule(
name,
"webhook_signature",
Severity::High,
likely_cause,
pinned,
reproduction,
))
}
fn rule_rate_limit(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
let has_429 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(429)));
let has_retry = ev.iter().any(|e| matches!(e, Evidence::RetryAfterSecs(_)));
if !(has_429 && has_retry) {
return None;
}
let pinned = pick(
ev,
&[
|e| matches!(e, Evidence::HttpStatus(_)),
|e| matches!(e, Evidence::RetryAfterSecs(_)),
|e| matches!(e, Evidence::RateLimitObserved { .. }),
],
);
let likely_cause = prose().rule("rate_limit").likely_cause_static().to_string();
Some(from_rule(
name,
"rate_limit",
Severity::Medium,
likely_cause,
pinned,
reproduction,
))
}
fn rule_auth_missing(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
let has_401 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(401)));
let auth_missing = ev
.iter()
.any(|e| matches!(e, Evidence::HeaderMissing { name } if name == "Authorization"));
if !(has_401 && auth_missing) {
return None;
}
let pinned = pick(
ev,
&[
|e| matches!(e, Evidence::HttpStatus(_)),
|e| matches!(e, Evidence::HeaderMissing { .. }),
],
);
let likely_cause = prose()
.rule("auth_missing")
.likely_cause_static()
.to_string();
Some(from_rule(
name,
"auth_missing",
Severity::Medium,
likely_cause,
pinned,
reproduction,
))
}
fn rule_bad_payload(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
let has_400 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(400)));
let validation = ev.iter().find_map(|e| match e {
Evidence::JsonValidationError { field, .. } => Some(field.clone()),
_ => None,
});
if !has_400 || validation.is_none() {
return None;
}
let field = validation.flatten();
let pinned = pick(
ev,
&[
|e| matches!(e, Evidence::HttpStatus(_)),
|e| matches!(e, Evidence::JsonValidationError { .. }),
],
);
let likely_cause = prose()
.rule("bad_payload")
.likely_cause_with_optional_field(field.as_deref());
Some(from_rule(
name,
"bad_payload",
Severity::Low,
likely_cause,
pinned,
reproduction,
))
}
fn rule_unknown(name: &str, ev: &[Evidence], reproduction: &str) -> Diagnosis {
let likely_cause = prose().rule("unknown").likely_cause_static().to_string();
from_rule(
name,
"unknown",
Severity::Low,
likely_cause,
ev.to_vec(),
reproduction,
)
}
fn pick(ev: &[Evidence], predicates: &[fn(&Evidence) -> bool]) -> Vec<Evidence> {
let mut out = Vec::new();
for predicate in predicates {
for e in ev {
if predicate(e) && !out.contains(e) {
out.push(e.clone());
}
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
use super::*;
#[test]
fn dns_failure_wins_over_other_signals() {
let ev = vec![
Evidence::DnsResolutionFailed {
host: "api.exmaple.com".into(),
message: "no such host".into(),
},
Evidence::HttpStatus(401),
];
let d = diagnose("dns_config", &ev);
assert_eq!(d.rule, "dns_failure");
assert_eq!(d.severity, Severity::Critical);
}
#[test]
fn tls_failure_rule_matches() {
let ev = vec![Evidence::TlsHandshakeFailed {
peer: "api.example.com".into(),
reason: "certificate has expired".into(),
}];
let d = diagnose("tls_failure", &ev);
assert_eq!(d.rule, "tls_failure");
assert_eq!(d.severity, Severity::Critical);
}
#[test]
fn tls_failure_rule_orders_after_dns_failure() {
let ev = vec![
Evidence::DnsResolutionFailed {
host: "api.example.com".into(),
message: "no such host".into(),
},
Evidence::TlsHandshakeFailed {
peer: "api.example.com".into(),
reason: "certificate has expired".into(),
},
];
let d = diagnose("ambiguous", &ev);
assert_eq!(d.rule, "dns_failure");
}
#[test]
fn connection_timeout_rule_matches() {
let ev = vec![Evidence::ConnectionTimeout {
elapsed_ms: 5012,
timeout_ms: 5000,
}];
let d = diagnose("timeout", &ev);
assert_eq!(d.rule, "connection_timeout");
assert_eq!(d.severity, Severity::High);
}
#[test]
fn webhook_signature_rule_matches() {
let ev = vec![
Evidence::HttpStatus(401),
Evidence::SignatureMismatch,
Evidence::ClockDriftSecs {
observed: 360,
tolerance_secs: 300,
},
Evidence::BodyMutatedBeforeVerification,
];
let d = diagnose("webhook_signature", &ev);
assert_eq!(d.rule, "webhook_signature");
assert_eq!(d.severity, Severity::High);
}
#[test]
fn rate_limit_rule_requires_429_and_retry_after() {
let with_retry = vec![Evidence::HttpStatus(429), Evidence::RetryAfterSecs(12)];
assert_eq!(diagnose("rate_limit", &with_retry).rule, "rate_limit");
let without_retry = vec![Evidence::HttpStatus(429)];
assert_eq!(diagnose("rate_limit", &without_retry).rule, "unknown");
}
#[test]
fn auth_missing_rule_matches() {
let ev = vec![
Evidence::HttpStatus(401),
Evidence::HeaderMissing {
name: "Authorization".into(),
},
];
let d = diagnose("auth_missing", &ev);
assert_eq!(d.rule, "auth_missing");
assert_eq!(d.severity, Severity::Medium);
}
#[test]
fn bad_payload_rule_matches() {
let ev = vec![
Evidence::HttpStatus(400),
Evidence::JsonValidationError {
field: Some("amount".into()),
message: "Expected integer, got string.".into(),
},
];
let d = diagnose("bad_payload", &ev);
assert_eq!(d.rule, "bad_payload");
assert!(d.likely_cause.contains("`amount`"));
}
#[test]
fn unknown_pattern_does_not_invent_a_cause() {
let ev = vec![Evidence::HttpStatus(418)];
let d = diagnose("teapot", &ev);
assert_eq!(d.rule, "unknown");
assert!(d.likely_cause.contains("does not match"));
}
}