Skip to main content

lean_rs/error/
mod.rs

1//! Typed error boundary for the safe `lean-rs` surface.
2//!
3//! Every fallible public function returns [`LeanResult<T>`]. [`LeanError`]
4//! is the single error type that crosses the boundary; it has three
5//! variants:
6//!
7//! - [`LeanError::LeanException`] when Lean threw through its `IO` error
8//!   channel. The payload reports the `IO.Error` constructor as
9//!   [`LeanExceptionKind`] and a bounded `message()` that callers may
10//!   surface to end users.
11//! - [`LeanError::Host`] when our stack failed (init, ABI conversion,
12//!   contained callback panic, future link/load, internal invariant). The
13//!   payload reports the [`HostStage`] and a bounded developer-facing
14//!   `message()`.
15//! - [`LeanError::Cancelled`] when a `lean-rs-host` cooperative
16//!   cancellation token was observed before an FFI dispatch or between
17//!   bulk dispatches.
18//!
19//! Bounded messages are a *structural* invariant: [`LeanException`] and
20//! [`HostFailure`] have private fields, and the only constructors are
21//! `pub(crate)`; both run a shared helper that truncates the message at
22//! [`LEAN_ERROR_MESSAGE_LIMIT`] on a UTF-8 char boundary. [`LeanCancelled`]
23//! also has private fields and carries a fixed host-authored message.
24//! External callers receive `LeanError` values but cannot mint one with
25//! an unbounded message.
26//!
27//! The rule callers learn: **runtime / host failures are [`LeanError`];
28//! application semantics are values.** A Lean function returning
29//! `IO (Except E T)` decodes as `LeanResult<Result<T, E>>` — outer `IO`
30//! failure becomes a [`LeanError::LeanException`], inner `Except`
31//! becomes a Rust [`Result`].
32//!
33//! ## Diagnostic codes
34//!
35//! Every error-bearing type on the public surface projects to a stable
36//! [`LeanDiagnosticCode`] via `.code()`. The code names the failure
37//! family a downstream caller must react to — `Linking`, `Elaboration`,
38//! `Unsupported`, and so on — independent of the internal [`HostStage`]
39//! tag. The `as_str()` form of each code is the identifier used by the
40//! tracing spans and the published `docs/diagnostics.md` catalogue;
41//! variant names and string ids are stable across patch releases.
42
43use std::any::Any;
44use std::fmt;
45
46pub(crate) mod capture;
47pub(crate) mod io;
48pub(crate) mod panic;
49pub(crate) mod redact;
50
51#[cfg(test)]
52mod tests;
53
54pub use self::capture::{CapturedEvent, DIAGNOSTIC_CAPTURE_DEFAULT_CAPACITY, DiagnosticCapture};
55
56/// Hard cap on the byte length of any [`LeanError`] message.
57///
58/// Enforced at construction time by the `pub(crate)` constructors in this
59/// module. The truncation respects UTF-8 char boundaries, so the cap is
60/// an upper bound rather than an exact length.
61pub const LEAN_ERROR_MESSAGE_LIMIT: usize = 4096;
62
63/// Result alias used by every fallible public API in `lean-rs`.
64pub type LeanResult<T> = Result<T, LeanError>;
65
66/// Errors reported across the safe `lean-rs` boundary.
67///
68/// `#[non_exhaustive]` so future toolchain or platform refinements can add
69/// new diagnostic tags inside [`HostStage`] / [`LeanExceptionKind`]
70/// without breaking pattern-matching code that already handles the
71/// top-level variants.
72#[non_exhaustive]
73#[derive(Clone, Debug)]
74pub enum LeanError {
75    /// Lean threw through its `IO` error channel; see [`LeanException`].
76    LeanException(LeanException),
77    /// The host stack failed at a particular stage; see [`HostFailure`].
78    Host(HostFailure),
79    /// A cooperative cancellation token was observed before dispatch.
80    Cancelled(LeanCancelled),
81}
82
83impl LeanError {
84    /// Project to the stable [`LeanDiagnosticCode`] taxonomy.
85    ///
86    /// `LeanException` always maps to [`LeanDiagnosticCode::LeanException`];
87    /// `Host` returns the code recorded by the construction site.
88    #[must_use]
89    pub fn code(&self) -> LeanDiagnosticCode {
90        match self {
91            Self::LeanException(_) => LeanDiagnosticCode::LeanException,
92            Self::Host(failure) => failure.code,
93            Self::Cancelled(_) => LeanDiagnosticCode::Cancelled,
94        }
95    }
96
97    /// Build a `RuntimeInit` host failure.
98    pub(crate) fn runtime_init(message: impl Into<String>) -> Self {
99        Self::host(HostStage::RuntimeInit, LeanDiagnosticCode::RuntimeInit, message)
100    }
101
102    /// Build a `RuntimeInit` host failure from a caught panic payload.
103    pub(crate) fn runtime_init_panic(payload: &(dyn Any + Send)) -> Self {
104        Self::runtime_init(render_panic_payload(payload))
105    }
106
107    /// Build a `RuntimeInit` host failure for an active Lean toolchain that
108    /// is not in [`lean_rs_sys::SUPPORTED_TOOLCHAINS`]. The build script
109    /// already filters at compile time; this constructor exists for the
110    /// runtime backstop in [`crate::runtime::LeanRuntime::init`].
111    pub(crate) fn runtime_init_unsupported_toolchain(active_version: &str) -> Self {
112        use std::fmt::Write as _;
113        let mut window = String::new();
114        for (i, t) in lean_rs_sys::SUPPORTED_TOOLCHAINS.iter().enumerate() {
115            if i > 0 {
116                window.push_str(", ");
117            }
118            let _ = write!(window, "{:?}", t.versions);
119        }
120        Self::runtime_init(format!(
121            "active Lean toolchain {active_version} is not in the supported window: {window}",
122        ))
123    }
124
125    /// Build a `Linking` host failure (missing/invalid Lake names,
126    /// missing initializer symbol, header-digest mismatch).
127    pub(crate) fn linking(message: impl Into<String>) -> Self {
128        Self::host(HostStage::Link, LeanDiagnosticCode::Linking, message)
129    }
130
131    /// Build a `ModuleInit` host failure (dylib could not be opened,
132    /// initializer raised, Lake project path bad).
133    pub(crate) fn module_init(message: impl Into<String>) -> Self {
134        Self::host(HostStage::Load, LeanDiagnosticCode::ModuleInit, message)
135    }
136
137    /// Build a `ModuleInit` host failure from a caught panic payload.
138    pub(crate) fn module_init_panic(payload: &(dyn Any + Send)) -> Self {
139        Self::module_init(render_panic_payload(payload))
140    }
141
142    /// Build a `SymbolLookup` host failure (dlsym miss, signature
143    /// mismatch — function symbol expected but global found, etc.).
144    pub(crate) fn symbol_lookup(message: impl Into<String>) -> Self {
145        Self::host(HostStage::Link, LeanDiagnosticCode::SymbolLookup, message)
146    }
147
148    /// Build an `AbiConversion` host failure (wrong Lean kind, integer
149    /// out of range, invalid UTF-8, missing declaration).
150    pub(crate) fn abi_conversion(message: impl Into<String>) -> Self {
151        Self::host(HostStage::Conversion, LeanDiagnosticCode::AbiConversion, message)
152    }
153
154    /// Build a Lean-thrown-exception report with a bounded message.
155    pub(crate) fn lean_exception(kind: LeanExceptionKind, message: impl Into<String>) -> Self {
156        Self::LeanException(LeanException {
157            kind,
158            message: bound_message(message.into()),
159        })
160    }
161
162    /// Build a host-stack failure from a caught `std::panic::catch_unwind`
163    /// payload at a Lean → Rust callback boundary. The payload is
164    /// rendered into a string before bounding so the panic value never
165    /// escapes the error boundary.
166    pub(crate) fn callback_panic(payload: &(dyn Any + Send)) -> Self {
167        Self::host(
168            HostStage::CallbackPanic,
169            LeanDiagnosticCode::Internal,
170            render_panic_payload(payload),
171        )
172    }
173
174    /// Build an internal host failure for a `pub(crate)` invariant breach.
175    pub(crate) fn internal(message: impl Into<String>) -> Self {
176        Self::host(HostStage::Internal, LeanDiagnosticCode::Internal, message)
177    }
178
179    /// Build a cooperative cancellation report.
180    pub(crate) fn cancelled() -> Self {
181        Self::Cancelled(LeanCancelled {
182            message: "operation cancelled by LeanCancellationToken".to_owned(),
183        })
184    }
185
186    /// Shared host-failure constructor. Private — every call site uses
187    /// the typed wrappers above so the [`HostStage`] / [`LeanDiagnosticCode`]
188    /// pair cannot drift.
189    fn host(stage: HostStage, code: LeanDiagnosticCode, message: impl Into<String>) -> Self {
190        Self::Host(HostFailure {
191            stage,
192            code,
193            message: bound_message(message.into()),
194        })
195    }
196}
197
198impl fmt::Display for LeanError {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        match self {
201            Self::LeanException(e) => write!(f, "lean-rs: {e}"),
202            Self::Host(e) => write!(f, "lean-rs: {e}"),
203            Self::Cancelled(e) => write!(f, "lean-rs: {e}"),
204        }
205    }
206}
207
208impl std::error::Error for LeanError {}
209
210/// A cooperative cancellation observed by `lean-rs-host`.
211///
212/// Constructed only by the crate through the hidden host boundary helper.
213/// The payload is intentionally small and stable: cancellation is a caller
214/// decision, not a Lean diagnostic.
215#[derive(Clone, Debug)]
216pub struct LeanCancelled {
217    message: String,
218}
219
220impl LeanCancelled {
221    /// Caller-facing cancellation message.
222    #[must_use]
223    pub fn message(&self) -> &str {
224        &self.message
225    }
226}
227
228impl fmt::Display for LeanCancelled {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        f.write_str(&self.message)
231    }
232}
233
234/// A Lean exception thrown through the `IO` error channel.
235///
236/// Constructed only by the crate; the `kind` and `message` fields are
237/// private so the bounded-message invariant survives downstream
238/// pattern-matching.
239#[derive(Clone, Debug)]
240pub struct LeanException {
241    kind: LeanExceptionKind,
242    message: String,
243}
244
245impl LeanException {
246    /// The `IO.Error` constructor Lean reported.
247    ///
248    /// Use this to classify a Lean-thrown exception when the
249    /// caller-facing taxonomy in [`LeanDiagnosticCode`] is not specific
250    /// enough — for example, distinguishing `FileNotFound` from
251    /// `PermissionDenied` to drive different recovery paths.
252    #[must_use]
253    pub fn kind(&self) -> LeanExceptionKind {
254        self.kind
255    }
256
257    /// What Lean said about the failure, truncated to at most
258    /// [`LEAN_ERROR_MESSAGE_LIMIT`] bytes on a UTF-8 char boundary.
259    #[must_use]
260    pub fn message(&self) -> &str {
261        &self.message
262    }
263}
264
265impl fmt::Display for LeanException {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        write!(f, "Lean threw {:?}: {}", self.kind, self.message)
268    }
269}
270
271/// A host-stack failure observed inside `lean-rs`.
272///
273/// Constructed only by the crate; fields are private so the bounded-message
274/// invariant survives downstream pattern-matching.
275#[derive(Clone, Debug)]
276pub struct HostFailure {
277    stage: HostStage,
278    code: LeanDiagnosticCode,
279    message: String,
280}
281
282impl HostFailure {
283    /// The host-stack stage that observed the failure.
284    ///
285    /// This is the *internal* classification; reach for
286    /// [`Self::code`] when displaying a stable identifier or routing on
287    /// the caller-facing failure family. The [`HostStage`] tag may grow
288    /// new variants alongside new internal paths.
289    #[must_use]
290    pub fn stage(&self) -> HostStage {
291        self.stage
292    }
293
294    /// The stable diagnostic code matching this failure.
295    ///
296    /// Recorded at the construction site rather than projected from
297    /// [`Self::stage`], so the code identity does not drift if the
298    /// internal stage tag is later refined. Use this for stable
299    /// logging, metrics, or downstream error routing.
300    #[must_use]
301    pub fn code(&self) -> LeanDiagnosticCode {
302        self.code
303    }
304
305    /// Developer-facing diagnostic, truncated to at most
306    /// [`LEAN_ERROR_MESSAGE_LIMIT`] bytes on a UTF-8 char boundary.
307    #[must_use]
308    pub fn message(&self) -> &str {
309        &self.message
310    }
311}
312
313impl fmt::Display for HostFailure {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(
316            f,
317            "host stage {:?} [{}]: {}",
318            self.stage,
319            self.code.as_str(),
320            self.message
321        )
322    }
323}
324
325/// What the host stack was doing when it failed.
326///
327/// A flat tag enum; callers rarely match on it and read
328/// [`HostFailure::message`] instead. Use [`LeanDiagnosticCode`] for the
329/// stable, caller-facing failure taxonomy — `HostStage` is the
330/// host-stack's internal classification and may grow new variants when
331/// new internal paths are added. Marked `#[non_exhaustive]` so later
332/// prompts can add variants without breaking exhaustive matches.
333#[non_exhaustive]
334#[derive(Copy, Clone, Debug, Eq, PartialEq)]
335pub enum HostStage {
336    /// `OnceLock` + `lean_initialize_*` panic-or-failure.
337    RuntimeInit,
338    /// First-order ABI value malformed (wrong kind, out of range,
339    /// invalid UTF-8, non-scalar `char`).
340    Conversion,
341    /// A Rust panic was contained at a Lean → Rust callback boundary.
342    CallbackPanic,
343    /// Link-time failure (missing symbol, header-digest mismatch).
344    Link,
345    /// Load-time failure (`dlopen`, per-module initializer).
346    Load,
347    /// A `pub(crate)` invariant tripped. Indicates a bug in `lean-rs`.
348    Internal,
349}
350
351/// Stable, caller-facing classification of a `lean-rs` failure.
352///
353/// Every error-bearing public type projects to one of these via
354/// `.code()`:
355///
356/// - [`LeanError::code`] — `LeanException` → [`LeanDiagnosticCode::LeanException`],
357///   `Host` → the code recorded by the construction site.
358/// - `lean_rs_host::LeanElabFailure::code` — always [`LeanDiagnosticCode::Elaboration`].
359/// - `lean_rs_host::meta::LeanMetaResponse::code` — `Ok` → `None`,
360///   `Failed` / `TimeoutOrHeartbeat` → `Elaboration`, `Unsupported` →
361///   `Unsupported`.
362///
363/// Use `match err.code() { Linking => ..., ModuleInit => ..., _ => ... }`
364/// to react by family; reach for [`HostStage`] only when you need the
365/// host-stack's internal classification (and accept that it may grow new
366/// variants). The string form returned by [`Self::as_str`] is also the
367/// identifier emitted in tracing fields and listed in
368/// `docs/diagnostics.md`.
369///
370/// `#[non_exhaustive]` so later prompts may add new families. The
371/// variant names and `as_str()` ids are stable across patch releases.
372#[non_exhaustive]
373#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
374pub enum LeanDiagnosticCode {
375    /// Lean runtime initialization failed (panic in `lean_initialize`,
376    /// thread-attach floor failure, task-manager init failure).
377    RuntimeInit,
378    /// A linkable artefact was missing or did not match: a Lake
379    /// package/module name was invalid, the initializer symbol was
380    /// absent, or the header digest did not match the active toolchain.
381    Linking,
382    /// A capability dylib could not be opened, parsed, or its root
383    /// module initializer raised. Also: the Lake project root did not
384    /// exist or was not a directory.
385    ModuleInit,
386    /// A function or global symbol was not present in the loaded dylib
387    /// when a session call tried to resolve it.
388    SymbolLookup,
389    /// An ABI conversion failed: wrong Lean kind for the requested
390    /// Rust type, integer out of range, invalid UTF-8, or a queried
391    /// declaration was missing from the imported environment.
392    AbiConversion,
393    /// Lean raised through its `IO` error channel. Inspect
394    /// [`LeanException::kind`] for the `IO.Error` constructor.
395    LeanException,
396    /// Term parsing or elaboration produced one or more diagnostics.
397    /// The payload is a `lean_rs_host::LeanElabFailure` with the typed
398    /// diagnostic list.
399    Elaboration,
400    /// The loaded capability does not expose the requested service —
401    /// either the Lean shim returned `unsupported` for the request
402    /// shape, or the optional capability symbol was absent at load
403    /// time.
404    Unsupported,
405    /// A cooperative cancellation token was observed before dispatch.
406    Cancelled,
407    /// A `pub(crate)` invariant tripped, or a callback panicked inside
408    /// the safe boundary. Indicates a bug in `lean-rs`.
409    Internal,
410}
411
412impl LeanDiagnosticCode {
413    /// Stable identifier used in tracing fields and `docs/diagnostics.md`.
414    ///
415    /// The returned string is part of the published API; the value for
416    /// an existing variant is fixed across patch releases. New variants
417    /// may add new ids.
418    #[must_use]
419    pub const fn as_str(self) -> &'static str {
420        match self {
421            Self::RuntimeInit => "lean_rs.runtime_init",
422            Self::Linking => "lean_rs.linking",
423            Self::ModuleInit => "lean_rs.module_init",
424            Self::SymbolLookup => "lean_rs.symbol_lookup",
425            Self::AbiConversion => "lean_rs.abi_conversion",
426            Self::LeanException => "lean_rs.lean_exception",
427            Self::Elaboration => "lean_rs.elaboration",
428            Self::Unsupported => "lean_rs.unsupported",
429            Self::Cancelled => "lean_rs.cancelled",
430            Self::Internal => "lean_rs.internal",
431        }
432    }
433
434    /// One-line prose description used in `docs/diagnostics.md` and as
435    /// the default fallback when a span needs human-readable context.
436    #[must_use]
437    pub const fn description(self) -> &'static str {
438        match self {
439            Self::RuntimeInit => "Lean runtime initialization failed",
440            Self::Linking => "a linkable artefact was missing or mismatched",
441            Self::ModuleInit => "a capability dylib could not be opened or initialized",
442            Self::SymbolLookup => "a symbol was not present in the loaded dylib",
443            Self::AbiConversion => "an ABI conversion failed",
444            Self::LeanException => "Lean raised through its IO error channel",
445            Self::Elaboration => "term parsing or elaboration produced diagnostics",
446            Self::Unsupported => "the loaded capability does not expose the requested service",
447            Self::Cancelled => "a cooperative cancellation token was observed before dispatch",
448            Self::Internal => "a pub(crate) invariant tripped — likely a bug in lean-rs",
449        }
450    }
451}
452
453impl fmt::Display for LeanDiagnosticCode {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        f.write_str(self.as_str())
456    }
457}
458
459/// Constructor tag for Lean's `IO.Error`.
460///
461/// 1:1 with `IO.Error` at the active Lean toolchain version (4.29.1 —
462/// see `src/lean/Init/System/IOError.lean`), plus an [`Other`] catch-all
463/// for tag values not in the declaration. The crate-internal decoder
464/// maps the raw `lean_obj_tag` to a variant via a `const` table; a unit
465/// test anchors the `userError` mapping against the live Lean runtime
466/// and will fail if the constructor index drifts.
467///
468/// [`Other`]: Self::Other
469#[non_exhaustive]
470#[derive(Copy, Clone, Debug, Eq, PartialEq)]
471pub enum LeanExceptionKind {
472    /// `IO.Error.alreadyExists`
473    AlreadyExists,
474    /// `IO.Error.otherError`
475    OtherError,
476    /// `IO.Error.resourceBusy`
477    ResourceBusy,
478    /// `IO.Error.resourceVanished`
479    ResourceVanished,
480    /// `IO.Error.unsupportedOperation`
481    UnsupportedOperation,
482    /// `IO.Error.hardwareFault`
483    HardwareFault,
484    /// `IO.Error.unsatisfiedConstraints`
485    UnsatisfiedConstraints,
486    /// `IO.Error.illegalOperation`
487    IllegalOperation,
488    /// `IO.Error.protocolError`
489    ProtocolError,
490    /// `IO.Error.timeExpired`
491    TimeExpired,
492    /// `IO.Error.interrupted`
493    Interrupted,
494    /// `IO.Error.noFileOrDirectory`
495    NoFileOrDirectory,
496    /// `IO.Error.invalidArgument`
497    InvalidArgument,
498    /// `IO.Error.permissionDenied`
499    PermissionDenied,
500    /// `IO.Error.resourceExhausted`
501    ResourceExhausted,
502    /// `IO.Error.inappropriateType`
503    InappropriateType,
504    /// `IO.Error.noSuchThing`
505    NoSuchThing,
506    /// `IO.Error.unexpectedEof`
507    UnexpectedEof,
508    /// `IO.Error.userError`
509    UserError,
510    /// Tag did not match any known `IO.Error` constructor at the active
511    /// Lean toolchain version.
512    Other,
513}
514
515/// Truncate `s` to at most [`LEAN_ERROR_MESSAGE_LIMIT`] bytes on a UTF-8
516/// char boundary. The single place every constructor enforces the bound.
517///
518/// `#[doc(hidden)] pub` so the sibling `lean-rs-host` crate can apply the
519/// same bound when it constructs `LeanError::Host(HostFailure)` values
520/// through the `__host_internals` helpers. Re-exported through
521/// [`crate::__host_internals`]; not part of the public semver promise.
522#[doc(hidden)]
523pub fn bound_message(mut s: String) -> String {
524    if s.len() <= LEAN_ERROR_MESSAGE_LIMIT {
525        return s;
526    }
527    // Walk to the largest char boundary at or below the limit, then
528    // truncate. `floor_char_boundary` is unstable; do it manually with a
529    // single backward scan from the limit. `saturating_sub` keeps clippy's
530    // arithmetic-side-effects lint quiet without changing semantics — the
531    // loop guard already prevents `cut == 0` from underflowing.
532    let mut cut = LEAN_ERROR_MESSAGE_LIMIT;
533    while cut > 0 && !s.is_char_boundary(cut) {
534        cut = cut.saturating_sub(1);
535    }
536    s.truncate(cut);
537    s
538}
539
540// -- L1 → L2 boundary helpers -----------------------------------------
541//
542// `LeanError`'s constructors are `pub(crate)` to preserve the structural
543// bounding invariant: external crates cannot mint `LeanError` values
544// directly. The sibling `lean-rs-host` crate needs to construct host
545// failures and cooperative cancellation reports when it dispatches
546// capability shims; it reaches the constructors it actually uses through
547// this `#[doc(hidden)] pub fn` wrapper, re-exported at
548// [`crate::__host_internals`].
549//
550// The only wrappers live here are those a real call site needs. Add another the same way
551// (single-call wrapper + re-export in `crate::__host_internals`) only
552// when a future call site needs one.
553
554/// Construct a `ModuleInit` host failure. See [`LeanError::module_init`].
555#[doc(hidden)]
556pub fn host_module_init(message: impl Into<String>) -> LeanError {
557    LeanError::module_init(message)
558}
559
560/// Construct a cooperative cancellation report. See [`LeanError::cancelled`].
561#[doc(hidden)]
562pub fn host_cancelled() -> LeanError {
563    LeanError::cancelled()
564}
565
566/// Construct a host-internal failure. See [`LeanError::internal`].
567#[doc(hidden)]
568pub fn host_internal(message: impl Into<String>) -> LeanError {
569    LeanError::internal(message)
570}
571
572/// Construct a callback-panic host failure. See [`LeanError::callback_panic`].
573#[doc(hidden)]
574pub fn host_callback_panic(payload: &(dyn Any + Send)) -> LeanError {
575    LeanError::callback_panic(payload)
576}
577
578/// Render an arbitrary panic payload as a human-readable string. Strings
579/// (both `&'static str` and `String`) come through verbatim; other types
580/// collapse to a generic placeholder. Centralised so the
581/// `HostStage::RuntimeInit` and `HostStage::CallbackPanic` sites stay
582/// consistent.
583fn render_panic_payload(payload: &(dyn Any + Send)) -> String {
584    if let Some(s) = payload.downcast_ref::<&'static str>() {
585        (*s).to_owned()
586    } else if let Some(s) = payload.downcast_ref::<String>() {
587        s.clone()
588    } else {
589        "panic payload was not a string".to_owned()
590    }
591}