1use std::collections::BTreeMap;
9use std::sync::OnceLock;
10
11use serde::{Deserialize, Serialize};
12
13use crate::render::to_json;
14use crate::{EXIT_USAGE, Rendered};
15
16const ERROR_CODES_JSON: &str = include_str!("../contract/error-codes.json");
18
19const DOCS_BASE: &str = "https://keel.dev/errors";
22
23#[derive(Debug, Clone, Deserialize)]
27struct ErrorEntry {
28 name: String,
29 what: String,
30 why: String,
31 next: String,
32}
33
34#[derive(Debug, Deserialize)]
36struct ErrorCodes {
37 codes: BTreeMap<String, ErrorEntry>,
38}
39
40#[derive(Debug, Serialize)]
42struct ExplainReport<'a> {
43 code: &'a str,
44 docs: String,
45 name: &'a str,
46 next: &'a str,
47 #[serde(skip_serializing_if = "Option::is_none")]
50 planned: Option<&'static str>,
51 what: &'a str,
52 why: &'a str,
53}
54
55fn planned_note(code: &str) -> Option<&'static str> {
62 match code {
63 "KEEL-E030" => Some(
64 "`keel flows` does not display the lease holder in v0.1 (planned); it lists flow id, \
65 entrypoint, status, steps, and age.",
66 ),
67 "KEEL-E010" => Some(
68 "`keel trace <id>` resolves durable-flow ids; Tier 1 trace ids (t-NNNNNN) are not \
69 persisted in v0.1, so they cannot be looked up (planned).",
70 ),
71 _ => None,
72 }
73}
74
75#[derive(Debug, Serialize)]
77struct UnknownReport<'a> {
78 error: &'static str,
79 known: Vec<&'a str>,
80 requested: &'a str,
81}
82
83fn taxonomy() -> &'static ErrorCodes {
86 static TAXONOMY: OnceLock<ErrorCodes> = OnceLock::new();
87 TAXONOMY.get_or_init(|| {
88 serde_json::from_str(ERROR_CODES_JSON).expect("contracts/error-codes.json parses")
89 })
90}
91
92fn known_codes() -> Vec<&'static str> {
94 taxonomy().codes.keys().map(String::as_str).collect()
95}
96
97pub fn run(code: &str) -> Rendered {
101 let code = code.trim();
102 let Some(entry) = taxonomy().codes.get(code) else {
103 return unknown(code);
104 };
105 let docs = format!("{DOCS_BASE}/{code}");
106 let planned = planned_note(code);
107 let report = ExplainReport {
108 code,
109 docs: docs.clone(),
110 name: &entry.name,
111 next: &entry.next,
112 planned,
113 what: &entry.what,
114 why: &entry.why,
115 };
116 let note = planned.map_or(String::new(), |p| format!("\n\nNote: {p}"));
117 let human = format!(
118 "{code} {name}\n\nWhat: {what}\nWhy: {why}\nNext: {next}{note}\n\nDocs: {docs}",
119 name = entry.name,
120 what = entry.what,
121 why = entry.why,
122 next = entry.next,
123 );
124 Rendered::ok(human, to_json(&report))
125}
126
127fn unknown(code: &str) -> Rendered {
129 let known = known_codes();
130 let human = format!(
131 "keel \u{25b8} {code}: unknown error code.\n\nKnown codes:\n{list}",
132 list = known
133 .iter()
134 .map(|c| format!(" {c}"))
135 .collect::<Vec<_>>()
136 .join("\n"),
137 );
138 let report = UnknownReport {
139 error: "unknown-code",
140 known,
141 requested: code,
142 };
143 Rendered {
144 human,
145 json: to_json(&report),
146 exit: EXIT_USAGE,
147 to_stderr: true,
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn taxonomy_parses_and_has_the_frozen_codes() {
157 let t = taxonomy();
158 assert!(t.codes.contains_key("KEEL-E001"));
159 assert!(t.codes.contains_key("KEEL-E005"));
160 assert!(t.codes.contains_key("KEEL-E014"));
161 assert!(t.codes.contains_key("KEEL-E016"));
162 assert!(t.codes.contains_key("KEEL-E040"));
163 }
164
165 #[test]
166 fn explain_e016_names_poll_deadline() {
167 let r = run("KEEL-E016");
168 assert_eq!(r.exit, crate::EXIT_OK);
169 assert!(r.human.contains("poll-deadline-exceeded"));
170 assert!(r.human.contains("breaker-countable"));
171 assert_eq!(r.json["name"], "poll-deadline-exceeded");
172 }
173
174 #[test]
175 fn explain_e033_names_side_effects() {
176 let r = run("KEEL-E033");
177 assert_eq!(r.exit, crate::EXIT_OK);
178 assert!(r.human.contains("side-effects-recorded"));
179 assert!(r.human.contains("--force"));
180 assert_eq!(r.json["name"], "side-effects-recorded");
181 }
182
183 #[test]
184 fn explain_e014_carries_the_contract_copy_verbatim() {
185 let r = run("KEEL-E014");
186 assert_eq!(r.exit, crate::EXIT_OK);
187 assert!(r.human.contains("non-idempotent-not-retried"));
190 assert!(r.human.contains(
191 "The call failed with a retryable error, but Keel did not retry it \
192 because repeating it is not provably safe."
193 ));
194 assert!(r.human.contains(
195 "Level 0 hard rule: non-idempotent calls (e.g. POST without an \
196 idempotency key) are observed, never retried."
197 ));
198 assert!(r.human.contains(
199 "If the API supports idempotency keys, configure \
200 `idempotency = { header = \"...\" }` for the target; then retries \
201 become safe."
202 ));
203 assert_eq!(r.json["docs"], "https://keel.dev/errors/KEEL-E014");
204 assert_eq!(r.json["name"], "non-idempotent-not-retried");
205 }
206
207 #[test]
208 fn explain_trims_surrounding_whitespace() {
209 assert_eq!(run(" KEEL-E011 ").exit, crate::EXIT_OK);
210 }
211
212 #[test]
213 fn e032_next_points_at_keel_replay_with_no_planned_qualifier() {
214 let r = run("KEEL-E032");
218 assert_eq!(r.exit, crate::EXIT_OK);
219 assert!(
220 r.human.contains("keel replay <flow>"),
221 "frozen copy verbatim"
222 );
223 assert!(!r.human.contains("(planned)"));
224 assert!(r.json.get("planned").is_none() || r.json["planned"].is_null());
225 }
226
227 #[test]
228 fn e030_still_carries_its_planned_qualifier() {
229 let r = run("KEEL-E030");
231 assert!(r.human.contains("not display the lease holder"));
232 assert!(r.json["planned"].as_str().unwrap().contains("lease holder"));
233 }
234
235 #[test]
236 fn a_code_without_a_planned_gap_has_no_note() {
237 let r = run("KEEL-E014");
238 assert!(!r.human.contains("Note:"));
239 assert!(r.json.get("planned").is_none() || r.json["planned"].is_null());
240 }
241
242 #[test]
243 fn unknown_code_exits_usage_and_lists_known() {
244 let r = run("KEEL-E999");
245 assert_eq!(r.exit, EXIT_USAGE);
246 assert!(r.to_stderr);
247 assert_eq!(r.json["error"], "unknown-code");
248 let known = r.json["known"].as_array().unwrap();
249 assert!(known.iter().any(|c| c == "KEEL-E001"));
250 assert!(r.human.contains("Known codes:"));
251 }
252}