linesmith_plugin/
engine.rs1use std::cell::{Cell, RefCell};
22use std::collections::HashMap;
23use std::sync::{Arc, OnceLock};
24use std::time::Instant;
25
26#[cfg(test)]
27use jiff::SignedDuration;
28use jiff::Timestamp;
29use rhai::packages::{Package, StandardPackage};
30use rhai::{Dynamic, Engine, EvalAltResult};
31
32pub const MAX_OPERATIONS: u64 = 50_000;
34pub const MAX_CALL_LEVELS: usize = 16;
36pub const MAX_EXPR_DEPTH: usize = 32;
38pub const MAX_STRING_SIZE: usize = 1024;
40pub const MAX_ARRAY_SIZE: usize = 256;
42pub const MAX_MAP_SIZE: usize = 256;
44pub const DEFAULT_RENDER_DEADLINE_MS: u64 = 50;
46
47#[derive(Clone)]
57pub(crate) struct DeadlineAbortMarker;
58const DEADLINE_CHECK_STRIDE: u64 = 256;
65
66const _: () = assert!(DEADLINE_CHECK_STRIDE > 0);
70const _: () = assert!(DEADLINE_CHECK_STRIDE < MAX_OPERATIONS);
71
72thread_local! {
73 static RENDER_DEADLINE: Cell<Option<Instant>> = const { Cell::new(None) };
77
78 static CURRENT_PLUGIN_ID: RefCell<Option<String>> = const { RefCell::new(None) };
82
83 static LOG_EMITTED: RefCell<HashMap<String, u32>> = RefCell::new(HashMap::new());
90}
91
92pub const LOG_LINES_PER_PLUGIN: u32 = 1;
95
96type WarnEmitter = Box<dyn Fn(&str) + Send + Sync>;
108static WARN_EMITTER: OnceLock<WarnEmitter> = OnceLock::new();
109
110pub fn install_warn_emitter(emitter: WarnEmitter) {
119 debug_assert!(
120 WARN_EMITTER.get().is_none(),
121 "install_warn_emitter called twice — first install wins, subsequent emitter is dropped"
122 );
123 let _ = WARN_EMITTER.set(emitter);
124}
125
126fn emit_warn(msg: &str) {
129 if let Some(emitter) = WARN_EMITTER.get() {
130 emitter(msg);
131 } else {
132 eprintln!("linesmith [warn] {msg}");
133 }
134}
135
136pub fn set_render_deadline(deadline: Option<Instant>) {
139 RENDER_DEADLINE.with(|d| d.set(deadline));
140}
141
142pub fn set_current_plugin_id(id: Option<&str>) {
145 CURRENT_PLUGIN_ID.with(|cell| {
146 *cell.borrow_mut() = id.map(str::to_owned);
147 });
148}
149
150#[cfg(test)]
155pub(crate) fn reset_log_counts() {
156 LOG_EMITTED.with(|cell| cell.borrow_mut().clear());
157}
158
159pub fn render_deadline_snapshot() -> Option<Instant> {
163 RENDER_DEADLINE.with(Cell::get)
164}
165
166#[must_use]
177pub fn is_deadline_abort(err: &EvalAltResult) -> bool {
178 if let EvalAltResult::ErrorTerminated(token, _) = err {
179 token.is::<DeadlineAbortMarker>()
180 } else {
181 false
182 }
183}
184
185pub fn current_plugin_id_snapshot() -> Option<String> {
188 CURRENT_PLUGIN_ID.with(|c| c.borrow().clone())
189}
190
191#[must_use]
196pub fn build_engine() -> Arc<Engine> {
197 let mut engine = Engine::new_raw();
198 engine.register_global_module(StandardPackage::new().as_shared_module());
201 engine.on_print(|_| {});
204 engine.on_debug(|_, _, _| {});
205 install_deadline_callback(&mut engine);
206 configure_limits(&mut engine);
207 lock_down_symbols(&mut engine);
208 register_host_fns(&mut engine);
209 Arc::new(engine)
210}
211
212fn install_deadline_callback(engine: &mut Engine) {
213 engine.on_progress(|ops| {
214 if ops % DEADLINE_CHECK_STRIDE != 0 {
215 return None;
216 }
217 let deadline = RENDER_DEADLINE.with(Cell::get)?;
218 if Instant::now() >= deadline {
219 Some(Dynamic::from(DeadlineAbortMarker))
220 } else {
221 None
222 }
223 });
224}
225
226fn configure_limits(engine: &mut Engine) {
227 engine.set_max_operations(MAX_OPERATIONS);
228 engine.set_max_call_levels(MAX_CALL_LEVELS);
229 engine.set_max_expr_depths(MAX_EXPR_DEPTH, MAX_EXPR_DEPTH);
230 engine.set_max_string_size(MAX_STRING_SIZE);
231 engine.set_max_array_size(MAX_ARRAY_SIZE);
232 engine.set_max_map_size(MAX_MAP_SIZE);
233}
234
235fn lock_down_symbols(engine: &mut Engine) {
236 engine.disable_symbol("import");
239 engine.disable_symbol("eval");
242}
243
244fn register_host_fns(engine: &mut Engine) {
245 engine.register_fn("log", rhai_log);
246 engine.register_fn("format_duration", rhai_format_duration);
247 engine.register_fn("format_cost_usd", rhai_format_cost_usd);
252 engine.register_fn("format_cost_usd", |n: i64| rhai_format_cost_usd(n as f64));
253 engine.register_fn("format_tokens", rhai_format_tokens);
254 engine.register_fn("format_countdown_until", rhai_format_countdown_until);
255}
256
257const _: fn() = || {
263 fn assert_send_sync<T: Send + Sync>() {}
264 assert_send_sync::<Arc<Engine>>();
265};
266
267fn rhai_log(msg: &str) {
287 const UNSCOPED: &str = "<unscoped>";
289
290 let allowed = LOG_EMITTED.with(|cell| {
291 let mut counts = cell.borrow_mut();
292 let id_str = CURRENT_PLUGIN_ID.with(|c| c.borrow().clone());
293 let key: &str = id_str.as_deref().unwrap_or(UNSCOPED);
294 match counts.get_mut(key) {
295 Some(n) if *n >= LOG_LINES_PER_PLUGIN => None,
296 Some(n) => {
297 *n += 1;
298 Some(key.to_owned())
299 }
300 None => {
301 counts.insert(key.to_owned(), 1);
302 Some(key.to_owned())
303 }
304 }
305 });
306 if let Some(id) = allowed {
307 emit_warn(&format!("plugin {id}: {msg}"));
308 }
309}
310
311fn rhai_format_duration(ms: i64) -> String {
314 if ms <= 0 {
315 return "0s".to_string();
316 }
317 let total_seconds = ms / 1000;
318 let hours = total_seconds / 3600;
319 let minutes = (total_seconds % 3600) / 60;
320 let seconds = total_seconds % 60;
321 if hours > 0 {
322 if minutes > 0 {
323 format!("{hours}h {minutes}m")
324 } else {
325 format!("{hours}h")
326 }
327 } else if minutes > 0 {
328 format!("{minutes}m")
329 } else {
330 format!("{seconds}s")
331 }
332}
333
334fn rhai_format_cost_usd(dollars: f64) -> String {
336 format!("${dollars:.2}")
337}
338
339fn rhai_format_tokens(count: i64) -> String {
347 let n = count.max(0);
348 if n >= 999_500 {
349 let m = n as f64 / 1_000_000.0;
350 format!("{m:.1}M")
351 } else if n >= 1_000 {
352 let k = n as f64 / 1_000.0;
353 format!("{k:.1}k")
354 } else {
355 format!("{n}")
356 }
357}
358
359fn rhai_format_countdown_until(rfc3339_ts: &str) -> String {
366 let Ok(target) = rfc3339_ts.parse::<Timestamp>() else {
367 return "?".to_string();
368 };
369 let total_minutes = (target.as_second() - Timestamp::now().as_second()) / 60;
370 if total_minutes <= 0 {
371 return "now".to_string();
372 }
373 let days = total_minutes / (24 * 60);
374 if days >= 2 {
375 return format!("{days}d");
376 }
377 let hours = total_minutes / 60;
378 if hours >= 1 {
379 let minutes = total_minutes - hours * 60;
380 return if minutes == 0 {
381 format!("{hours}h")
382 } else {
383 format!("{hours}h {minutes}m")
384 };
385 }
386 format!("{total_minutes}m")
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn engine_evaluates_basic_arithmetic() {
395 let engine = build_engine();
396 let n: i64 = engine.eval("1 + 2").expect("eval ok");
397 assert_eq!(n, 3);
398 }
399
400 #[test]
401 fn infinite_loop_trips_operation_limit() {
402 let engine = build_engine();
403 let err = engine.eval::<()>("loop {}").unwrap_err();
404 assert!(
407 format!("{err}").contains("operations"),
408 "expected operation-limit error, got: {err}"
409 );
410 }
411
412 struct ThreadLocalGuard;
417
418 impl ThreadLocalGuard {
419 fn install_deadline(at: Instant) -> Self {
420 set_render_deadline(Some(at));
421 Self
422 }
423
424 fn install_plugin_id(id: &str) -> Self {
425 set_current_plugin_id(Some(id));
426 Self
427 }
428 }
429
430 impl Drop for ThreadLocalGuard {
431 fn drop(&mut self) {
432 set_render_deadline(None);
433 set_current_plugin_id(None);
434 }
435 }
436
437 #[test]
438 fn past_deadline_aborts_long_running_script() {
439 let engine = build_engine();
440 let _guard = ThreadLocalGuard::install_deadline(Instant::now());
441 let err = engine.eval::<()>("loop {}").unwrap_err();
442 let msg = format!("{err}");
443 assert!(
444 msg.to_lowercase().contains("terminated"),
445 "expected `Script terminated` from on_progress abort, got: {msg}"
446 );
447 }
448
449 #[test]
450 fn far_future_deadline_does_not_abort_quick_script() {
451 let engine = build_engine();
452 let _guard = ThreadLocalGuard::install_deadline(
453 Instant::now() + std::time::Duration::from_secs(3600),
454 );
455 let n: i64 = engine.eval("1 + 2 + 3").expect("quick eval ok");
456 assert_eq!(n, 6);
457 }
458
459 #[test]
460 fn no_deadline_set_does_not_abort_quick_script() {
461 set_render_deadline(None);
465 let engine = build_engine();
466 let n: i64 = engine.eval("4 * 5").expect("eval ok");
467 assert_eq!(n, 20);
468 }
469
470 #[test]
471 fn log_emits_first_call_then_silences() {
472 reset_log_counts();
477 let engine = build_engine();
478 let _guard = ThreadLocalGuard::install_plugin_id("log_emits_first_call_then_silences");
479 engine
480 .eval::<()>(r#"log("first"); log("second"); log("third");"#)
481 .expect("eval ok");
482 let count = LOG_EMITTED.with(|cell| {
483 cell.borrow()
484 .get("log_emits_first_call_then_silences")
485 .copied()
486 .unwrap_or(0)
487 });
488 assert_eq!(
489 count, LOG_LINES_PER_PLUGIN,
490 "expected exactly {LOG_LINES_PER_PLUGIN} emission(s), counted {count}"
491 );
492 }
493
494 #[test]
495 fn log_under_distinct_plugin_ids_each_gets_its_own_quota() {
496 reset_log_counts();
497 let engine = build_engine();
498 for id in ["log_quota_a", "log_quota_b"] {
499 let _guard = ThreadLocalGuard::install_plugin_id(id);
500 engine.eval::<()>(r#"log("hi");"#).expect("eval ok");
501 }
502 let counts = LOG_EMITTED.with(|cell| {
503 let map = cell.borrow();
504 (
505 map.get("log_quota_a").copied().unwrap_or(0),
506 map.get("log_quota_b").copied().unwrap_or(0),
507 )
508 });
509 assert_eq!(counts, (LOG_LINES_PER_PLUGIN, LOG_LINES_PER_PLUGIN));
510 }
511
512 #[test]
513 fn log_outside_render_attributes_to_unscoped_bucket() {
514 reset_log_counts();
518 let engine = build_engine();
519 engine.eval::<()>(r#"log("from-eval");"#).expect("eval ok");
520 let count = LOG_EMITTED.with(|cell| cell.borrow().get("<unscoped>").copied());
521 assert_eq!(count, Some(LOG_LINES_PER_PLUGIN));
522 }
523
524 #[test]
525 fn import_is_disabled() {
526 let engine = build_engine();
527 let err = engine.eval::<()>(r#"import "foo" as bar;"#).unwrap_err();
530 assert!(
531 format!("{err}").to_lowercase().contains("import"),
532 "expected import-related error, got: {err}"
533 );
534 }
535
536 #[test]
537 fn eval_symbol_is_disabled() {
538 let engine = build_engine();
539 let err = engine.eval::<()>(r#"eval("1 + 1")"#).unwrap_err();
540 assert!(
541 format!("{err}").to_lowercase().contains("eval"),
542 "expected eval-related error, got: {err}"
543 );
544 }
545
546 #[test]
547 fn unregistered_fs_call_fails_at_runtime() {
548 let engine = build_engine();
551 let err = engine.eval::<()>(r#"fs::read("/etc/passwd")"#).unwrap_err();
552 let msg = format!("{err}").to_lowercase();
553 assert!(
554 msg.contains("fs::read") || msg.contains("not found") || msg.contains("function"),
555 "expected function-not-found error, got: {err}"
556 );
557 }
558
559 #[test]
560 fn print_and_debug_are_silent_no_ops() {
561 let engine = build_engine();
568 engine
571 .eval::<()>(
572 r#"print("this would leak to stdout under Engine::new"); debug("this too");"#,
573 )
574 .expect("print/debug call must succeed as a no-op");
575 }
576
577 #[test]
578 fn format_duration_sub_minute_renders_seconds() {
579 assert_eq!(rhai_format_duration(45_000), "45s");
580 }
581
582 #[test]
583 fn format_duration_negative_clamps_to_zero() {
584 assert_eq!(rhai_format_duration(-1), "0s");
585 }
586
587 #[test]
588 fn format_duration_renders_hours_and_minutes() {
589 assert_eq!(rhai_format_duration(3_600_000 + 23 * 60 * 1000), "1h 23m");
590 }
591
592 #[test]
593 fn format_duration_renders_minutes_only_under_an_hour() {
594 assert_eq!(rhai_format_duration(12 * 60 * 1000), "12m");
595 }
596
597 #[test]
598 fn format_duration_drops_minutes_on_round_hour() {
599 assert_eq!(rhai_format_duration(2 * 3_600_000), "2h");
600 }
601
602 #[test]
603 fn format_cost_usd_two_decimals() {
604 assert_eq!(rhai_format_cost_usd(1.234), "$1.23");
605 assert_eq!(rhai_format_cost_usd(0.0), "$0.00");
606 }
607
608 #[test]
609 fn format_tokens_under_1k_renders_literal() {
610 assert_eq!(rhai_format_tokens(42), "42");
611 assert_eq!(rhai_format_tokens(0), "0");
612 }
613
614 #[test]
615 fn format_tokens_thousands_get_k_suffix() {
616 assert_eq!(rhai_format_tokens(1200), "1.2k");
617 }
618
619 #[test]
620 fn format_tokens_millions_get_m_suffix() {
621 assert_eq!(rhai_format_tokens(3_500_000), "3.5M");
622 }
623
624 #[test]
625 fn format_tokens_negative_clamps_to_zero() {
626 assert_eq!(rhai_format_tokens(-5), "0");
627 }
628
629 #[test]
630 fn format_countdown_until_bad_rfc3339_renders_marker() {
631 assert_eq!(rhai_format_countdown_until("not a timestamp"), "?");
632 }
633
634 #[test]
635 fn format_countdown_until_past_timestamp_says_now() {
636 assert_eq!(rhai_format_countdown_until("2001-09-09T01:46:40Z"), "now");
638 }
639
640 #[test]
641 fn host_format_cost_usd_invokable_from_script() {
642 let engine = build_engine();
643 let s: String = engine.eval(r#"format_cost_usd(1.99)"#).expect("eval ok");
644 assert_eq!(s, "$1.99");
645 }
646
647 #[test]
648 fn host_format_tokens_invokable_from_script() {
649 let engine = build_engine();
650 let s: String = engine.eval(r#"format_tokens(1500)"#).expect("eval ok");
651 assert_eq!(s, "1.5k");
652 }
653
654 #[test]
655 fn host_log_invokable_from_script() {
656 let engine = build_engine();
660 engine
661 .eval::<()>(r#"log("hello from rhai");"#)
662 .expect("eval ok");
663 }
664
665 #[test]
666 fn host_format_duration_invokable_from_script() {
667 let engine = build_engine();
668 let s: String = engine.eval(r#"format_duration(45000)"#).expect("eval ok");
669 assert_eq!(s, "45s");
670 }
671
672 #[test]
673 fn host_format_countdown_until_invokable_from_script() {
674 let engine = build_engine();
675 let s: String = engine
676 .eval(r#"format_countdown_until("2001-09-09T01:46:40Z")"#)
677 .expect("eval ok");
678 assert_eq!(s, "now");
679 }
680
681 #[test]
682 fn host_format_cost_usd_accepts_integer_literal() {
683 let engine = build_engine();
686 let s: String = engine.eval(r#"format_cost_usd(2)"#).expect("eval ok");
687 assert_eq!(s, "$2.00");
688 }
689
690 #[test]
691 fn format_tokens_boundary_at_exactly_1000() {
692 assert_eq!(rhai_format_tokens(1_000), "1.0k");
695 }
696
697 #[test]
698 fn format_tokens_boundary_at_exactly_1_000_000() {
699 assert_eq!(rhai_format_tokens(1_000_000), "1.0M");
700 }
701
702 #[test]
703 fn standard_package_string_helpers_work() {
704 let engine = build_engine();
708 let len: i64 = engine.eval(r#""hello".len()"#).expect("eval ok");
709 assert_eq!(len, 5);
710 }
711
712 #[test]
713 fn standard_package_array_helpers_work() {
714 let engine = build_engine();
715 let n: i64 = engine
716 .eval(r#"let xs = [1, 2, 3]; xs.len()"#)
717 .expect("eval ok");
718 assert_eq!(n, 3);
719 }
720
721 #[test]
722 fn format_tokens_near_million_boundary_rolls_to_m() {
723 assert_eq!(rhai_format_tokens(999_950), "1.0M");
727 assert_eq!(rhai_format_tokens(999_999), "1.0M");
728 }
729
730 #[test]
731 fn format_tokens_just_below_rollover_boundary_stays_k() {
732 assert_eq!(rhai_format_tokens(999_499), "999.5k");
735 }
736
737 #[test]
738 fn format_countdown_until_future_timestamp_renders_duration() {
739 let target = Timestamp::now() + SignedDuration::from_hours(2);
744 let rendered = rhai_format_countdown_until(&target.to_string());
745 assert_ne!(rendered, "?", "expected successful parse + format");
746 assert_ne!(rendered, "now", "expected future-duration output");
747 assert!(!rendered.is_empty());
748 }
749}