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-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        // The four strings must appear verbatim (acceptance: matches
169        // error-codes.json copy exactly).
170        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        // The frozen `next` names `keel replay`, verbatim — and since it now
196        // exists, no honest-gap qualifier is appended (contrast with E030
197        // below, which still names an unimplemented affordance).
198        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        // `keel flows` genuinely does not display the lease holder yet.
211        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}