Skip to main content

linesmith_core/plugins/
errors.rs

1//! [`PluginError`] covers every failure mode described in
2//! `docs/specs/plugin-api.md` §Edge cases.
3//!
4//! Load-time errors (`Compile`, `UnknownDataDep`, `MalformedDataDeps`,
5//! `IdCollision`) are collected in a `Vec<PluginError>` by the registry
6//! and surfaced via `linesmith doctor`. Runtime errors (`Runtime`,
7//! `ResourceExceeded`, `Timeout`, `MalformedReturn`) drop the plugin
8//! segment for one render invocation and log once to stderr.
9
10use std::path::PathBuf;
11
12/// Which of the configured rhai resource ceilings tripped. One-to-one
13/// with the `MAX_*` constants in [`crate::plugins::engine`]; a typed
14/// enum here (rather than `&'static str`) keeps [`PluginError::ResourceExceeded`]
15/// and `linesmith doctor` output typo-proof.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum ResourceLimit {
19    MaxOperations,
20    MaxCallLevels,
21    MaxExprDepth,
22    MaxStringSize,
23    MaxArraySize,
24    MaxMapSize,
25}
26
27impl ResourceLimit {
28    /// Stable string form for logs + doctor output.
29    #[must_use]
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Self::MaxOperations => "max_operations",
33            Self::MaxCallLevels => "max_call_levels",
34            Self::MaxExprDepth => "max_expr_depth",
35            Self::MaxStringSize => "max_string_size",
36            Self::MaxArraySize => "max_array_size",
37            Self::MaxMapSize => "max_map_size",
38        }
39    }
40}
41
42impl std::fmt::Display for ResourceLimit {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(self.as_str())
45    }
46}
47
48/// Every failure mode a plugin can hit at load time or render time.
49/// Variants match `plugin-api.md` §Edge cases; error copy is aimed at
50/// the person reading `linesmith doctor` output, not the plugin
51/// author's script.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum PluginError {
55    /// Rhai script failed to parse or compile at load time.
56    Compile { path: PathBuf, message: String },
57
58    /// Script runtime error during `render(ctx)` — any thrown rhai
59    /// error that wasn't a resource-limit hit. Drops the segment for
60    /// this invocation; logged once.
61    Runtime { id: String, message: String },
62
63    /// Script exceeded a configured rhai resource limit per
64    /// `plugin-api.md` §Resource ceilings. Drops the segment; logged.
65    ResourceExceeded { id: String, limit: ResourceLimit },
66
67    /// Host-side wallclock timer fired before the script returned
68    /// (default 50ms per render). Distinct from `ResourceExceeded
69    /// { limit: MaxOperations }`, which covers CPU-budget overruns
70    /// surfaced by rhai itself. Drops the segment; logged.
71    Timeout { id: String },
72
73    /// `render(ctx)` returned a value that isn't `()` and isn't a map
74    /// matching the `RenderedSegment` shape. Drops the segment.
75    MalformedReturn { id: String, message: String },
76
77    /// `@data_deps` declared a name that isn't in the plugin-accessible
78    /// set. Per `plugin-api.md`, `credentials` and `jsonl` are reserved
79    /// and surface here even though they're real `DataDep` variants.
80    /// `path` rather than `id` because header parsing fires before
81    /// `const ID` has been extracted from the script.
82    UnknownDataDep { path: PathBuf, name: String },
83
84    /// `@data_deps = ...` header didn't parse as a JSON-style array of
85    /// bare-string dep names. Same `path`-over-`id` rationale as
86    /// [`Self::UnknownDataDep`].
87    MalformedDataDeps { path: PathBuf, message: String },
88
89    /// Two discovered plugins (or a plugin and a built-in) claim the
90    /// same `id`. First-discovered wins per the precedence rules in
91    /// `plugin-api.md` §Plugin file location; loser is rejected.
92    IdCollision {
93        id: String,
94        winner: CollisionWinner,
95        loser_path: PathBuf,
96    },
97}
98
99impl PluginError {
100    /// Static variant tag — guaranteed token-free `&'static str`,
101    /// safe to render in any user-facing diagnostic. Use this in
102    /// place of `Display` or `Debug` when the consumer might be
103    /// rendering plugin-author-controlled data (e.g., `Runtime
104    /// { message }` and `MalformedReturn { message }` carry strings
105    /// the script author wrote, which can leak secrets via
106    /// `throw("...")`).
107    #[must_use]
108    pub fn kind(&self) -> &'static str {
109        match self {
110            Self::Compile { .. } => "Compile",
111            Self::Runtime { .. } => "Runtime",
112            Self::ResourceExceeded { .. } => "ResourceExceeded",
113            Self::Timeout { .. } => "Timeout",
114            Self::MalformedReturn { .. } => "MalformedReturn",
115            Self::UnknownDataDep { .. } => "UnknownDataDep",
116            Self::MalformedDataDeps { .. } => "MalformedDataDeps",
117            Self::IdCollision { .. } => "IdCollision",
118        }
119    }
120}
121
122/// What "won" an [`PluginError::IdCollision`] — either a built-in
123/// segment (which plugins can never shadow) or another plugin (keyed
124/// by path). Avoids the stringly-typed `PathBuf::from("<built-in>")`
125/// sentinel used before.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum CollisionWinner {
128    /// A built-in segment — no on-disk path, reserved globally.
129    BuiltIn,
130    /// Another plugin at the given path.
131    Plugin(PathBuf),
132}
133
134impl std::fmt::Display for CollisionWinner {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::BuiltIn => f.write_str("<built-in>"),
138            Self::Plugin(p) => f.write_str(&p.display().to_string()),
139        }
140    }
141}
142
143impl std::fmt::Display for PluginError {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        match self {
146            Self::Compile { path, message } => {
147                write!(f, "compile error in {}: {message}", path.display())
148            }
149            Self::Runtime { id, message } => {
150                write!(f, "plugin {id} runtime error: {message}")
151            }
152            Self::ResourceExceeded { id, limit } => {
153                write!(f, "plugin {id} exceeded {limit}")
154            }
155            Self::Timeout { id } => write!(f, "plugin {id} timed out"),
156            Self::MalformedReturn { id, message } => {
157                write!(f, "plugin {id} returned malformed value: {message}")
158            }
159            Self::UnknownDataDep { path, name } => {
160                write!(
161                    f,
162                    "plugin at {} declares unknown @data_deps entry `{name}`",
163                    path.display()
164                )
165            }
166            Self::MalformedDataDeps { path, message } => {
167                write!(
168                    f,
169                    "plugin at {} has malformed @data_deps header: {message}",
170                    path.display()
171                )
172            }
173            Self::IdCollision {
174                id,
175                winner,
176                loser_path,
177            } => write!(
178                f,
179                "plugin id `{id}` collision: kept {winner}, rejected {}",
180                loser_path.display()
181            ),
182        }
183    }
184}
185
186impl std::error::Error for PluginError {}