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