kaish_kernel/error.rs
1//! Public error type for the kernel's execute surface.
2//!
3//! An embedder reported the consequence of collapsing every `execute`
4//! failure into one untyped `anyhow::Error`: it could not tell a validator
5//! rejection ("you wrote a bad command") from a genuine execution fault
6//! ("something broke while running"), so it routed both identically —
7//! surfacing a syntax hint to a model as if the shell itself had crashed.
8//! [`KernelError`] carries the distinction the kernel's own control flow
9//! already makes, so a caller can match on it instead of parsing the
10//! message text.
11
12use std::fmt;
13use crate::parser::ParseError;
14use crate::validator::ValidationIssue;
15
16/// Why a call to [`crate::kernel::Kernel::execute`] (or a sibling —
17/// `execute_with_options`, `execute_argv`, …) did not return a result.
18///
19/// The essential distinction is **rejected before running** versus **failed
20/// while running**. [`KernelError::is_rejected`] answers that question
21/// without inspecting `Display` text; match on the variant for the finer
22/// detail each rejection carries (issue codes, spans, parse locations).
23///
24/// - [`KernelError::Parse`] and [`KernelError::Validation`] are rejections:
25/// the kernel never ran a statement.
26/// - [`KernelError::Execution`] means a statement started running and
27/// something failed partway through — a builtin, the evaluator, an IO
28/// fault, or any other error the interpreter propagated.
29///
30/// `Display` on every variant reproduces exactly what `Kernel::execute`
31/// returned before this type existed, so a caller that only prints the
32/// error sees no change on upgrade.
33///
34/// `#[non_exhaustive]`: a rejection reason can be added later — a refused
35/// external command is one candidate under discussion — without breaking a
36/// caller that already has a wildcard arm. Add a match arm rather than
37/// expect this list to stay closed.
38#[derive(Debug)]
39#[non_exhaustive]
40pub enum KernelError {
41 /// The input did not lex or parse. Nothing ran.
42 Parse {
43 /// Every lex/parse failure found, in source order. Each carries its
44 /// own span; `errors[i].format(source)` reproduces one line of
45 /// `message`.
46 errors: Vec<ParseError>,
47 /// Pre-rendered `"parse error:\n..."` text, byte-identical to what
48 /// `Kernel::execute` returned as an `anyhow::Error` before this type
49 /// existed.
50 message: String,
51 },
52
53 /// The pre-execution validator rejected the program. Nothing ran.
54 Validation {
55 /// Every error-severity issue the validator raised, carrying its
56 /// [`kaish_tool_api::IssueCode`](crate::validator::IssueCode),
57 /// message, and span. Warnings never appear here — they don't
58 /// prevent execution, so they surface on the successful `ExecResult`
59 /// instead.
60 issues: Vec<ValidationIssue>,
61 /// Pre-rendered `"validation failed:\n..."` text, byte-identical to
62 /// what `Kernel::execute` returned as an `anyhow::Error` before this
63 /// type existed.
64 message: String,
65 },
66
67 /// A statement started running and something failed partway through —
68 /// a builtin, the evaluator, dispatch, or an IO fault. Carries the
69 /// original error chain unchanged; `source()` and `{:#}` still walk it.
70 Execution(anyhow::Error),
71}
72
73// Display is hand-written rather than derived because the derive would drop
74// the alternate flag. `anyhow` uses `{:#}` to mean "walk the whole cause
75// chain", and a derived `#[error("{0}")]` renders the inner error with a
76// plain `{}`, which reports only the outermost context — so
77// `{:#}` on a wrapped execution fault would lose the real cause. A caller
78// that printed `{:#}` before this type existed must keep seeing the chain.
79impl fmt::Display for KernelError {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 KernelError::Parse { message, .. } | KernelError::Validation { message, .. } => {
83 f.write_str(message)
84 }
85 KernelError::Execution(e) => {
86 if f.alternate() {
87 write!(f, "{e:#}")
88 } else {
89 write!(f, "{e}")
90 }
91 }
92 }
93 }
94}
95
96impl std::error::Error for KernelError {
97 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
98 match self {
99 // `anyhow::Error` is not itself a `std::error::Error`, so the
100 // chain is reached through its own source rather than by
101 // returning it directly.
102 KernelError::Execution(e) => e.source(),
103 _ => None,
104 }
105 }
106}
107
108impl KernelError {
109 /// The program was rejected before anything ran — a lex/parse failure
110 /// ([`KernelError::Parse`]) or a validator rejection
111 /// ([`KernelError::Validation`]). No statement executed, so nothing an
112 /// embedder retries would have a different partial side effect.
113 pub fn is_rejected(&self) -> bool {
114 matches!(self, KernelError::Parse { .. } | KernelError::Validation { .. })
115 }
116
117 /// A statement began running and faulted partway through
118 /// ([`KernelError::Execution`]). The complement of [`Self::is_rejected`].
119 pub fn is_execution_failure(&self) -> bool {
120 matches!(self, KernelError::Execution(_))
121 }
122}
123
124/// Classify the `anyhow::Error` the internal `run_inner`/`execute_argv_locked`
125/// call chain returns into the public [`KernelError`].
126///
127/// `Kernel::execute_streaming_inner` tags its two rejection sites (parse,
128/// validation) by boxing a `KernelError` into the `anyhow::Error` it returns;
129/// everything else it and the deeper interpreter propagate (`?` through
130/// `execute_stmt_flow`, `eval_expr_async`, dispatch, tool bodies, …) stays a
131/// plain `anyhow::Error`, untouched. This is the one place that downcasts: it
132/// recovers a tagged rejection when the chain carries one, and falls back to
133/// [`KernelError::Execution`] for everything else. Every public `execute*`
134/// method applies this at its own return, so the interpreter's internal
135/// `Result<T>` (`anyhow::Result`) never has to change shape.
136pub(crate) fn classify_execute_error(e: anyhow::Error) -> KernelError {
137 match e.downcast::<KernelError>() {
138 Ok(tagged) => tagged,
139 Err(e) => KernelError::Execution(e),
140 }
141}
142
143#[cfg(test)]
144#[allow(clippy::unwrap_used, clippy::expect_used)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn is_rejected_true_for_parse_and_validation() {
150 let parse = KernelError::Parse { errors: Vec::new(), message: "parse error:\nx".into() };
151 assert!(parse.is_rejected());
152 assert!(!parse.is_execution_failure());
153
154 let validation = KernelError::Validation { issues: Vec::new(), message: "validation failed:\nx".into() };
155 assert!(validation.is_rejected());
156 assert!(!validation.is_execution_failure());
157 }
158
159 #[test]
160 fn is_rejected_false_for_execution() {
161 let exec = KernelError::Execution(anyhow::anyhow!("boom"));
162 assert!(!exec.is_rejected());
163 assert!(exec.is_execution_failure());
164 }
165
166 #[test]
167 fn classify_recovers_a_tagged_rejection() {
168 let tagged = KernelError::Validation { issues: Vec::new(), message: "validation failed:\nx".into() };
169 let boxed = anyhow::Error::from(tagged);
170 let classified = classify_execute_error(boxed);
171 assert!(classified.is_rejected());
172 }
173
174 #[test]
175 fn classify_falls_back_to_execution_for_untagged_errors() {
176 let classified = classify_execute_error(anyhow::anyhow!("some deep interpreter error"));
177 assert!(matches!(classified, KernelError::Execution(_)));
178 }
179
180 #[test]
181 fn anyhow_error_from_kernel_error_works() {
182 // An embedder that wants to keep using anyhow can still do so —
183 // `?` converts via anyhow's blanket `From<E: std::error::Error>`.
184 fn as_anyhow() -> anyhow::Result<()> {
185 fn fails() -> Result<(), KernelError> {
186 Err(KernelError::Execution(anyhow::anyhow!("boom")))
187 }
188 fails()?;
189 Ok(())
190 }
191 assert!(as_anyhow().is_err());
192 }
193}