llm_assisted_api_debugging_lab/prose.rs
1//! Per-rule prose loader.
2//!
3//! All editorial content (likely-cause templates, hypotheses, unknowns,
4//! next-step bullets, escalation-note copy, severity rationale) lives in
5//! `prose.toml` at the crate root, embedded into the binary via
6//! `include_str!` and parsed once on first access through `OnceLock`.
7//!
8//! Rule logic in `diagnose.rs` references prose by rule name; mismatches
9//! between the two surface as a panic at first access (during tests,
10//! immediately on first use). This is the right severity: a missing prose
11//! key or malformed prose.toml is a programming error, not a runtime
12//! condition — and the test suite exercises every rule, so any drift fails
13//! `cargo test` rather than reaching production. The `clippy::panic`
14//! allow below is scoped to this load-time validation surface only.
15
16#![allow(clippy::panic)]
17
18use serde::Deserialize;
19use std::collections::BTreeMap;
20use std::sync::OnceLock;
21
22const PROSE_TOML: &str = include_str!("../prose.toml");
23
24/// Top-level deserialized form of `prose.toml`.
25///
26/// The TOML file looks like `[rules.<rule_name>] ...`, which deserializes
27/// as a single `rules` map keyed on rule name. `BTreeMap` rather than
28/// `HashMap` so iteration order during validation tests is stable.
29#[derive(Debug, Deserialize)]
30pub struct Prose {
31 rules: BTreeMap<String, RuleProse>,
32}
33
34/// Per-rule prose entry.
35///
36/// `likely_cause` and the three `likely_cause_template_*` fields are all
37/// `Option`; each rule arm uses exactly one of them depending on whether
38/// it needs to interpolate a placeholder. The
39/// `every_rule_template_kind_matches_its_call_site` test pins which
40/// variant each rule is expected to populate.
41#[derive(Debug, Deserialize)]
42pub struct RuleProse {
43 pub severity_rationale: String,
44 #[serde(default)]
45 pub likely_cause: Option<String>,
46 #[serde(default)]
47 pub likely_cause_template: Option<String>,
48 #[serde(default)]
49 pub likely_cause_template_with_field: Option<String>,
50 #[serde(default)]
51 pub likely_cause_template_no_field: Option<String>,
52 pub hypotheses: Vec<String>,
53 pub unknowns: Vec<String>,
54 pub next_steps: Vec<String>,
55 pub escalation_note: String,
56}
57
58impl Prose {
59 pub fn rule(&self, name: &str) -> &RuleProse {
60 self.rules
61 .get(name)
62 .unwrap_or_else(|| panic!("missing prose entry for rule `{name}` in prose.toml"))
63 }
64}
65
66impl RuleProse {
67 /// Static likely-cause text for rules that don't interpolate fields.
68 pub fn likely_cause_static(&self) -> &str {
69 self.likely_cause.as_deref().unwrap_or_else(|| {
70 panic!(
71 "rule prose has no static `likely_cause`; \
72 use a `likely_cause_template*` accessor instead"
73 )
74 })
75 }
76
77 /// Likely-cause text with `{host}` substituted.
78 pub fn likely_cause_with_host(&self, host: &str) -> String {
79 self.likely_cause_template
80 .as_deref()
81 .unwrap_or_else(|| panic!("rule prose has no `likely_cause_template`"))
82 .replace("{host}", host)
83 }
84
85 /// Likely-cause text with `{peer}` substituted.
86 pub fn likely_cause_with_peer(&self, peer: &str) -> String {
87 self.likely_cause_template
88 .as_deref()
89 .unwrap_or_else(|| panic!("rule prose has no `likely_cause_template`"))
90 .replace("{peer}", peer)
91 }
92
93 /// Likely-cause text for `bad_payload`-style rules where the validated
94 /// field may or may not be present. Picks the right template and
95 /// substitutes `{field}` when applicable.
96 pub fn likely_cause_with_optional_field(&self, field: Option<&str>) -> String {
97 match field {
98 Some(f) => self
99 .likely_cause_template_with_field
100 .as_deref()
101 .unwrap_or_else(|| panic!("rule prose has no `likely_cause_template_with_field`"))
102 .replace("{field}", f),
103 None => self
104 .likely_cause_template_no_field
105 .as_deref()
106 .unwrap_or_else(|| panic!("rule prose has no `likely_cause_template_no_field`"))
107 .to_string(),
108 }
109 }
110}
111
112/// Access the parsed prose. Loaded and validated once on first call.
113pub fn prose() -> &'static Prose {
114 static CACHE: OnceLock<Prose> = OnceLock::new();
115 CACHE.get_or_init(|| {
116 toml::from_str(PROSE_TOML).unwrap_or_else(|e| panic!("prose.toml is malformed: {e}"))
117 })
118}
119
120#[cfg(test)]
121mod tests {
122 #![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
123 use super::*;
124
125 #[test]
126 fn prose_parses() {
127 // Side effect: panics if the embedded TOML is invalid.
128 let _ = prose();
129 }
130
131 /// Every likely-cause template variant a rule arm can call.
132 ///
133 /// This enum exists only inside the test below; it pairs each
134 /// `RuleProse` accessor with the rule arms that use it, so the test
135 /// can assert that the prose entry actually populates the right
136 /// template fields.
137 enum TemplateKind {
138 Static,
139 Host,
140 Peer,
141 OptionalField,
142 }
143
144 /// Source of truth for the rule-name ↔ template-kind binding.
145 /// Mirrors the call sites in `diagnose.rs`. If a rule arm starts
146 /// calling a different accessor (e.g. switches from `static` to
147 /// `with_host`), the binding here must change to match — and the
148 /// test below will catch a missing template field at test time
149 /// rather than at first runtime call.
150 const TEMPLATE_BINDINGS: &[(&str, TemplateKind)] = &[
151 ("dns_failure", TemplateKind::Host),
152 ("tls_failure", TemplateKind::Peer),
153 ("connection_timeout", TemplateKind::Static),
154 ("webhook_signature", TemplateKind::Static),
155 ("rate_limit", TemplateKind::Static),
156 ("auth_missing", TemplateKind::Static),
157 ("bad_payload", TemplateKind::OptionalField),
158 ("unknown", TemplateKind::Static),
159 ];
160
161 #[test]
162 fn every_rule_named_in_diagnose_has_prose() {
163 let p = prose();
164 for (rule, _) in TEMPLATE_BINDINGS {
165 // Triggers the panic in `Prose::rule` if missing.
166 let _ = p.rule(rule);
167 }
168 }
169
170 /// Exercise every rule's likely-cause accessor in the same shape its
171 /// call site uses. Closes the gap that
172 /// `every_rule_named_in_diagnose_has_prose` leaves: a rule whose
173 /// `prose.toml` entry has, say, `likely_cause` set when the call
174 /// site asks for `likely_cause_template_with_field` would still pass
175 /// the presence test but panic at first runtime call. This test
176 /// surfaces that mismatch as a test failure instead.
177 ///
178 /// The placeholder values fed in here (`example.test`,
179 /// `field_name`) are chosen to be obviously fake; we only care that
180 /// the accessor returns a non-empty string, not that the rendered
181 /// prose makes sense.
182 #[test]
183 fn every_rule_template_kind_matches_its_call_site() {
184 let p = prose();
185 for (rule, kind) in TEMPLATE_BINDINGS {
186 let entry = p.rule(rule);
187 let rendered = match kind {
188 TemplateKind::Static => entry.likely_cause_static().to_string(),
189 TemplateKind::Host => entry.likely_cause_with_host("example.test"),
190 TemplateKind::Peer => entry.likely_cause_with_peer("example.test"),
191 TemplateKind::OptionalField => {
192 let with = entry.likely_cause_with_optional_field(Some("field_name"));
193 let without = entry.likely_cause_with_optional_field(None);
194 assert!(!with.is_empty(), "{rule}: with-field template empty");
195 assert!(!without.is_empty(), "{rule}: no-field template empty");
196 with
197 }
198 };
199 assert!(!rendered.is_empty(), "{rule}: rendered likely_cause empty");
200 }
201 }
202}