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 {}