1use 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
32struct RenderState;
37
38impl RenderState {
39 fn install(plugin_id: &str, deadline: Instant) -> Self {
40 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
65pub 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 #[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 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 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 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 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 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 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 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 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 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 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}