Skip to main content

linesmith_core/plugins/
segment.rs

1//! `RhaiSegment` — the adapter that lets a compiled `.rhai` plugin
2//! participate in the layout engine as a first-class
3//! [`crate::segments::Segment`].
4//!
5//! Built from a [`CompiledPlugin`] + the shared `Arc<Engine>`:
6//! `declared_deps` is promoted to `&'static` (per the `Segment` trait's
7//! lifetime contract) via `Vec::leak` once at construction. Each
8//! render builds a fresh [`build_ctx`] mirror, invokes the script's
9//! `render(ctx)` function, and runs the returned value through
10//! [`validate_return`] for shape enforcement.
11//!
12//! Runtime failures (rhai errors, resource-exceeded, malformed
13//! return) surface as [`SegmentError`] so the layout engine logs once
14//! and hides the segment for this invocation, matching the posture of
15//! a built-in `render` that returns `Err`.
16
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use rhai::{Dynamic, Engine, EvalAltResult, Scope, AST};
21
22use crate::data_context::{DataContext, DataDep};
23use crate::segments::{RenderContext, RenderResult, Segment, SegmentError};
24
25use super::ctx_mirror::build_ctx;
26use super::engine::{
27    set_current_plugin_id, set_render_deadline, DeadlineAbortMarker, DEFAULT_RENDER_DEADLINE_MS,
28};
29use super::output::validate_return;
30use super::registry::CompiledPlugin;
31
32/// RAII guard for the engine's per-render thread-local state
33/// (`RENDER_DEADLINE` + `CURRENT_PLUGIN_ID`). Drop restores both to
34/// `None` even if the render panics or short-circuits, so a leaky
35/// thread-local can't poison subsequent renders on the same thread.
36struct RenderState;
37
38impl RenderState {
39    fn install(plugin_id: &str, deadline: Instant) -> Self {
40        // Catches a leaked thread-local — would mean a prior render
41        // panicked between install and Drop without unwinding through
42        // a Drop handler (e.g. caught by `catch_unwind`). Production
43        // wouldn't notice; dev / test surfaces it loudly.
44        debug_assert!(
45            super::engine::render_deadline_snapshot().is_none(),
46            "RENDER_DEADLINE leaked from a prior render"
47        );
48        debug_assert!(
49            super::engine::current_plugin_id_snapshot().is_none(),
50            "CURRENT_PLUGIN_ID leaked from a prior render"
51        );
52        set_render_deadline(Some(deadline));
53        set_current_plugin_id(Some(plugin_id));
54        Self
55    }
56}
57
58impl Drop for RenderState {
59    fn drop(&mut self) {
60        set_render_deadline(None);
61        set_current_plugin_id(None);
62    }
63}
64
65/// A plugin-authored segment backed by a compiled rhai script.
66pub struct RhaiSegment {
67    id: String,
68    ast: AST,
69    engine: Arc<Engine>,
70    config: Dynamic,
71    declared_deps: &'static [DataDep],
72}
73
74impl RhaiSegment {
75    /// Wrap a [`CompiledPlugin`] in the [`Segment`] trait.
76    ///
77    /// `config` is the plugin's `[segments.<id>]` TOML table, already
78    /// converted to a rhai-compatible [`Dynamic`]. Pass [`Dynamic::UNIT`]
79    /// when no table is configured.
80    ///
81    /// Consumes `plugin`: the AST and declared deps move into the
82    /// segment, and `declared_deps` is promoted to `&'static` via
83    /// [`Vec::leak`] — see [`Segment::data_deps`] for why.
84    #[must_use]
85    pub fn from_compiled(plugin: CompiledPlugin, engine: Arc<Engine>, config: Dynamic) -> Self {
86        let declared_deps: &'static [DataDep] = Vec::leak(plugin.declared_deps);
87        Self {
88            id: plugin.id,
89            ast: plugin.ast,
90            engine,
91            config,
92            declared_deps,
93        }
94    }
95
96    #[must_use]
97    pub fn id(&self) -> &str {
98        &self.id
99    }
100
101    /// Map a rhai eval error into a [`SegmentError`]. Deadline aborts
102    /// get a wallclock-specific message that names the host's default
103    /// budget; every other failure carries rhai's own `Display`
104    /// output through unchanged.
105    ///
106    /// Deadline classification matches against [`DeadlineAbortMarker`]
107    /// — a host-only Rust type plugins can't construct from rhai.
108    /// Plugin `throw` also surfaces as `ErrorTerminated`, but with a
109    /// script-supplied payload that can't impersonate the marker.
110    fn classify_render_error(&self, err: Box<EvalAltResult>) -> SegmentError {
111        if let EvalAltResult::ErrorTerminated(token, _) = err.as_ref() {
112            if token.is::<DeadlineAbortMarker>() {
113                return SegmentError::new(format!(
114                    "plugin `{}` exceeded the {}ms render deadline",
115                    self.id, DEFAULT_RENDER_DEADLINE_MS
116                ));
117            }
118        }
119        SegmentError::new(format!("plugin `{}` render failed: {err}", self.id))
120    }
121}
122
123impl Segment for RhaiSegment {
124    fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult {
125        let mirror = build_ctx(ctx, rc, self.declared_deps, self.config.clone());
126        let deadline = Instant::now() + Duration::from_millis(DEFAULT_RENDER_DEADLINE_MS);
127        let _state = RenderState::install(&self.id, deadline);
128        let mut scope = Scope::new();
129        let returned: Dynamic = self
130            .engine
131            .call_fn(&mut scope, &self.ast, "render", (mirror,))
132            .map_err(|e| self.classify_render_error(e))?;
133        validate_return(returned, &self.id).map_err(|e| {
134            SegmentError::new(format!(
135                "plugin `{}` returned malformed shape: {e}",
136                self.id
137            ))
138        })
139    }
140
141    fn data_deps(&self) -> &'static [DataDep] {
142        self.declared_deps
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
150    use crate::plugins::build_engine;
151    use crate::plugins::registry::PluginRegistry;
152    use std::fs;
153    use std::path::PathBuf;
154    use std::sync::Arc;
155    use tempfile::TempDir;
156
157    fn minimal_status() -> StatusContext {
158        StatusContext {
159            tool: Tool::ClaudeCode,
160            model: Some(ModelInfo {
161                display_name: "Sonnet".to_string(),
162            }),
163            workspace: Some(WorkspaceInfo {
164                project_dir: PathBuf::from("/repo"),
165                git_worktree: None,
166            }),
167            context_window: None,
168            cost: None,
169            effort: None,
170            vim: None,
171            output_style: None,
172            agent_name: None,
173            version: None,
174            raw: Arc::new(serde_json::json!({})),
175        }
176    }
177
178    fn load_single(
179        dir: &tempfile::TempDir,
180        name: &str,
181        src: &str,
182    ) -> (CompiledPlugin, Arc<Engine>) {
183        fs::write(dir.path().join(name), src).expect("write plugin");
184        let engine = build_engine();
185        let registry =
186            PluginRegistry::load_with_xdg(&[dir.path().to_path_buf()], None, &engine, &[]);
187        assert!(
188            registry.load_errors().is_empty(),
189            "unexpected load errors: {:?}",
190            registry.load_errors()
191        );
192        let plugin = registry
193            .into_plugins()
194            .into_iter()
195            .next()
196            .expect("plugin loaded");
197        (plugin, engine)
198    }
199
200    #[test]
201    fn plugin_can_read_terminal_width_from_ctx_render() {
202        // Single test that exercises the full RenderContext threading
203        // path: layout engine → RhaiSegment::render → build_ctx →
204        // build_render → rhai property read. A regression in any link
205        // (missing key, misnamed field, host-side mirror dropped)
206        // surfaces here. The number `137` is arbitrary; the test pins
207        // that whatever the host passes is what the script sees.
208        let tmp = TempDir::new().expect("tempdir");
209        let (plugin, engine) = load_single(
210            &tmp,
211            "tw.rhai",
212            r#"
213            const ID = "tw";
214            fn render(ctx) {
215                #{ runs: [#{ text: `${ctx.render.terminal_width}` }] }
216            }
217            "#,
218        );
219        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
220        let dc = DataContext::new(minimal_status());
221        let rc = RenderContext::new(137);
222        let rendered = seg.render(&dc, &rc).unwrap().expect("rendered");
223        assert_eq!(rendered.text(), "137");
224    }
225
226    #[test]
227    fn plugin_returning_unit_hides_segment() {
228        let tmp = TempDir::new().expect("tempdir");
229        let (plugin, engine) = load_single(
230            &tmp,
231            "hide.rhai",
232            r#"
233            const ID = "hide";
234            fn render(ctx) { () }
235            "#,
236        );
237        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
238        let dc = DataContext::new(minimal_status());
239        let rc = RenderContext::new(80);
240        assert_eq!(seg.render(&dc, &rc).unwrap(), None);
241    }
242
243    #[test]
244    fn plugin_returning_single_run_renders() {
245        let tmp = TempDir::new().expect("tempdir");
246        let (plugin, engine) = load_single(
247            &tmp,
248            "simple.rhai",
249            r#"
250            const ID = "simple";
251            fn render(ctx) {
252                #{ runs: [#{ text: "hello" }] }
253            }
254            "#,
255        );
256        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
257        let dc = DataContext::new(minimal_status());
258        let rc = RenderContext::new(80);
259        let rendered = seg.render(&dc, &rc).unwrap().expect("rendered");
260        assert_eq!(rendered.text(), "hello");
261    }
262
263    #[test]
264    fn plugin_sees_status_fields_via_ctx() {
265        let tmp = TempDir::new().expect("tempdir");
266        let (plugin, engine) = load_single(
267            &tmp,
268            "model_echo.rhai",
269            r#"
270            const ID = "model_echo";
271            fn render(ctx) {
272                #{ runs: [#{ text: ctx.status.model.display_name }] }
273            }
274            "#,
275        );
276        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
277        let dc = DataContext::new(minimal_status());
278        let rc = RenderContext::new(80);
279        let rendered = seg.render(&dc, &rc).unwrap().expect("rendered");
280        assert_eq!(rendered.text(), "Sonnet");
281    }
282
283    #[test]
284    fn plugin_receives_config_passed_in() {
285        let tmp = TempDir::new().expect("tempdir");
286        let (plugin, engine) = load_single(
287            &tmp,
288            "cfg.rhai",
289            r#"
290            const ID = "cfg";
291            fn render(ctx) {
292                #{ runs: [#{ text: ctx.config.label }] }
293            }
294            "#,
295        );
296        let mut config = rhai::Map::new();
297        config.insert("label".into(), Dynamic::from("configured".to_string()));
298        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::from_map(config));
299        let dc = DataContext::new(minimal_status());
300        let rc = RenderContext::new(80);
301        let rendered = seg.render(&dc, &rc).unwrap().expect("rendered");
302        assert_eq!(rendered.text(), "configured");
303    }
304
305    #[test]
306    fn plugin_can_read_ctx_env_from_rhai_side() {
307        // The whitelist + OnceLock snapshot in `ctx_mirror` is only
308        // useful if `ctx.env.<KEY>` is actually reachable from a
309        // running plugin. A plugin that branches on `ctx.env.TERM ==
310        // ()` (the unset case) covers both the snapshot and the
311        // unit-or-string discriminator.
312        let tmp = TempDir::new().expect("tempdir");
313        let (plugin, engine) = load_single(
314            &tmp,
315            "env.rhai",
316            r#"
317            const ID = "env_probe";
318            fn render(ctx) {
319                let term = ctx.env.TERM;
320                let label = if term == () { "unset" } else { "set" };
321                #{ runs: [#{ text: label }] }
322            }
323            "#,
324        );
325        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
326        let dc = DataContext::new(minimal_status());
327        let rc = RenderContext::new(80);
328        let rendered = seg.render(&dc, &rc).unwrap().expect("rendered");
329        // Don't pin to "set" or "unset" — env_snapshot() is
330        // process-cached, so test order can decide whether `TERM`
331        // was set when the OnceLock was populated. Either label
332        // proves the env path round-trips through rhai.
333        assert!(rendered.text() == "set" || rendered.text() == "unset");
334    }
335
336    #[test]
337    fn declared_deps_surface_via_trait() {
338        let tmp = TempDir::new().expect("tempdir");
339        let (plugin, engine) = load_single(
340            &tmp,
341            "deps.rhai",
342            r#"// @data_deps = ["usage"]
343            const ID = "deps";
344            fn render(ctx) { () }
345            "#,
346        );
347        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
348        assert!(seg.data_deps().contains(&DataDep::Status));
349        assert!(seg.data_deps().contains(&DataDep::Usage));
350    }
351
352    #[test]
353    fn plugin_runtime_error_maps_to_segment_error() {
354        // Division-by-zero at runtime surfaces as a rhai error. The
355        // segment must map it to `SegmentError` (hide + log), not
356        // panic. Also confirms the *generic* classifier branch:
357        // non-deadline failures must NOT be relabeled as a deadline
358        // abort by an over-eager classifier match.
359        let tmp = TempDir::new().expect("tempdir");
360        let (plugin, engine) = load_single(
361            &tmp,
362            "boom.rhai",
363            r#"
364            const ID = "boom";
365            fn render(ctx) {
366                let n = 1 / 0;
367                #{ runs: [#{ text: `${n}` }] }
368            }
369            "#,
370        );
371        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
372        let dc = DataContext::new(minimal_status());
373        let rc = RenderContext::new(80);
374        let err = seg.render(&dc, &rc).unwrap_err();
375        assert!(err.message.contains("boom"), "message: {}", err.message);
376        assert!(
377            err.message.contains("render failed"),
378            "non-deadline failures must use the generic branch: {}",
379            err.message
380        );
381        assert!(
382            !err.message.contains("deadline"),
383            "non-deadline failures must NOT be relabeled as a timeout: {}",
384            err.message
385        );
386    }
387
388    #[test]
389    fn plugin_throw_cannot_impersonate_deadline_abort() {
390        // Codex flagged: a plugin that `throw`s a string identical to
391        // a former host sentinel could be misclassified as a deadline
392        // timeout. With the marker-type sentinel, even a plugin that
393        // throws the most-suspicious-looking string must fall through
394        // to the generic "render failed" branch.
395        let tmp = TempDir::new().expect("tempdir");
396        let (plugin, engine) = load_single(
397            &tmp,
398            "fake.rhai",
399            r##"
400            const ID = "fake_deadline";
401            fn render(ctx) {
402                throw "linesmith:render-deadline-exceeded";
403            }
404            "##,
405        );
406        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
407        let dc = DataContext::new(minimal_status());
408        let rc = RenderContext::new(80);
409        let err = seg.render(&dc, &rc).unwrap_err();
410        assert!(
411            err.message.contains("render failed"),
412            "throw must use the generic branch: {}",
413            err.message
414        );
415        // Host-specific wording is "exceeded the {N}ms render deadline";
416        // the thrown payload's "deadline" substring is allowed because
417        // it's the script's own message, surfaced verbatim by rhai.
418        assert!(
419            !err.message.contains("exceeded the"),
420            "thrown payload must not impersonate the host deadline message: {}",
421            err.message
422        );
423    }
424
425    #[test]
426    fn render_state_drop_clears_thread_locals() {
427        // Pin the load-bearing safety property of `RenderState`: Drop
428        // restores both thread-locals to None even after a clean
429        // scope exit. A regression that removed either set_*(None)
430        // call from Drop would silently leak a stale deadline or
431        // plugin id into subsequent renders on this thread.
432        use crate::plugins::engine::{current_plugin_id_snapshot, render_deadline_snapshot};
433        {
434            let _state =
435                RenderState::install("guard_test", Instant::now() + Duration::from_secs(60));
436            assert!(render_deadline_snapshot().is_some());
437            assert_eq!(current_plugin_id_snapshot().as_deref(), Some("guard_test"));
438        }
439        assert!(render_deadline_snapshot().is_none(), "deadline leaked");
440        assert!(current_plugin_id_snapshot().is_none(), "plugin id leaked");
441    }
442
443    #[test]
444    fn plugin_returning_malformed_shape_maps_to_segment_error() {
445        let tmp = TempDir::new().expect("tempdir");
446        let (plugin, engine) = load_single(
447            &tmp,
448            "bad.rhai",
449            r#"
450            const ID = "bad_shape";
451            fn render(ctx) { 42 }
452            "#,
453        );
454        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
455        let dc = DataContext::new(minimal_status());
456        let rc = RenderContext::new(80);
457        let err = seg.render(&dc, &rc).unwrap_err();
458        assert!(
459            err.message.contains("bad_shape"),
460            "message: {}",
461            err.message
462        );
463        assert!(
464            err.message.to_lowercase().contains("malformed") || err.message.contains("must return"),
465            "message: {}",
466            err.message
467        );
468    }
469
470    #[test]
471    fn deadline_abort_surfaces_clear_segment_error() {
472        // RhaiSegment::render installs a fresh 50ms deadline via its
473        // RAII guard, overwriting any prior thread-local. To exercise
474        // the classifier path the segment uses on a real abort, drive
475        // the engine directly with a past deadline, then feed the
476        // resulting EvalAltResult through `classify_render_error`.
477        use crate::plugins::engine::set_render_deadline;
478        let tmp = TempDir::new().expect("tempdir");
479        let (plugin, engine) =
480            load_single(&tmp, "x.rhai", r#"const ID = "x"; fn render(ctx) { () }"#);
481        set_render_deadline(Some(Instant::now()));
482        let err = engine.eval::<()>("loop {}").unwrap_err();
483        set_render_deadline(None);
484        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
485        let segment_err = seg.classify_render_error(err);
486        assert!(
487            segment_err.message.contains("deadline"),
488            "deadline aborts should name the timeout: {}",
489            segment_err.message
490        );
491    }
492
493    #[test]
494    fn operation_limit_kills_infinite_loop_without_hang() {
495        // Without the engine's `max_operations` ceiling this test
496        // hangs CI instead of failing.
497        let tmp = TempDir::new().expect("tempdir");
498        let (plugin, engine) = load_single(
499            &tmp,
500            "loop.rhai",
501            r#"
502            const ID = "loop";
503            fn render(ctx) {
504                loop {}
505            }
506            "#,
507        );
508        let seg = RhaiSegment::from_compiled(plugin, engine, Dynamic::UNIT);
509        let dc = DataContext::new(minimal_status());
510        let rc = RenderContext::new(80);
511        let err = seg.render(&dc, &rc).unwrap_err();
512        assert!(
513            err.message.to_lowercase().contains("operation") || err.message.contains("loop"),
514            "message: {}",
515            err.message
516        );
517    }
518}