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