Skip to main content

keel_cli/
explain.rs

1//! `keel explain <code>` — the frozen error taxonomy, for humans and agents.
2//!
3//! The copy is `contracts/error-codes.json`, embedded with `include_str!` and
4//! parsed once into typed entries. A coding agent that sees `KEEL-E014` gets the
5//! exact remedy here without a web search (dx-spec §5); an unknown code exits
6//! [`EXIT_USAGE`](crate::EXIT_USAGE) and lists every code it *does* know.
7
8use std::collections::BTreeMap;
9use std::sync::OnceLock;
10
11use serde::{Deserialize, Serialize};
12
13use crate::render::to_json;
14use crate::{EXIT_USAGE, Rendered};
15
16/// The frozen taxonomy, embedded so the binary and the contract never drift.
17const ERROR_CODES_JSON: &str = include_str!("../contract/error-codes.json");
18
19/// The base for the per-code docs URL stub (dx-spec §5, "docs built for
20/// retrieval").
21const DOCS_BASE: &str = "https://keel.dev/errors";
22
23/// One taxonomy entry — the four verbatim strings `keel explain` prints. Field
24/// order matches `contracts/error-codes.json` for readability; the JSON twin
25/// re-sorts keys via [`to_json`](crate::render::to_json).
26#[derive(Debug, Clone, Deserialize)]
27struct ErrorEntry {
28    name: String,
29    what: String,
30    why: String,
31    next: String,
32}
33
34/// The whole `error-codes.json` document, typed.
35#[derive(Debug, Deserialize)]
36struct ErrorCodes {
37    codes: BTreeMap<String, ErrorEntry>,
38}
39
40/// The machine twin of an explained code (sorted-key JSON via [`to_json`]).
41#[derive(Debug, Serialize)]
42struct ExplainReport<'a> {
43    code: &'a str,
44    docs: String,
45    name: &'a str,
46    next: &'a str,
47    /// An honest "(planned)" qualifier when the frozen `next` guidance points at
48    /// a CLI affordance that does not exist yet (omitted otherwise).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    planned: Option<&'static str>,
51    what: &'a str,
52    why: &'a str,
53}
54
55/// The frozen taxonomy references a couple of CLI affordances v0.1 does not
56/// implement yet. The taxonomy file is frozen, so rather than edit its `next`
57/// copy we append an honest qualifier at render time (finding: `next` points at
58/// nonexistent affordances — the lease holder in `keel flows`, Tier 1 trace
59/// ids). `keel replay` shipped in a later wave, so KEEL-E032's frozen `next`
60/// (which names it) is fully accurate now and carries no qualifier.
61fn 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/// The machine twin when the code is unknown.
76#[derive(Debug, Serialize)]
77struct UnknownReport<'a> {
78    error: &'static str,
79    known: Vec<&'a str>,
80    requested: &'a str,
81}
82
83/// Parse the embedded taxonomy once. The contract is validated in CI, so a parse
84/// failure here is a build-time contract break, not a runtime condition.
85fn 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
92/// Every known code, sorted (the `BTreeMap` already orders them).
93fn known_codes() -> Vec<&'static str> {
94    taxonomy().codes.keys().map(String::as_str).collect()
95}
96
97/// Explain `code`. A known code renders what/why/next + a docs URL and exits
98/// [`EXIT_OK`](crate::EXIT_OK); an unknown one lists the known codes on stderr
99/// and exits [`EXIT_USAGE`](crate::EXIT_USAGE).
100pub 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
127/// The unknown-code path: exit 2, list every known code.
128fn 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        // The four strings must appear verbatim (acceptance: matches
188        // error-codes.json copy exactly).
189        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        // The frozen `next` names `keel replay`, verbatim — and since it now
215        // exists, no honest-gap qualifier is appended (contrast with E030
216        // below, which still names an unimplemented affordance).
217        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        // `keel flows` genuinely does not display the lease holder yet.
230        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}