Skip to main content

linesmith_core/plugins/
engine.rs

1//! Rhai engine construction for the plugin runtime.
2//!
3//! [`build_engine`] returns a single shared `Arc<rhai::Engine>`
4//! configured per `docs/specs/plugin-api.md` §Resource ceilings and
5//! §Host-registered APIs. Every plugin segment invokes `call_fn` on
6//! this shared engine with its compiled AST; the engine is not
7//! recreated per plugin or per render.
8//!
9//! Sandboxing posture (plugin-api.md §Requirements → Functional):
10//! - No filesystem or network functions registered → unknown-symbol
11//!   errors at script parse/runtime for any `fs::*` / `http::*` call.
12//! - `import` and `eval` symbols disabled → scripts cannot load other
13//!   files or compile strings at runtime.
14//! - Resource limits cap operations, call depth, expression nesting,
15//!   and string / array / map size.
16//!
17//! Wallclock-timeout enforcement (via `on_progress`) is the segment
18//! wrapper's job — it owns the per-render `Instant` — not the engine's.
19//! This module enforces operation-count limits only.
20
21use 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
30/// Max script operations per plugin invocation.
31pub const MAX_OPERATIONS: u64 = 50_000;
32/// Max call depth for user-defined functions.
33pub const MAX_CALL_LEVELS: usize = 16;
34/// Max expression nesting (functions, other).
35pub const MAX_EXPR_DEPTH: usize = 32;
36/// Max length of any rhai string.
37pub const MAX_STRING_SIZE: usize = 1024;
38/// Max length of any rhai array.
39pub const MAX_ARRAY_SIZE: usize = 256;
40/// Max entry count of any rhai map.
41pub const MAX_MAP_SIZE: usize = 256;
42/// Default per-render wallclock budget per `plugin-api.md` §Resource ceilings.
43pub const DEFAULT_RENDER_DEADLINE_MS: u64 = 50;
44
45/// Host-side marker passed to `EvalAltResult::ErrorTerminated` when the
46/// `on_progress` callback aborts the script for exceeding its
47/// wallclock deadline. The segment wrapper inspects the token type
48/// to produce a clearer error than rhai's generic "Script terminated".
49///
50/// Must be a host-only type (not a string) so a plugin can't forge a
51/// deadline classification by `throw`-ing a coincidentally-equal
52/// string payload — rhai's `Dynamic` keeps the underlying Rust type
53/// intact, and the type itself is module-private to host code.
54#[derive(Clone)]
55pub(crate) struct DeadlineAbortMarker;
56/// `on_progress` is called per rhai operation; checking the deadline
57/// every op would burn cycles on `Instant::now()`. Stride amortises:
58/// at 256 ops between checks, the worst-case overrun is roughly the
59/// per-op cost × 256 — sub-ms for typical script ops, larger if a
60/// plugin sits in a host-fn-heavy loop. The 50 ms budget should be
61/// read as "50 ms + (stride − 1) × per-op cost".
62const DEADLINE_CHECK_STRIDE: u64 = 256;
63
64// Compile-time invariants for the stride: stride=0 panics with
65// modulo-by-zero on the first op; stride >= MAX_OPERATIONS silently
66// disables deadline enforcement because the op-limit fires first.
67const _: () = assert!(DEADLINE_CHECK_STRIDE > 0);
68const _: () = assert!(DEADLINE_CHECK_STRIDE < MAX_OPERATIONS);
69
70thread_local! {
71    /// Per-render wallclock deadline. The plugin-segment wrapper sets
72    /// this just before invoking the script and clears it after, so
73    /// the engine's `on_progress` callback can abort runaway scripts.
74    static RENDER_DEADLINE: Cell<Option<Instant>> = const { Cell::new(None) };
75
76    /// Identifier of the plugin currently being rendered. Lets the
77    /// host `log` function attribute output to a specific plugin and
78    /// rate-limit per id without changing the rhai function surface.
79    static CURRENT_PLUGIN_ID: RefCell<Option<String>> = const { RefCell::new(None) };
80
81    /// Total `log()` invocations emitted to stderr per plugin id for
82    /// the lifetime of this thread (== process for a one-shot CLI).
83    /// Per-plugin rate limit per `plugin-api.md` §Host-registered APIs.
84    /// **Per-thread, not per-process:** if rendering ever goes
85    /// multi-threaded (parallel segment render), each thread gets its
86    /// own quota. Switch to `Mutex<HashMap>` then.
87    static LOG_EMITTED: RefCell<HashMap<String, u32>> = RefCell::new(HashMap::new());
88}
89
90/// Maximum `log()` lines per plugin per process. Higher counts get
91/// silently dropped to keep a chatty plugin from flooding stderr.
92pub const LOG_LINES_PER_PLUGIN: u32 = 1;
93
94/// Install a per-render deadline visible to the engine's `on_progress`
95/// callback. Pass `None` to clear after the render completes.
96pub fn set_render_deadline(deadline: Option<Instant>) {
97    RENDER_DEADLINE.with(|d| d.set(deadline));
98}
99
100/// Tag the active plugin so the host `log()` function can attribute
101/// output for rate-limiting. Pass `None` to clear after the render.
102pub 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/// Drop every emission count for the current thread. Wholesale clear
109/// is the contract — callers depending on per-id reset will need a
110/// new helper. Test-only; the production rate-limit is process-
111/// lifetime and intentionally does not expose a reset path to plugins.
112#[cfg(test)]
113pub(crate) fn reset_log_counts() {
114    LOG_EMITTED.with(|cell| cell.borrow_mut().clear());
115}
116
117/// Snapshot of the current thread's `RENDER_DEADLINE`. Used by the
118/// segment wrapper's `debug_assert!` leak-check and by tests; the
119/// production render path doesn't need to read the deadline back.
120pub(crate) fn render_deadline_snapshot() -> Option<Instant> {
121    RENDER_DEADLINE.with(Cell::get)
122}
123
124/// Snapshot of the current thread's `CURRENT_PLUGIN_ID`. Same niche
125/// as [`render_deadline_snapshot`].
126pub(crate) fn current_plugin_id_snapshot() -> Option<String> {
127    CURRENT_PLUGIN_ID.with(|c| c.borrow().clone())
128}
129
130/// Build the shared rhai engine used by every plugin segment. Returns
131/// an `Arc` so the layout engine can clone cheaply into each
132/// `RhaiSegment`. The engine is immutable after this call.
133#[must_use]
134pub fn build_engine() -> Arc<Engine> {
135    let mut engine = Engine::new_raw();
136    // `new_raw()` registers nothing; StandardPackage adds the common
137    // script helpers (`str.len()`, `arr.push(x)`, iterators, …).
138    engine.register_global_module(StandardPackage::new().as_shared_module());
139    // No-op `print`/`debug` overrides; default routing leaks to host
140    // stdout/stderr. The no-op-routing test pins this contract.
141    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    // `import` loads other rhai files — plugins get one script file;
175    // loading siblings would bypass the registry and discovery model.
176    engine.disable_symbol("import");
177    // `eval` compiles an arbitrary string at runtime — lets a plugin
178    // author evade the operation-count budget of the original AST.
179    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    // Rhai's Dynamic dispatch does not promote i64 → f64 for overload
186    // resolution, so register both arities. Without the i64 variant a
187    // plugin author writing `format_cost_usd(1)` instead of
188    // `format_cost_usd(1.0)` hits "function not found" at render time.
189    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
195// Compile-time guarantee that `Arc<Engine>` stays thread-safe. Relies
196// on the `sync` feature flag in `Cargo.toml`; if that flag gets
197// dropped, this line breaks with a clear trait-bound error at the
198// engine constructor instead of surfacing later in the segment
199// wrapper that depends on the trait bound.
200const _: fn() = || {
201    fn assert_send_sync<T: Send + Sync>() {}
202    assert_send_sync::<Arc<Engine>>();
203};
204
205/// Host-registered `log(msg)` for plugin scripts. Emits one warn-
206/// level line per plugin per process through the crate logger;
207/// subsequent calls from the same plugin are silently dropped to
208/// keep a chatty plugin from flooding stderr. The active plugin id
209/// comes from a thread-local set by `RhaiSegment::render`; calls
210/// outside a render (e.g. tests that `eval` directly) attribute to
211/// a synthetic `<unscoped>` bucket.
212///
213/// `log()` is a diagnostic channel, not a user-feedback channel.
214/// It routes through `LINESMITH_LOG` and a user who sets
215/// `LINESMITH_LOG=off` will not see plugin log lines. Plugins that
216/// want to communicate with users should emit via segment output
217/// (the return of `fn render(ctx)`), not `log()`.
218///
219/// Bumping the counter *before* emitting is deliberate: a chatty
220/// plugin should pay at most a single `to_owned` per process for its
221/// id, not one per dropped call.
222fn rhai_log(msg: &str) {
223    /// Sentinel id for `log()` calls outside a render scope.
224    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        // The existing per-plugin counter still gates chattiness; the
244        // logger just adds `LINESMITH_LOG=off` suppression and the
245        // uniform `[warn]` prefix.
246        crate::lsm_warn!("plugin {id}: {msg}");
247    }
248}
249
250/// Format a duration in milliseconds as `"1h 23m"` / `"45m"` / `"12s"`.
251/// Negative inputs render as `"0s"`.
252fn 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
273/// Format a dollar amount as `"$1.23"` (two decimal places).
274fn rhai_format_cost_usd(dollars: f64) -> String {
275    format!("${dollars:.2}")
276}
277
278/// Format a token count as `"1.2k"` / `"3.5M"` / `"999"`. rhai's
279/// integer type is i64; we clamp negatives to 0 and format unsigned
280/// magnitudes.
281///
282/// The `M` threshold is set slightly below 1_000_000 (at 999_500) so
283/// that one-decimal rounding of the `k` branch can't produce the
284/// nonsensical `"1000.0k"` for values like 999_950.
285fn 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
298/// Format an RFC 3339 timestamp string as a coarse countdown relative
299/// to now (`"2h 13m"` / `"45m"` / `"6d"` / `"now"`). Parse failures
300/// surface as the literal `"?"` so the statusline degrades visibly.
301/// Inlined here rather than sharing with the rate-limit segments
302/// (which use a different format per spec) — this is a stable
303/// plugin-facing API and moving cadence shouldn't track segment refactors.
304fn 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        // Rhai wraps this as a `NumberOfOperations` variant; the
347        // message contains the limit literal.
348        assert!(
349            format!("{err}").contains("operations"),
350            "expected operation-limit error, got: {err}"
351        );
352    }
353
354    /// RAII guard for the per-render thread-locals so a test panic
355    /// can't leak state into siblings on the same thread. The
356    /// production [`super::super::segment::RhaiSegment::render`] uses
357    /// the same pattern.
358    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        // Belt-and-suspenders: ensure cleared deadlines don't abort
404        // normal evaluation. A leak from a prior test on this thread
405        // would surface here as an unexpected error.
406        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        // Pin the per-plugin rate-limit: three `log()` calls under
415        // the same id collapse to exactly LOG_LINES_PER_PLUGIN
416        // emissions. Reset first so cross-test ordering on this
417        // thread can't preload the counter.
418        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        // Pin the sentinel id used when CURRENT_PLUGIN_ID is unset so
457        // a future rename (`<none>`, `<anon>`) doesn't silently
458        // scatter eval-callsite logs across new buckets.
459        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        // `import "foo"` would normally parse; disabling the symbol
470        // turns it into a parse error.
471        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        // We register nothing filesystem-related. A plugin that calls
491        // `fs::read("/etc/passwd")` hits "function not found."
492        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        // `print` / `debug` are rhai built-ins that route through the
504        // engine's on_print / on_debug callbacks. Our builder points
505        // both at no-op closures so plugin scripts can call them
506        // without crashing AND without reaching the host's stdout or
507        // stderr. `Engine::new()` defaults would leak to stdout; this
508        // test pins the no-op routing as the required posture.
509        let engine = build_engine();
510        // Eval returns Ok(()) — the call succeeded but produced
511        // nothing the host can observe.
512        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        // 2001-09-09 is safely in the past forever.
579        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        // Smoke test: `log` is registered and callable. Actual stderr
599        // capture lives in the segment wrapper that owns output
600        // routing.
601        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        // Regression guard for the i64 overload: plugins writing
626        // `format_cost_usd(1)` must not hit "function not found."
627        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        // `>= 1_000` branch triggers at 1000 exactly; guard against a
635        // future refactor to `>` that would silently regress.
636        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        // The spec's sample plugin (plugin-api.md §Plugin script
647        // contract) uses `model_name.len()`. If StandardPackage isn't
648        // loaded, that call fails with "function not found."
649        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        // Regression guard for rounding drift: 999_950 / 1_000 rounds
666        // to 1000.0, which would display as "1000.0k" without the
667        // boundary guard. Must roll to "1.0M" instead.
668        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        // Paired with the above: 999_499 is comfortably under the
675        // M threshold; must format as "999.5k".
676        assert_eq!(rhai_format_tokens(999_499), "999.5k");
677    }
678
679    #[test]
680    fn format_countdown_until_future_timestamp_renders_duration() {
681        // Exercises the RFC 3339 → DateTime → countdown path end-to-
682        // end (not just the bad-input fallback). Shape assertion only
683        // so the test isn't time-sensitive: result is neither "?"
684        // (parse failure) nor "now" (for a timestamp > 1 minute out).
685        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}