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