linesmith_core/plugins/
engine.rs1use std::cell::{Cell, RefCell};
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::Instant;
25
26use chrono::Utc;
27use rhai::packages::{Package, StandardPackage};
28use rhai::{Dynamic, Engine};
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
94pub fn set_render_deadline(deadline: Option<Instant>) {
97 RENDER_DEADLINE.with(|d| d.set(deadline));
98}
99
100pub fn set_current_plugin_id(id: Option<&str>) {
103 CURRENT_PLUGIN_ID.with(|cell| {
104 *cell.borrow_mut() = id.map(str::to_owned);
105 });
106}
107
108#[cfg(test)]
113pub(crate) fn reset_log_counts() {
114 LOG_EMITTED.with(|cell| cell.borrow_mut().clear());
115}
116
117pub(crate) fn render_deadline_snapshot() -> Option<Instant> {
121 RENDER_DEADLINE.with(Cell::get)
122}
123
124pub(crate) fn current_plugin_id_snapshot() -> Option<String> {
127 CURRENT_PLUGIN_ID.with(|c| c.borrow().clone())
128}
129
130#[must_use]
134pub fn build_engine() -> Arc<Engine> {
135 let mut engine = Engine::new_raw();
136 engine.register_global_module(StandardPackage::new().as_shared_module());
139 engine.on_print(|_| {});
142 engine.on_debug(|_, _, _| {});
143 install_deadline_callback(&mut engine);
144 configure_limits(&mut engine);
145 lock_down_symbols(&mut engine);
146 register_host_fns(&mut engine);
147 Arc::new(engine)
148}
149
150fn install_deadline_callback(engine: &mut Engine) {
151 engine.on_progress(|ops| {
152 if ops % DEADLINE_CHECK_STRIDE != 0 {
153 return None;
154 }
155 let deadline = RENDER_DEADLINE.with(Cell::get)?;
156 if Instant::now() >= deadline {
157 Some(Dynamic::from(DeadlineAbortMarker))
158 } else {
159 None
160 }
161 });
162}
163
164fn configure_limits(engine: &mut Engine) {
165 engine.set_max_operations(MAX_OPERATIONS);
166 engine.set_max_call_levels(MAX_CALL_LEVELS);
167 engine.set_max_expr_depths(MAX_EXPR_DEPTH, MAX_EXPR_DEPTH);
168 engine.set_max_string_size(MAX_STRING_SIZE);
169 engine.set_max_array_size(MAX_ARRAY_SIZE);
170 engine.set_max_map_size(MAX_MAP_SIZE);
171}
172
173fn lock_down_symbols(engine: &mut Engine) {
174 engine.disable_symbol("import");
177 engine.disable_symbol("eval");
180}
181
182fn register_host_fns(engine: &mut Engine) {
183 engine.register_fn("log", rhai_log);
184 engine.register_fn("format_duration", rhai_format_duration);
185 engine.register_fn("format_cost_usd", rhai_format_cost_usd);
190 engine.register_fn("format_cost_usd", |n: i64| rhai_format_cost_usd(n as f64));
191 engine.register_fn("format_tokens", rhai_format_tokens);
192 engine.register_fn("format_countdown_until", rhai_format_countdown_until);
193}
194
195const _: fn() = || {
201 fn assert_send_sync<T: Send + Sync>() {}
202 assert_send_sync::<Arc<Engine>>();
203};
204
205fn rhai_log(msg: &str) {
223 const UNSCOPED: &str = "<unscoped>";
225
226 let allowed = LOG_EMITTED.with(|cell| {
227 let mut counts = cell.borrow_mut();
228 let id_str = CURRENT_PLUGIN_ID.with(|c| c.borrow().clone());
229 let key: &str = id_str.as_deref().unwrap_or(UNSCOPED);
230 match counts.get_mut(key) {
231 Some(n) if *n >= LOG_LINES_PER_PLUGIN => None,
232 Some(n) => {
233 *n += 1;
234 Some(key.to_owned())
235 }
236 None => {
237 counts.insert(key.to_owned(), 1);
238 Some(key.to_owned())
239 }
240 }
241 });
242 if let Some(id) = allowed {
243 crate::lsm_warn!("plugin {id}: {msg}");
247 }
248}
249
250fn rhai_format_duration(ms: i64) -> String {
253 if ms <= 0 {
254 return "0s".to_string();
255 }
256 let total_seconds = ms / 1000;
257 let hours = total_seconds / 3600;
258 let minutes = (total_seconds % 3600) / 60;
259 let seconds = total_seconds % 60;
260 if hours > 0 {
261 if minutes > 0 {
262 format!("{hours}h {minutes}m")
263 } else {
264 format!("{hours}h")
265 }
266 } else if minutes > 0 {
267 format!("{minutes}m")
268 } else {
269 format!("{seconds}s")
270 }
271}
272
273fn rhai_format_cost_usd(dollars: f64) -> String {
275 format!("${dollars:.2}")
276}
277
278fn rhai_format_tokens(count: i64) -> String {
286 let n = count.max(0);
287 if n >= 999_500 {
288 let m = n as f64 / 1_000_000.0;
289 format!("{m:.1}M")
290 } else if n >= 1_000 {
291 let k = n as f64 / 1_000.0;
292 format!("{k:.1}k")
293 } else {
294 format!("{n}")
295 }
296}
297
298fn rhai_format_countdown_until(rfc3339_ts: &str) -> String {
305 let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339_ts) else {
306 return "?".to_string();
307 };
308 let target = dt.with_timezone(&Utc);
309 let now = Utc::now();
310 let delta = target - now;
311 let total_minutes = delta.num_minutes();
312 if total_minutes <= 0 {
313 return "now".to_string();
314 }
315 let days = delta.num_days();
316 if days >= 2 {
317 return format!("{days}d");
318 }
319 let hours = delta.num_hours();
320 if hours >= 1 {
321 let minutes = (total_minutes - hours * 60).max(0);
322 return if minutes == 0 {
323 format!("{hours}h")
324 } else {
325 format!("{hours}h {minutes}m")
326 };
327 }
328 format!("{total_minutes}m")
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn engine_evaluates_basic_arithmetic() {
337 let engine = build_engine();
338 let n: i64 = engine.eval("1 + 2").expect("eval ok");
339 assert_eq!(n, 3);
340 }
341
342 #[test]
343 fn infinite_loop_trips_operation_limit() {
344 let engine = build_engine();
345 let err = engine.eval::<()>("loop {}").unwrap_err();
346 assert!(
349 format!("{err}").contains("operations"),
350 "expected operation-limit error, got: {err}"
351 );
352 }
353
354 struct ThreadLocalGuard;
359
360 impl ThreadLocalGuard {
361 fn install_deadline(at: Instant) -> Self {
362 set_render_deadline(Some(at));
363 Self
364 }
365
366 fn install_plugin_id(id: &str) -> Self {
367 set_current_plugin_id(Some(id));
368 Self
369 }
370 }
371
372 impl Drop for ThreadLocalGuard {
373 fn drop(&mut self) {
374 set_render_deadline(None);
375 set_current_plugin_id(None);
376 }
377 }
378
379 #[test]
380 fn past_deadline_aborts_long_running_script() {
381 let engine = build_engine();
382 let _guard = ThreadLocalGuard::install_deadline(Instant::now());
383 let err = engine.eval::<()>("loop {}").unwrap_err();
384 let msg = format!("{err}");
385 assert!(
386 msg.to_lowercase().contains("terminated"),
387 "expected `Script terminated` from on_progress abort, got: {msg}"
388 );
389 }
390
391 #[test]
392 fn far_future_deadline_does_not_abort_quick_script() {
393 let engine = build_engine();
394 let _guard = ThreadLocalGuard::install_deadline(
395 Instant::now() + std::time::Duration::from_secs(3600),
396 );
397 let n: i64 = engine.eval("1 + 2 + 3").expect("quick eval ok");
398 assert_eq!(n, 6);
399 }
400
401 #[test]
402 fn no_deadline_set_does_not_abort_quick_script() {
403 set_render_deadline(None);
407 let engine = build_engine();
408 let n: i64 = engine.eval("4 * 5").expect("eval ok");
409 assert_eq!(n, 20);
410 }
411
412 #[test]
413 fn log_emits_first_call_then_silences() {
414 reset_log_counts();
419 let engine = build_engine();
420 let _guard = ThreadLocalGuard::install_plugin_id("log_emits_first_call_then_silences");
421 engine
422 .eval::<()>(r#"log("first"); log("second"); log("third");"#)
423 .expect("eval ok");
424 let count = LOG_EMITTED.with(|cell| {
425 cell.borrow()
426 .get("log_emits_first_call_then_silences")
427 .copied()
428 .unwrap_or(0)
429 });
430 assert_eq!(
431 count, LOG_LINES_PER_PLUGIN,
432 "expected exactly {LOG_LINES_PER_PLUGIN} emission(s), counted {count}"
433 );
434 }
435
436 #[test]
437 fn log_under_distinct_plugin_ids_each_gets_its_own_quota() {
438 reset_log_counts();
439 let engine = build_engine();
440 for id in ["log_quota_a", "log_quota_b"] {
441 let _guard = ThreadLocalGuard::install_plugin_id(id);
442 engine.eval::<()>(r#"log("hi");"#).expect("eval ok");
443 }
444 let counts = LOG_EMITTED.with(|cell| {
445 let map = cell.borrow();
446 (
447 map.get("log_quota_a").copied().unwrap_or(0),
448 map.get("log_quota_b").copied().unwrap_or(0),
449 )
450 });
451 assert_eq!(counts, (LOG_LINES_PER_PLUGIN, LOG_LINES_PER_PLUGIN));
452 }
453
454 #[test]
455 fn log_outside_render_attributes_to_unscoped_bucket() {
456 reset_log_counts();
460 let engine = build_engine();
461 engine.eval::<()>(r#"log("from-eval");"#).expect("eval ok");
462 let count = LOG_EMITTED.with(|cell| cell.borrow().get("<unscoped>").copied());
463 assert_eq!(count, Some(LOG_LINES_PER_PLUGIN));
464 }
465
466 #[test]
467 fn import_is_disabled() {
468 let engine = build_engine();
469 let err = engine.eval::<()>(r#"import "foo" as bar;"#).unwrap_err();
472 assert!(
473 format!("{err}").to_lowercase().contains("import"),
474 "expected import-related error, got: {err}"
475 );
476 }
477
478 #[test]
479 fn eval_symbol_is_disabled() {
480 let engine = build_engine();
481 let err = engine.eval::<()>(r#"eval("1 + 1")"#).unwrap_err();
482 assert!(
483 format!("{err}").to_lowercase().contains("eval"),
484 "expected eval-related error, got: {err}"
485 );
486 }
487
488 #[test]
489 fn unregistered_fs_call_fails_at_runtime() {
490 let engine = build_engine();
493 let err = engine.eval::<()>(r#"fs::read("/etc/passwd")"#).unwrap_err();
494 let msg = format!("{err}").to_lowercase();
495 assert!(
496 msg.contains("fs::read") || msg.contains("not found") || msg.contains("function"),
497 "expected function-not-found error, got: {err}"
498 );
499 }
500
501 #[test]
502 fn print_and_debug_are_silent_no_ops() {
503 let engine = build_engine();
510 engine
513 .eval::<()>(
514 r#"print("this would leak to stdout under Engine::new"); debug("this too");"#,
515 )
516 .expect("print/debug call must succeed as a no-op");
517 }
518
519 #[test]
520 fn format_duration_sub_minute_renders_seconds() {
521 assert_eq!(rhai_format_duration(45_000), "45s");
522 }
523
524 #[test]
525 fn format_duration_negative_clamps_to_zero() {
526 assert_eq!(rhai_format_duration(-1), "0s");
527 }
528
529 #[test]
530 fn format_duration_renders_hours_and_minutes() {
531 assert_eq!(rhai_format_duration(3_600_000 + 23 * 60 * 1000), "1h 23m");
532 }
533
534 #[test]
535 fn format_duration_renders_minutes_only_under_an_hour() {
536 assert_eq!(rhai_format_duration(12 * 60 * 1000), "12m");
537 }
538
539 #[test]
540 fn format_duration_drops_minutes_on_round_hour() {
541 assert_eq!(rhai_format_duration(2 * 3_600_000), "2h");
542 }
543
544 #[test]
545 fn format_cost_usd_two_decimals() {
546 assert_eq!(rhai_format_cost_usd(1.234), "$1.23");
547 assert_eq!(rhai_format_cost_usd(0.0), "$0.00");
548 }
549
550 #[test]
551 fn format_tokens_under_1k_renders_literal() {
552 assert_eq!(rhai_format_tokens(42), "42");
553 assert_eq!(rhai_format_tokens(0), "0");
554 }
555
556 #[test]
557 fn format_tokens_thousands_get_k_suffix() {
558 assert_eq!(rhai_format_tokens(1200), "1.2k");
559 }
560
561 #[test]
562 fn format_tokens_millions_get_m_suffix() {
563 assert_eq!(rhai_format_tokens(3_500_000), "3.5M");
564 }
565
566 #[test]
567 fn format_tokens_negative_clamps_to_zero() {
568 assert_eq!(rhai_format_tokens(-5), "0");
569 }
570
571 #[test]
572 fn format_countdown_until_bad_rfc3339_renders_marker() {
573 assert_eq!(rhai_format_countdown_until("not a timestamp"), "?");
574 }
575
576 #[test]
577 fn format_countdown_until_past_timestamp_says_now() {
578 assert_eq!(rhai_format_countdown_until("2001-09-09T01:46:40Z"), "now");
580 }
581
582 #[test]
583 fn host_format_cost_usd_invokable_from_script() {
584 let engine = build_engine();
585 let s: String = engine.eval(r#"format_cost_usd(1.99)"#).expect("eval ok");
586 assert_eq!(s, "$1.99");
587 }
588
589 #[test]
590 fn host_format_tokens_invokable_from_script() {
591 let engine = build_engine();
592 let s: String = engine.eval(r#"format_tokens(1500)"#).expect("eval ok");
593 assert_eq!(s, "1.5k");
594 }
595
596 #[test]
597 fn host_log_invokable_from_script() {
598 let engine = build_engine();
602 engine
603 .eval::<()>(r#"log("hello from rhai");"#)
604 .expect("eval ok");
605 }
606
607 #[test]
608 fn host_format_duration_invokable_from_script() {
609 let engine = build_engine();
610 let s: String = engine.eval(r#"format_duration(45000)"#).expect("eval ok");
611 assert_eq!(s, "45s");
612 }
613
614 #[test]
615 fn host_format_countdown_until_invokable_from_script() {
616 let engine = build_engine();
617 let s: String = engine
618 .eval(r#"format_countdown_until("2001-09-09T01:46:40Z")"#)
619 .expect("eval ok");
620 assert_eq!(s, "now");
621 }
622
623 #[test]
624 fn host_format_cost_usd_accepts_integer_literal() {
625 let engine = build_engine();
628 let s: String = engine.eval(r#"format_cost_usd(2)"#).expect("eval ok");
629 assert_eq!(s, "$2.00");
630 }
631
632 #[test]
633 fn format_tokens_boundary_at_exactly_1000() {
634 assert_eq!(rhai_format_tokens(1_000), "1.0k");
637 }
638
639 #[test]
640 fn format_tokens_boundary_at_exactly_1_000_000() {
641 assert_eq!(rhai_format_tokens(1_000_000), "1.0M");
642 }
643
644 #[test]
645 fn standard_package_string_helpers_work() {
646 let engine = build_engine();
650 let len: i64 = engine.eval(r#""hello".len()"#).expect("eval ok");
651 assert_eq!(len, 5);
652 }
653
654 #[test]
655 fn standard_package_array_helpers_work() {
656 let engine = build_engine();
657 let n: i64 = engine
658 .eval(r#"let xs = [1, 2, 3]; xs.len()"#)
659 .expect("eval ok");
660 assert_eq!(n, 3);
661 }
662
663 #[test]
664 fn format_tokens_near_million_boundary_rolls_to_m() {
665 assert_eq!(rhai_format_tokens(999_950), "1.0M");
669 assert_eq!(rhai_format_tokens(999_999), "1.0M");
670 }
671
672 #[test]
673 fn format_tokens_just_below_rollover_boundary_stays_k() {
674 assert_eq!(rhai_format_tokens(999_499), "999.5k");
677 }
678
679 #[test]
680 fn format_countdown_until_future_timestamp_renders_duration() {
681 let target = chrono::Utc::now() + chrono::Duration::hours(2);
686 let rendered = rhai_format_countdown_until(&target.to_rfc3339());
687 assert_ne!(rendered, "?", "expected successful parse + format");
688 assert_ne!(rendered, "now", "expected future-duration output");
689 assert!(!rendered.is_empty());
690 }
691}