1use 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
32fn 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
54struct RenderState;
59
60impl RenderState {
61 fn install(plugin_id: &str, deadline: Instant) -> Self {
62 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
87pub 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 #[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 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 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 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 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 for token in linesmith_plugin::header::KNOWN_DEPS {
392 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 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 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 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 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 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 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 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}