Skip to main content

linesmith_plugin/
error.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::engine`]; a typed enum here
14/// (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 defined by `header::KNOWN_DEPS`. Per
79    /// `plugin-api.md` §@data_deps header syntax, `credentials` and
80    /// `jsonl` are reserved and surface here alongside truly unknown
81    /// names; consumer-side handling of those reserved names lives
82    /// in linesmith-core. `path` rather than `id` because header
83    /// parsing fires before `const ID` has been extracted from the
84    /// script.
85    UnknownDataDep { path: PathBuf, name: String },
86
87    /// `@data_deps = ...` header didn't parse as a JSON-style array of
88    /// bare-string dep names. Same `path`-over-`id` rationale as
89    /// [`Self::UnknownDataDep`].
90    MalformedDataDeps { path: PathBuf, message: String },
91
92    /// Two discovered plugins (or a plugin and a built-in) claim the
93    /// same `id`. First-discovered wins per the precedence rules in
94    /// `plugin-api.md` §Plugin file location; loser is rejected.
95    IdCollision {
96        id: String,
97        winner: CollisionWinner,
98        loser_path: PathBuf,
99    },
100}
101
102impl PluginError {
103    /// Static variant tag — guaranteed token-free `&'static str`,
104    /// safe to render in any user-facing diagnostic. Use this in
105    /// place of `Display` or `Debug` when the consumer might be
106    /// rendering plugin-author-controlled data (e.g., `Runtime
107    /// { message }` and `MalformedReturn { message }` carry strings
108    /// the script author wrote, which can leak secrets via
109    /// `throw("...")`).
110    #[must_use]
111    pub fn kind(&self) -> &'static str {
112        match self {
113            Self::Compile { .. } => "Compile",
114            Self::Runtime { .. } => "Runtime",
115            Self::ResourceExceeded { .. } => "ResourceExceeded",
116            Self::Timeout { .. } => "Timeout",
117            Self::MalformedReturn { .. } => "MalformedReturn",
118            Self::UnknownDataDep { .. } => "UnknownDataDep",
119            Self::MalformedDataDeps { .. } => "MalformedDataDeps",
120            Self::IdCollision { .. } => "IdCollision",
121        }
122    }
123}
124
125/// What "won" an [`PluginError::IdCollision`] — either a built-in
126/// segment (which plugins can never shadow) or another plugin (keyed
127/// by path). Avoids the stringly-typed `PathBuf::from("<built-in>")`
128/// sentinel used before.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum CollisionWinner {
131    /// A built-in segment — no on-disk path, reserved globally.
132    BuiltIn,
133    /// Another plugin at the given path.
134    Plugin(PathBuf),
135}
136
137impl std::fmt::Display for CollisionWinner {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        match self {
140            Self::BuiltIn => f.write_str("<built-in>"),
141            Self::Plugin(p) => f.write_str(&p.display().to_string()),
142        }
143    }
144}
145
146impl std::fmt::Display for PluginError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::Compile { path, message } => {
150                write!(f, "compile error in {}: {message}", path.display())
151            }
152            Self::Runtime { id, message } => {
153                write!(f, "plugin {id} runtime error: {message}")
154            }
155            Self::ResourceExceeded { id, limit } => {
156                write!(f, "plugin {id} exceeded {limit}")
157            }
158            Self::Timeout { id } => write!(f, "plugin {id} timed out"),
159            Self::MalformedReturn { id, message } => {
160                write!(f, "plugin {id} returned malformed value: {message}")
161            }
162            Self::UnknownDataDep { path, name } => {
163                write!(
164                    f,
165                    "plugin at {} declares unknown @data_deps entry `{name}`",
166                    path.display()
167                )
168            }
169            Self::MalformedDataDeps { path, message } => {
170                write!(
171                    f,
172                    "plugin at {} has malformed @data_deps header: {message}",
173                    path.display()
174                )
175            }
176            Self::IdCollision {
177                id,
178                winner,
179                loser_path,
180            } => write!(
181                f,
182                "plugin id `{id}` collision: kept {winner}, rejected {}",
183                loser_path.display()
184            ),
185        }
186    }
187}
188
189impl std::error::Error for PluginError {}