Skip to main content

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}