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-E040"));
162 }
163
164 #[test]
165 fn explain_e014_carries_the_contract_copy_verbatim() {
166 let r = run("KEEL-E014");
167 assert_eq!(r.exit, crate::EXIT_OK);
168 assert!(r.human.contains("non-idempotent-not-retried"));
171 assert!(r.human.contains(
172 "The call failed with a retryable error, but Keel did not retry it \
173 because repeating it is not provably safe."
174 ));
175 assert!(r.human.contains(
176 "Level 0 hard rule: non-idempotent calls (e.g. POST without an \
177 idempotency key) are observed, never retried."
178 ));
179 assert!(r.human.contains(
180 "If the API supports idempotency keys, configure \
181 `idempotency = { header = \"...\" }` for the target; then retries \
182 become safe."
183 ));
184 assert_eq!(r.json["docs"], "https://keel.dev/errors/KEEL-E014");
185 assert_eq!(r.json["name"], "non-idempotent-not-retried");
186 }
187
188 #[test]
189 fn explain_trims_surrounding_whitespace() {
190 assert_eq!(run(" KEEL-E011 ").exit, crate::EXIT_OK);
191 }
192
193 #[test]
194 fn e032_next_points_at_keel_replay_with_no_planned_qualifier() {
195 let r = run("KEEL-E032");
199 assert_eq!(r.exit, crate::EXIT_OK);
200 assert!(
201 r.human.contains("keel replay <flow>"),
202 "frozen copy verbatim"
203 );
204 assert!(!r.human.contains("(planned)"));
205 assert!(r.json.get("planned").is_none() || r.json["planned"].is_null());
206 }
207
208 #[test]
209 fn e030_still_carries_its_planned_qualifier() {
210 let r = run("KEEL-E030");
212 assert!(r.human.contains("not display the lease holder"));
213 assert!(r.json["planned"].as_str().unwrap().contains("lease holder"));
214 }
215
216 #[test]
217 fn a_code_without_a_planned_gap_has_no_note() {
218 let r = run("KEEL-E014");
219 assert!(!r.human.contains("Note:"));
220 assert!(r.json.get("planned").is_none() || r.json["planned"].is_null());
221 }
222
223 #[test]
224 fn unknown_code_exits_usage_and_lists_known() {
225 let r = run("KEEL-E999");
226 assert_eq!(r.exit, EXIT_USAGE);
227 assert!(r.to_stderr);
228 assert_eq!(r.json["error"], "unknown-code");
229 let known = r.json["known"].as_array().unwrap();
230 assert!(known.iter().any(|c| c == "KEEL-E001"));
231 assert!(r.human.contains("Known codes:"));
232 }
233}