llm_assisted_api_debugging_lab/diagnose.rs
1//! Deterministic rules engine.
2//!
3//! [`diagnose`] is a pure function over `(name, &[Evidence])`. There is no
4//! clock, no env, no fs, and no randomness — which is what makes the
5//! `insta` snapshot tests reproducible on any machine.
6//!
7//! ## How a rule works
8//!
9//! Each rule is a private function `rule_*(name, evidence, reproduction) ->
10//! Option<Diagnosis>`. The rule:
11//!
12//! 1. Inspects `evidence` for the variants it cares about. If the trigger
13//! pattern is absent, the rule returns `None` and the dispatcher tries
14//! the next rule.
15//! 2. If the trigger pattern is present, the rule calls [`pick`] to choose
16//! which evidence items to surface in the rendered output (and in what
17//! order).
18//! 3. The rule looks up its prose in `prose.toml` via [`prose`], then
19//! constructs and returns a [`Diagnosis`].
20//!
21//! ## Why rule order matters
22//!
23//! Rules are tried in order from most specific (network-layer failure) to
24//! least specific (application-layer failure). The dispatcher returns the
25//! first match. This matters for inputs where multiple rules could in
26//! principle fire — see the test
27//! `tls_failure_rule_orders_after_dns_failure` for the canonical example.
28//!
29//! ## Why prose lives outside this file
30//!
31//! The hand-written English content (likely-cause templates, hypotheses,
32//! unknowns, next-steps, escalation notes, severity rationales) lives in
33//! `prose.toml` at the crate root. Editorial changes (a clearer
34//! hypothesis, a tighter escalation note) do not require a code change.
35//! Logic changes (severity, rule order, evidence patterns) still go here.
36
37use crate::evidence::Evidence;
38use crate::prose::prose;
39use serde::Serialize;
40
41/// Severity rank assigned to a diagnosis. See [`SeveritySource`] for how a
42/// rule arrived at the rank, and the README's "What it does (and does not)
43/// claim" section for the ranking philosophy (immediacy of failure, not
44/// blast radius).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "lowercase")]
47pub enum Severity {
48 Low,
49 Medium,
50 High,
51 Critical,
52}
53
54impl Severity {
55 /// Lowercase label used in rendered output.
56 pub fn as_str(&self) -> &'static str {
57 match self {
58 Severity::Low => "low",
59 Severity::Medium => "medium",
60 Severity::High => "high",
61 Severity::Critical => "critical",
62 }
63 }
64}
65
66/// How the rule arrived at its severity rank.
67///
68/// Today every rule reports `AuthorJudgment` because the diagnoser has no
69/// visibility into per-customer blast radius — it can only see one
70/// transaction at a time. The variant exists so the provenance is explicit
71/// in every rendered prompt and report; a reader cannot mistake a
72/// hand-assigned rank for a measured impact.
73///
74/// `DerivedFromEvidence` is reserved for a future variant that would
75/// derive severity from evidence values (e.g. `RetryAfterSecs > 60`
76/// upgrading rate-limit to High, or a sustained `ConnectionTimeout` count
77/// upgrading to Critical). When that variant gets used, the rendered
78/// label will say "derived from evidence" instead of "author judgment" —
79/// no caller code needs to change.
80#[derive(Debug, Clone, Serialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum SeveritySource {
83 AuthorJudgment { rationale: String },
84 DerivedFromEvidence { rationale: String },
85}
86
87impl SeveritySource {
88 /// The human-readable rationale, regardless of variant.
89 pub fn rationale(&self) -> &str {
90 match self {
91 SeveritySource::AuthorJudgment { rationale }
92 | SeveritySource::DerivedFromEvidence { rationale } => rationale,
93 }
94 }
95
96 /// Short label distinguishing the two provenance kinds; rendered next
97 /// to the severity rank in every output.
98 pub fn label(&self) -> &'static str {
99 match self {
100 SeveritySource::AuthorJudgment { .. } => "author judgment",
101 SeveritySource::DerivedFromEvidence { .. } => "derived from evidence",
102 }
103 }
104}
105
106/// The output of [`diagnose`], consumed by every renderer.
107///
108/// Fields are organized into three groups:
109///
110/// - **Identification:** `case`, `rule`. The `rule` is a stable string that
111/// names the rule arm that fired (e.g. `"dns_failure"`); it is the join
112/// key against `prose.toml`.
113/// - **Classification:** `severity`, `severity_source`, `likely_cause`. The
114/// `likely_cause` is human prose, possibly with a `{host}`/`{peer}`/`{field}`
115/// placeholder filled in. `severity_source` carries the provenance.
116/// - **Communication:** `evidence`, `hypotheses`, `unknowns`, `next_steps`,
117/// `reproduction`, `escalation_note`. These feed both the human report
118/// and the LLM prompt. The `evidence` field is a curated subset chosen
119/// by [`pick`] in rule order, not the raw input vector.
120///
121/// `Diagnosis` is `Serialize` so the JSON-envelope prompt renderer can
122/// embed it directly without a parallel struct.
123#[derive(Debug, Clone, Serialize)]
124pub struct Diagnosis {
125 pub case: String,
126 pub severity: Severity,
127 pub severity_source: SeveritySource,
128 pub likely_cause: String,
129 pub evidence: Vec<Evidence>,
130 pub hypotheses: Vec<String>,
131 pub unknowns: Vec<String>,
132 pub next_steps: Vec<String>,
133 pub reproduction: String,
134 pub escalation_note: String,
135 pub rule: &'static str,
136}
137
138/// Diagnose a case by name and the evidence collected for it.
139///
140/// Rules are matched in a fixed order from most specific (network-layer)
141/// to least specific (application-layer); the first matching rule wins.
142/// The `unknown` fallback always returns a diagnosis — an unrecognized
143/// pattern produces an explicit "no rule matched" diagnosis rather than a
144/// silent guess.
145///
146/// Order is documented in `docs/architecture.md` and pinned by both unit
147/// tests (e.g. `dns_failure_wins_over_other_signals`) and proptest
148/// invariants (`tests/proptests.rs` proves selection is permutation- and
149/// rotation-invariant for any input).
150///
151/// The `name` parameter is used only for the rendered output (CASE label,
152/// reproduction command). Rule selection itself is a pure function of
153/// `evidence`.
154pub fn diagnose(name: &str, evidence: &[Evidence]) -> Diagnosis {
155 // The reproduction command is the same for every rule arm — compute it
156 // once and pass by reference.
157 let reproduction = format!("cargo run -p llm-assisted-api-debugging-lab -- diagnose {name}");
158
159 // Rule order: network layer first, then transport, then application.
160 // Each rule's trigger pattern is documented in its function body.
161 if let Some(d) = rule_dns_failure(name, evidence, &reproduction) {
162 return d;
163 }
164 if let Some(d) = rule_tls_failure(name, evidence, &reproduction) {
165 return d;
166 }
167 if let Some(d) = rule_connection_timeout(name, evidence, &reproduction) {
168 return d;
169 }
170 if let Some(d) = rule_webhook_signature(name, evidence, &reproduction) {
171 return d;
172 }
173 if let Some(d) = rule_rate_limit(name, evidence, &reproduction) {
174 return d;
175 }
176 if let Some(d) = rule_auth_missing(name, evidence, &reproduction) {
177 return d;
178 }
179 if let Some(d) = rule_bad_payload(name, evidence, &reproduction) {
180 return d;
181 }
182 // Fallback always fires. By design it returns `Diagnosis`, not
183 // `Option<Diagnosis>`, so the dispatcher can never return `None`.
184 rule_unknown(name, evidence, &reproduction)
185}
186
187/// Construct a `Diagnosis` from the pieces that vary between rules.
188///
189/// Every rule arm computes the same shape: a severity rank, a curated
190/// evidence list, a `likely_cause` string. Everything else (severity
191/// rationale, hypotheses, unknowns, next-steps, escalation note) is
192/// pulled from `prose.toml` keyed on the rule name. This helper performs
193/// that lookup and assembles the `Diagnosis` so each rule arm can stay
194/// focused on the parts that are actually rule-specific.
195///
196/// `rule` doubles as the prose-table key and the `Diagnosis::rule`
197/// field, which means a typo in one place is impossible — there is only
198/// one place to put it.
199fn from_rule(
200 name: &str,
201 rule: &'static str,
202 severity: Severity,
203 likely_cause: String,
204 pinned_evidence: Vec<Evidence>,
205 reproduction: &str,
206) -> Diagnosis {
207 let p = prose().rule(rule);
208 Diagnosis {
209 case: name.into(),
210 severity,
211 severity_source: SeveritySource::AuthorJudgment {
212 rationale: p.severity_rationale.clone(),
213 },
214 likely_cause,
215 evidence: pinned_evidence,
216 hypotheses: p.hypotheses.clone(),
217 unknowns: p.unknowns.clone(),
218 next_steps: p.next_steps.clone(),
219 reproduction: reproduction.into(),
220 escalation_note: p.escalation_note.clone(),
221 rule,
222 }
223}
224
225/// Trigger: any `DnsResolutionFailed` evidence item.
226///
227/// Severity Critical: name resolution failed before any traffic was sent,
228/// so no application-level diagnosis is possible until DNS is restored.
229/// Ordered first because every other failure mode presupposes that DNS
230/// resolved.
231///
232/// Pinned evidence order: DNS first (the cause), then `ConnectionTimeout`
233/// or `HttpStatus` if either is present (rare in practice — both imply DNS
234/// did resolve, but evidence collected from the response side could
235/// theoretically include them alongside a context-derived DNS signal).
236fn rule_dns_failure(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
237 // Extract just the host; the message is rendered via the prose
238 // template's `{host}` substitution, not directly.
239 let host = ev.iter().find_map(|e| match e {
240 Evidence::DnsResolutionFailed { host, .. } => Some(host.clone()),
241 _ => None,
242 })?;
243 let pinned = pick(
244 ev,
245 &[
246 |e| matches!(e, Evidence::DnsResolutionFailed { .. }),
247 |e| matches!(e, Evidence::ConnectionTimeout { .. }),
248 |e| matches!(e, Evidence::HttpStatus(_)),
249 ],
250 );
251 let likely_cause = prose().rule("dns_failure").likely_cause_with_host(&host);
252 Some(from_rule(
253 name,
254 "dns_failure",
255 Severity::Critical,
256 likely_cause,
257 pinned,
258 reproduction,
259 ))
260}
261
262/// Trigger: any `TlsHandshakeFailed` evidence item.
263///
264/// Severity Critical: TLS failure means no HTTP request was ever
265/// transmitted. Like DNS failure, until the transport is restored there
266/// is no application-level evidence to reason about.
267///
268/// Ordered after `dns_failure` because TLS presupposes DNS resolved (a
269/// host that doesn't resolve cannot have started a TLS handshake). The
270/// test `tls_failure_rule_orders_after_dns_failure` pins this — when both
271/// kinds of evidence are present, DNS wins.
272fn rule_tls_failure(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
273 // Extract the peer hostname; the reason is rendered via the prose
274 // template's `{peer}` substitution.
275 let peer = ev.iter().find_map(|e| match e {
276 Evidence::TlsHandshakeFailed { peer, .. } => Some(peer.clone()),
277 _ => None,
278 })?;
279 let pinned = pick(
280 ev,
281 &[
282 |e| matches!(e, Evidence::TlsHandshakeFailed { .. }),
283 |e| matches!(e, Evidence::DnsResolutionFailed { .. }),
284 |e| matches!(e, Evidence::HttpStatus(_)),
285 ],
286 );
287 let likely_cause = prose().rule("tls_failure").likely_cause_with_peer(&peer);
288 Some(from_rule(
289 name,
290 "tls_failure",
291 Severity::Critical,
292 likely_cause,
293 pinned,
294 reproduction,
295 ))
296}
297
298/// Trigger: any `ConnectionTimeout` evidence item.
299///
300/// Severity High: the client aborted before any HTTP response was
301/// received. The transport opened and the request went out, but no reply
302/// came back inside the budget. Severity is High rather than Critical
303/// because evidence at this layer still allows the on-call to correlate
304/// the failed `request_id` with server-side traces (whereas DNS/TLS
305/// failures provide nothing past the transport layer).
306fn rule_connection_timeout(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
307 if !ev
308 .iter()
309 .any(|e| matches!(e, Evidence::ConnectionTimeout { .. }))
310 {
311 return None;
312 }
313 let pinned = pick(
314 ev,
315 &[
316 |e| matches!(e, Evidence::ConnectionTimeout { .. }),
317 |e| matches!(e, Evidence::HttpStatus(_)),
318 ],
319 );
320 let likely_cause = prose()
321 .rule("connection_timeout")
322 .likely_cause_static()
323 .to_string();
324 Some(from_rule(
325 name,
326 "connection_timeout",
327 Severity::High,
328 likely_cause,
329 pinned,
330 reproduction,
331 ))
332}
333
334/// Trigger: any `SignatureMismatch` evidence item.
335///
336/// Severity High: HMAC verification rejected the inbound webhook. The
337/// failure is silent in the sense that there is no surfaced error in the
338/// customer's application code — the receiver just stops processing
339/// events. That class of failure tends to go unnoticed for long stretches.
340///
341/// Pinned evidence ordering walks the reader through the chain: the HTTP
342/// status (the symptom), then the signature mismatch (the diagnoser's
343/// observation), then the clock drift and body-mutation signals if
344/// present (the two most common upstream causes).
345fn rule_webhook_signature(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
346 if !ev.iter().any(|e| matches!(e, Evidence::SignatureMismatch)) {
347 return None;
348 }
349 let pinned = pick(
350 ev,
351 &[
352 |e| matches!(e, Evidence::HttpStatus(_)),
353 |e| matches!(e, Evidence::SignatureMismatch),
354 |e| matches!(e, Evidence::ClockDriftSecs { .. }),
355 |e| matches!(e, Evidence::BodyMutatedBeforeVerification),
356 ],
357 );
358 let likely_cause = prose()
359 .rule("webhook_signature")
360 .likely_cause_static()
361 .to_string();
362 Some(from_rule(
363 name,
364 "webhook_signature",
365 Severity::High,
366 likely_cause,
367 pinned,
368 reproduction,
369 ))
370}
371
372/// Trigger: `HttpStatus(429)` AND `RetryAfterSecs(_)` together.
373///
374/// Severity Medium: this is expected back-pressure rather than a service
375/// fault. The server explicitly told the client to slow down; the client's
376/// retry path is the right place to handle it.
377///
378/// Both signals are required by design. A bare 429 without `Retry-After`
379/// is unusual enough that we'd rather fall through to `unknown` than
380/// guess; the test `rate_limit_rule_requires_429_and_retry_after` pins
381/// this.
382fn rule_rate_limit(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
383 let has_429 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(429)));
384 let has_retry = ev.iter().any(|e| matches!(e, Evidence::RetryAfterSecs(_)));
385 if !(has_429 && has_retry) {
386 return None;
387 }
388 let pinned = pick(
389 ev,
390 &[
391 |e| matches!(e, Evidence::HttpStatus(_)),
392 |e| matches!(e, Evidence::RetryAfterSecs(_)),
393 |e| matches!(e, Evidence::RateLimitObserved { .. }),
394 ],
395 );
396 let likely_cause = prose().rule("rate_limit").likely_cause_static().to_string();
397 Some(from_rule(
398 name,
399 "rate_limit",
400 Severity::Medium,
401 likely_cause,
402 pinned,
403 reproduction,
404 ))
405}
406
407/// Trigger: `HttpStatus(401)` AND `HeaderMissing { name: "Authorization" }`
408/// together.
409///
410/// Severity Medium: the request was rejected at the auth boundary, but
411/// the failure mode is well-understood and almost always a client-side
412/// configuration issue (env var unset, secret manager not loaded, proxy
413/// stripping the header).
414///
415/// Both signals are required: a bare 401 without missing Authorization
416/// might be a wrong key, an expired token, or many other things that this
417/// rule cannot distinguish. The conjunctive trigger keeps the diagnosis
418/// honest.
419fn rule_auth_missing(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
420 let has_401 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(401)));
421 let auth_missing = ev
422 .iter()
423 .any(|e| matches!(e, Evidence::HeaderMissing { name } if name == "Authorization"));
424 if !(has_401 && auth_missing) {
425 return None;
426 }
427 let pinned = pick(
428 ev,
429 &[
430 |e| matches!(e, Evidence::HttpStatus(_)),
431 |e| matches!(e, Evidence::HeaderMissing { .. }),
432 ],
433 );
434 let likely_cause = prose()
435 .rule("auth_missing")
436 .likely_cause_static()
437 .to_string();
438 Some(from_rule(
439 name,
440 "auth_missing",
441 Severity::Medium,
442 likely_cause,
443 pinned,
444 reproduction,
445 ))
446}
447
448/// Trigger: `HttpStatus(400)` AND `JsonValidationError { .. }` together.
449///
450/// Severity Low: a single client-side request failed with a structured
451/// validation response. The client has all the information it needs to
452/// fix the request and retry; nothing else is affected.
453///
454/// The `field` value (the name of the failing field, if known) is
455/// surfaced through the prose template's `{field}` placeholder. If the
456/// validation evidence carries no field name, the no-field template is
457/// used instead — see `RuleProse::likely_cause_with_optional_field`.
458fn rule_bad_payload(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
459 let has_400 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(400)));
460 let validation = ev.iter().find_map(|e| match e {
461 Evidence::JsonValidationError { field, .. } => Some(field.clone()),
462 _ => None,
463 });
464 if !has_400 || validation.is_none() {
465 return None;
466 }
467 // `validation` is `Option<Option<String>>`: the outer is "did we find a
468 // JsonValidationError at all", the inner is "did that error name a
469 // field". Flatten to drop the outer Some(...) wrapper.
470 let field = validation.flatten();
471 let pinned = pick(
472 ev,
473 &[
474 |e| matches!(e, Evidence::HttpStatus(_)),
475 |e| matches!(e, Evidence::JsonValidationError { .. }),
476 ],
477 );
478 let likely_cause = prose()
479 .rule("bad_payload")
480 .likely_cause_with_optional_field(field.as_deref());
481 Some(from_rule(
482 name,
483 "bad_payload",
484 Severity::Low,
485 likely_cause,
486 pinned,
487 reproduction,
488 ))
489}
490
491/// Fallback rule: always fires.
492///
493/// This is the architectural promise that the diagnoser will not silently
494/// guess. If no other rule matched, this one produces a diagnosis whose
495/// `likely_cause` literally says "Evidence does not match any built-in
496/// rule" and whose next-steps tell the reader to add a fixture and a
497/// rule for this evidence shape before claiming a diagnosis. Severity
498/// Low because the diagnoser has nothing to base a higher rank on, not
499/// because the underlying failure is benign — the prose's
500/// `severity_rationale` says exactly this.
501///
502/// Returns `Diagnosis` (not `Option<Diagnosis>`): this is the dispatcher's
503/// guarantee that `diagnose()` always returns a value.
504fn rule_unknown(name: &str, ev: &[Evidence], reproduction: &str) -> Diagnosis {
505 let likely_cause = prose().rule("unknown").likely_cause_static().to_string();
506 // The fallback shows every evidence item it received; it has no
507 // basis for curating further. Compare against every other rule,
508 // which calls `pick()` to surface a curated subset.
509 from_rule(
510 name,
511 "unknown",
512 Severity::Low,
513 likely_cause,
514 ev.to_vec(),
515 reproduction,
516 )
517}
518
519/// Pick evidence items matching the given predicates, in the order the
520/// predicates appear, preserving original ordering for ties. Used by
521/// every rule arm to choose which evidence to surface in the rendered
522/// output (and in what order).
523///
524/// Predicates are typically `matches!`-driven (e.g.
525/// `|e| matches!(e, Evidence::HttpStatus(_))`) so the compiler enforces
526/// that the variant name still exists on `Evidence`. This replaced an
527/// earlier hand-rolled `u8` tag table that had to be kept in sync with
528/// the `Evidence` enum manually — a class of bug the type system can
529/// prevent.
530///
531/// Each evidence item is included at most once even if multiple
532/// predicates match; this matters when, e.g., a `webhook_signature` rule
533/// pins both `HttpStatus(_)` and `SignatureMismatch` and the input vector
534/// could contain ordering edge cases.
535fn pick(ev: &[Evidence], predicates: &[fn(&Evidence) -> bool]) -> Vec<Evidence> {
536 let mut out = Vec::new();
537 for predicate in predicates {
538 for e in ev {
539 if predicate(e) && !out.contains(e) {
540 out.push(e.clone());
541 }
542 }
543 }
544 out
545}
546
547#[cfg(test)]
548mod tests {
549 #![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
550 use super::*;
551
552 #[test]
553 fn dns_failure_wins_over_other_signals() {
554 let ev = vec![
555 Evidence::DnsResolutionFailed {
556 host: "api.exmaple.com".into(),
557 message: "no such host".into(),
558 },
559 Evidence::HttpStatus(401),
560 ];
561 let d = diagnose("dns_config", &ev);
562 assert_eq!(d.rule, "dns_failure");
563 assert_eq!(d.severity, Severity::Critical);
564 }
565
566 #[test]
567 fn tls_failure_rule_matches() {
568 let ev = vec![Evidence::TlsHandshakeFailed {
569 peer: "api.example.com".into(),
570 reason: "certificate has expired".into(),
571 }];
572 let d = diagnose("tls_failure", &ev);
573 assert_eq!(d.rule, "tls_failure");
574 assert_eq!(d.severity, Severity::Critical);
575 }
576
577 #[test]
578 fn tls_failure_rule_orders_after_dns_failure() {
579 // If both DNS and TLS evidence are present, DNS wins (a connection
580 // that did not resolve cannot have completed TLS).
581 let ev = vec![
582 Evidence::DnsResolutionFailed {
583 host: "api.example.com".into(),
584 message: "no such host".into(),
585 },
586 Evidence::TlsHandshakeFailed {
587 peer: "api.example.com".into(),
588 reason: "certificate has expired".into(),
589 },
590 ];
591 let d = diagnose("ambiguous", &ev);
592 assert_eq!(d.rule, "dns_failure");
593 }
594
595 #[test]
596 fn connection_timeout_rule_matches() {
597 let ev = vec![Evidence::ConnectionTimeout {
598 elapsed_ms: 5012,
599 timeout_ms: 5000,
600 }];
601 let d = diagnose("timeout", &ev);
602 assert_eq!(d.rule, "connection_timeout");
603 assert_eq!(d.severity, Severity::High);
604 }
605
606 #[test]
607 fn webhook_signature_rule_matches() {
608 let ev = vec![
609 Evidence::HttpStatus(401),
610 Evidence::SignatureMismatch,
611 Evidence::ClockDriftSecs {
612 observed: 360,
613 tolerance_secs: 300,
614 },
615 Evidence::BodyMutatedBeforeVerification,
616 ];
617 let d = diagnose("webhook_signature", &ev);
618 assert_eq!(d.rule, "webhook_signature");
619 assert_eq!(d.severity, Severity::High);
620 }
621
622 #[test]
623 fn rate_limit_rule_requires_429_and_retry_after() {
624 let with_retry = vec![Evidence::HttpStatus(429), Evidence::RetryAfterSecs(12)];
625 assert_eq!(diagnose("rate_limit", &with_retry).rule, "rate_limit");
626
627 // 429 alone (no Retry-After) should not match the rule and falls to unknown.
628 let without_retry = vec![Evidence::HttpStatus(429)];
629 assert_eq!(diagnose("rate_limit", &without_retry).rule, "unknown");
630 }
631
632 #[test]
633 fn auth_missing_rule_matches() {
634 let ev = vec![
635 Evidence::HttpStatus(401),
636 Evidence::HeaderMissing {
637 name: "Authorization".into(),
638 },
639 ];
640 let d = diagnose("auth_missing", &ev);
641 assert_eq!(d.rule, "auth_missing");
642 assert_eq!(d.severity, Severity::Medium);
643 }
644
645 #[test]
646 fn bad_payload_rule_matches() {
647 let ev = vec![
648 Evidence::HttpStatus(400),
649 Evidence::JsonValidationError {
650 field: Some("amount".into()),
651 message: "Expected integer, got string.".into(),
652 },
653 ];
654 let d = diagnose("bad_payload", &ev);
655 assert_eq!(d.rule, "bad_payload");
656 assert!(d.likely_cause.contains("`amount`"));
657 }
658
659 #[test]
660 fn unknown_pattern_does_not_invent_a_cause() {
661 let ev = vec![Evidence::HttpStatus(418)];
662 let d = diagnose("teapot", &ev);
663 assert_eq!(d.rule, "unknown");
664 assert!(d.likely_cause.contains("does not match"));
665 }
666}