use std::collections::BTreeMap;
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::render::to_json;
use crate::{EXIT_USAGE, Rendered};
const ERROR_CODES_JSON: &str = include_str!("../contract/error-codes.json");
const DOCS_BASE: &str = "https://keel.dev/errors";
#[derive(Debug, Clone, Deserialize)]
struct ErrorEntry {
name: String,
what: String,
why: String,
next: String,
}
#[derive(Debug, Deserialize)]
struct ErrorCodes {
codes: BTreeMap<String, ErrorEntry>,
}
#[derive(Debug, Serialize)]
struct ExplainReport<'a> {
code: &'a str,
docs: String,
name: &'a str,
next: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
planned: Option<&'static str>,
what: &'a str,
why: &'a str,
}
fn planned_note(code: &str) -> Option<&'static str> {
match code {
"KEEL-E030" => Some(
"`keel flows` does not display the lease holder in v0.1 (planned); it lists flow id, \
entrypoint, status, steps, and age.",
),
"KEEL-E010" => Some(
"`keel trace <id>` resolves durable-flow ids; Tier 1 trace ids (t-NNNNNN) are not \
persisted in v0.1, so they cannot be looked up (planned).",
),
_ => None,
}
}
#[derive(Debug, Serialize)]
struct UnknownReport<'a> {
error: &'static str,
known: Vec<&'a str>,
requested: &'a str,
}
fn taxonomy() -> &'static ErrorCodes {
static TAXONOMY: OnceLock<ErrorCodes> = OnceLock::new();
TAXONOMY.get_or_init(|| {
serde_json::from_str(ERROR_CODES_JSON).expect("contracts/error-codes.json parses")
})
}
fn known_codes() -> Vec<&'static str> {
taxonomy().codes.keys().map(String::as_str).collect()
}
pub fn run(code: &str) -> Rendered {
let code = code.trim();
let Some(entry) = taxonomy().codes.get(code) else {
return unknown(code);
};
let docs = format!("{DOCS_BASE}/{code}");
let planned = planned_note(code);
let report = ExplainReport {
code,
docs: docs.clone(),
name: &entry.name,
next: &entry.next,
planned,
what: &entry.what,
why: &entry.why,
};
let note = planned.map_or(String::new(), |p| format!("\n\nNote: {p}"));
let human = format!(
"{code} {name}\n\nWhat: {what}\nWhy: {why}\nNext: {next}{note}\n\nDocs: {docs}",
name = entry.name,
what = entry.what,
why = entry.why,
next = entry.next,
);
Rendered::ok(human, to_json(&report))
}
fn unknown(code: &str) -> Rendered {
let known = known_codes();
let human = format!(
"keel \u{25b8} {code}: unknown error code.\n\nKnown codes:\n{list}",
list = known
.iter()
.map(|c| format!(" {c}"))
.collect::<Vec<_>>()
.join("\n"),
);
let report = UnknownReport {
error: "unknown-code",
known,
requested: code,
};
Rendered {
human,
json: to_json(&report),
exit: EXIT_USAGE,
to_stderr: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn taxonomy_parses_and_has_the_frozen_codes() {
let t = taxonomy();
assert!(t.codes.contains_key("KEEL-E001"));
assert!(t.codes.contains_key("KEEL-E005"));
assert!(t.codes.contains_key("KEEL-E014"));
assert!(t.codes.contains_key("KEEL-E016"));
assert!(t.codes.contains_key("KEEL-E040"));
}
#[test]
fn explain_e016_names_poll_deadline() {
let r = run("KEEL-E016");
assert_eq!(r.exit, crate::EXIT_OK);
assert!(r.human.contains("poll-deadline-exceeded"));
assert!(r.human.contains("breaker-countable"));
assert_eq!(r.json["name"], "poll-deadline-exceeded");
}
#[test]
fn explain_e033_names_side_effects() {
let r = run("KEEL-E033");
assert_eq!(r.exit, crate::EXIT_OK);
assert!(r.human.contains("side-effects-recorded"));
assert!(r.human.contains("--force"));
assert_eq!(r.json["name"], "side-effects-recorded");
}
#[test]
fn explain_e014_carries_the_contract_copy_verbatim() {
let r = run("KEEL-E014");
assert_eq!(r.exit, crate::EXIT_OK);
assert!(r.human.contains("non-idempotent-not-retried"));
assert!(r.human.contains(
"The call failed with a retryable error, but Keel did not retry it \
because repeating it is not provably safe."
));
assert!(r.human.contains(
"Level 0 hard rule: non-idempotent calls (e.g. POST without an \
idempotency key) are observed, never retried."
));
assert!(r.human.contains(
"If the API supports idempotency keys, configure \
`idempotency = { header = \"...\" }` for the target; then retries \
become safe."
));
assert_eq!(r.json["docs"], "https://keel.dev/errors/KEEL-E014");
assert_eq!(r.json["name"], "non-idempotent-not-retried");
}
#[test]
fn explain_trims_surrounding_whitespace() {
assert_eq!(run(" KEEL-E011 ").exit, crate::EXIT_OK);
}
#[test]
fn e032_next_points_at_keel_replay_with_no_planned_qualifier() {
let r = run("KEEL-E032");
assert_eq!(r.exit, crate::EXIT_OK);
assert!(
r.human.contains("keel replay <flow>"),
"frozen copy verbatim"
);
assert!(!r.human.contains("(planned)"));
assert!(r.json.get("planned").is_none() || r.json["planned"].is_null());
}
#[test]
fn e030_still_carries_its_planned_qualifier() {
let r = run("KEEL-E030");
assert!(r.human.contains("not display the lease holder"));
assert!(r.json["planned"].as_str().unwrap().contains("lease holder"));
}
#[test]
fn a_code_without_a_planned_gap_has_no_note() {
let r = run("KEEL-E014");
assert!(!r.human.contains("Note:"));
assert!(r.json.get("planned").is_none() || r.json["planned"].is_null());
}
#[test]
fn unknown_code_exits_usage_and_lists_known() {
let r = run("KEEL-E999");
assert_eq!(r.exit, EXIT_USAGE);
assert!(r.to_stderr);
assert_eq!(r.json["error"], "unknown-code");
let known = r.json["known"].as_array().unwrap();
assert!(known.iter().any(|c| c == "KEEL-E001"));
assert!(r.human.contains("Known codes:"));
}
}