Skip to main content

linesmith_plugin/
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, 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
32/// Max script operations per plugin invocation.
33pub const MAX_OPERATIONS: u64 = 50_000;
34/// Max call depth for user-defined functions.
35pub const MAX_CALL_LEVELS: usize = 16;
36/// Max expression nesting (functions, other).
37pub const MAX_EXPR_DEPTH: usize = 32;
38/// Max length of any rhai string.
39pub const MAX_STRING_SIZE: usize = 1024;
40/// Max length of any rhai array.
41pub const MAX_ARRAY_SIZE: usize = 256;
42/// Max entry count of any rhai map.
43pub const MAX_MAP_SIZE: usize = 256;
44/// Default per-render wallclock budget per `plugin-api.md` §Resource ceilings.
45pub const DEFAULT_RENDER_DEADLINE_MS: u64 = 50;
46
47/// Host-side marker passed to `EvalAltResult::ErrorTerminated` when the
48/// `on_progress` callback aborts the script for exceeding its
49/// wallclock deadline. The segment wrapper inspects the token type
50/// to produce a clearer error than rhai's generic "Script terminated".
51///
52/// Must be a host-only type (not a string) so a plugin can't forge a
53/// deadline classification by `throw`-ing a coincidentally-equal
54/// string payload — rhai's `Dynamic` keeps the underlying Rust type
55/// intact, and the type itself is module-private to host code.
56#[derive(Clone)]
57pub(crate) struct DeadlineAbortMarker;
58/// `on_progress` is called per rhai operation; checking the deadline
59/// every op would burn cycles on `Instant::now()`. Stride amortises:
60/// at 256 ops between checks, the worst-case overrun is roughly the
61/// per-op cost × 256 — sub-ms for typical script ops, larger if a
62/// plugin sits in a host-fn-heavy loop. The 50 ms budget should be
63/// read as "50 ms + (stride − 1) × per-op cost".
64const DEADLINE_CHECK_STRIDE: u64 = 256;
65
66// Compile-time invariants for the stride: stride=0 panics with
67// modulo-by-zero on the first op; stride >= MAX_OPERATIONS silently
68// disables deadline enforcement because the op-limit fires first.
69const _: () = assert!(DEADLINE_CHECK_STRIDE > 0);
70const _: () = assert!(DEADLINE_CHECK_STRIDE < MAX_OPERATIONS);
71
72thread_local! {
73    /// Per-render wallclock deadline. The plugin-segment wrapper sets
74    /// this just before invoking the script and clears it after, so
75    /// the engine's `on_progress` callback can abort runaway scripts.
76    static RENDER_DEADLINE: Cell<Option<Instant>> = const { Cell::new(None) };
77
78    /// Identifier of the plugin currently being rendered. Lets the
79    /// host `log` function attribute output to a specific plugin and
80    /// rate-limit per id without changing the rhai function surface.
81    static CURRENT_PLUGIN_ID: RefCell<Option<String>> = const { RefCell::new(None) };
82
83    /// Total `log()` invocations emitted to stderr per plugin id for
84    /// the lifetime of this thread (== process for a one-shot CLI).
85    /// Per-plugin rate limit per `plugin-api.md` §Host-registered APIs.
86    /// **Per-thread, not per-process:** if rendering ever goes
87    /// multi-threaded (parallel segment render), each thread gets its
88    /// own quota. Switch to `Mutex<HashMap>` then.
89    static LOG_EMITTED: RefCell<HashMap<String, u32>> = RefCell::new(HashMap::new());
90}
91
92/// Maximum `log()` lines per plugin per process. Higher counts get
93/// silently dropped to keep a chatty plugin from flooding stderr.
94pub const LOG_LINES_PER_PLUGIN: u32 = 1;
95
96/// Process-global emitter for plugin `log()` output. Set once by the
97/// consumer so plugin diagnostics route through the host's logging
98/// level (e.g. `LINESMITH_LOG`) without dragging linesmith-core into
99/// this crate's dep graph. The consumer is expected to install at
100/// the same chokepoint where it builds plugin engines, so the
101/// emitter is in place before the first render.
102///
103/// Stderr fallback (when unset) is for unit tests and direct
104/// embedders that never go through a host. Production consumers
105/// install an emitter before any plugin renders, so the fallback
106/// never fires in production.
107type WarnEmitter = Box<dyn Fn(&str) + Send + Sync>;
108static WARN_EMITTER: OnceLock<WarnEmitter> = OnceLock::new();
109
110/// Install the host's warn-emitter. The `OnceLock` keeps the first
111/// installation authoritative so consumers like
112/// `linesmith-core::runtime::plugins::load_plugins` can call this
113/// from a `Once::call_once` without worrying about racing tests.
114///
115/// `debug_assert` catches accidental double-install with conflicting
116/// emitters (a buggy embedder wiring two bridges) so dev/test surfaces
117/// the silent-mis-route loudly while production keeps the safe ignore.
118pub 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
126/// Emit a warn-level diagnostic through the installed host emitter,
127/// or fall back to stderr when none is installed.
128fn 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
136/// Install a per-render deadline visible to the engine's `on_progress`
137/// callback. Pass `None` to clear after the render completes.
138pub fn set_render_deadline(deadline: Option<Instant>) {
139    RENDER_DEADLINE.with(|d| d.set(deadline));
140}
141
142/// Tag the active plugin so the host `log()` function can attribute
143/// output for rate-limiting. Pass `None` to clear after the render.
144pub 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/// Drop every emission count for the current thread. Wholesale clear
151/// is the contract — callers depending on per-id reset will need a
152/// new helper. Test-only; the production rate-limit is process-
153/// lifetime and intentionally does not expose a reset path to plugins.
154#[cfg(test)]
155pub(crate) fn reset_log_counts() {
156    LOG_EMITTED.with(|cell| cell.borrow_mut().clear());
157}
158
159/// Snapshot of the current thread's `RENDER_DEADLINE`. Used by the
160/// segment wrapper's `debug_assert!` leak-check and by tests; the
161/// production render path doesn't need to read the deadline back.
162pub fn render_deadline_snapshot() -> Option<Instant> {
163    RENDER_DEADLINE.with(Cell::get)
164}
165
166/// True when `err` was produced by the per-render wallclock deadline
167/// aborting the script (the engine's `on_progress` callback). Lets
168/// the consumer-side segment wrapper distinguish a host-imposed
169/// timeout from a script-issued `throw`/runtime error without
170/// reaching for the host-only marker type directly.
171///
172/// A plugin cannot forge this classification: the marker is a
173/// host-only Rust type that's never registered with the rhai engine,
174/// so a `throw "..."` payload — even one carrying an identical
175/// string message — won't satisfy the inner type-id check.
176#[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
185/// Snapshot of the current thread's `CURRENT_PLUGIN_ID`. Same niche
186/// as [`render_deadline_snapshot`].
187pub fn current_plugin_id_snapshot() -> Option<String> {
188    CURRENT_PLUGIN_ID.with(|c| c.borrow().clone())
189}
190
191/// Build the shared rhai engine used by every plugin segment. Returns
192/// an `Arc` so the consumer's layout engine can clone cheaply into
193/// each segment adapter that wraps a [`crate::CompiledPlugin`]. The
194/// engine is immutable after this call.
195#[must_use]
196pub fn build_engine() -> Arc<Engine> {
197    let mut engine = Engine::new_raw();
198    // `new_raw()` registers nothing; StandardPackage adds the common
199    // script helpers (`str.len()`, `arr.push(x)`, iterators, …).
200    engine.register_global_module(StandardPackage::new().as_shared_module());
201    // No-op `print`/`debug` overrides; default routing leaks to host
202    // stdout/stderr. The no-op-routing test pins this contract.
203    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    // `import` loads other rhai files — plugins get one script file;
237    // loading siblings would bypass the registry and discovery model.
238    engine.disable_symbol("import");
239    // `eval` compiles an arbitrary string at runtime — lets a plugin
240    // author evade the operation-count budget of the original AST.
241    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    // Rhai's Dynamic dispatch does not promote i64 → f64 for overload
248    // resolution, so register both arities. Without the i64 variant a
249    // plugin author writing `format_cost_usd(1)` instead of
250    // `format_cost_usd(1.0)` hits "function not found" at render time.
251    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
257// Compile-time guarantee that `Arc<Engine>` stays thread-safe. Relies
258// on the `sync` feature flag in `Cargo.toml`; if that flag gets
259// dropped, this line breaks with a clear trait-bound error at the
260// engine constructor instead of surfacing later in the segment
261// wrapper that depends on the trait bound.
262const _: fn() = || {
263    fn assert_send_sync<T: Send + Sync>() {}
264    assert_send_sync::<Arc<Engine>>();
265};
266
267/// Host-registered `log(msg)` for plugin scripts. Emits one warn-
268/// level line per plugin per process through the installed host
269/// emitter; subsequent calls from the same plugin are silently
270/// dropped to keep a chatty plugin from flooding stderr. The active
271/// plugin id comes from `CURRENT_PLUGIN_ID`, set by the consumer's
272/// segment wrapper via [`set_current_plugin_id`] for the duration
273/// of a render; calls outside a render (e.g. tests that `eval`
274/// directly) attribute to a synthetic `<unscoped>` bucket.
275///
276/// `log()` is a diagnostic channel, not a user-feedback channel.
277/// It routes through the host's logging level (e.g. `LINESMITH_LOG`)
278/// when a host emitter is installed; a user who turns that off will
279/// not see plugin log lines. Plugins that want to communicate with
280/// users should emit via segment output (the return of `fn render(ctx)`),
281/// not `log()`.
282///
283/// Bumping the counter *before* emitting is deliberate: a chatty
284/// plugin should pay at most a single `to_owned` per process for its
285/// id, not one per dropped call.
286fn rhai_log(msg: &str) {
287    /// Sentinel id for `log()` calls outside a render scope.
288    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
311/// Format a duration in milliseconds as `"1h 23m"` / `"45m"` / `"12s"`.
312/// Negative inputs render as `"0s"`.
313fn 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
334/// Format a dollar amount as `"$1.23"` (two decimal places).
335fn rhai_format_cost_usd(dollars: f64) -> String {
336    format!("${dollars:.2}")
337}
338
339/// Format a token count as `"1.2k"` / `"3.5M"` / `"999"`. rhai's
340/// integer type is i64; we clamp negatives to 0 and format unsigned
341/// magnitudes.
342///
343/// The `M` threshold is set slightly below 1_000_000 (at 999_500) so
344/// that one-decimal rounding of the `k` branch can't produce the
345/// nonsensical `"1000.0k"` for values like 999_950.
346fn 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
359/// Format an RFC 3339 timestamp string as a coarse countdown relative
360/// to now (`"2h 13m"` / `"45m"` / `"6d"` / `"now"`). Parse failures
361/// surface as the literal `"?"` so the statusline degrades visibly.
362/// Inlined here rather than sharing with the rate-limit segments
363/// (which use a different format per spec) — this is a stable
364/// plugin-facing API and moving cadence shouldn't track segment refactors.
365fn 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        // Rhai wraps this as a `NumberOfOperations` variant; the
405        // message contains the limit literal.
406        assert!(
407            format!("{err}").contains("operations"),
408            "expected operation-limit error, got: {err}"
409        );
410    }
411
412    /// RAII guard for the per-render thread-locals so a test panic
413    /// can't leak state into siblings on the same thread. The
414    /// consumer-side segment wrapper (linesmith-core's
415    /// `RhaiSegment::render`) uses the same RAII pattern in production.
416    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        // Belt-and-suspenders: ensure cleared deadlines don't abort
462        // normal evaluation. A leak from a prior test on this thread
463        // would surface here as an unexpected error.
464        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        // Pin the per-plugin rate-limit: three `log()` calls under
473        // the same id collapse to exactly LOG_LINES_PER_PLUGIN
474        // emissions. Reset first so cross-test ordering on this
475        // thread can't preload the counter.
476        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        // Pin the sentinel id used when CURRENT_PLUGIN_ID is unset so
515        // a future rename (`<none>`, `<anon>`) doesn't silently
516        // scatter eval-callsite logs across new buckets.
517        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        // `import "foo"` would normally parse; disabling the symbol
528        // turns it into a parse error.
529        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        // We register nothing filesystem-related. A plugin that calls
549        // `fs::read("/etc/passwd")` hits "function not found."
550        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        // `print` / `debug` are rhai built-ins that route through the
562        // engine's on_print / on_debug callbacks. Our builder points
563        // both at no-op closures so plugin scripts can call them
564        // without crashing AND without reaching the host's stdout or
565        // stderr. `Engine::new()` defaults would leak to stdout; this
566        // test pins the no-op routing as the required posture.
567        let engine = build_engine();
568        // Eval returns Ok(()) — the call succeeded but produced
569        // nothing the host can observe.
570        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        // 2001-09-09 is safely in the past forever.
637        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        // Smoke test: `log` is registered and callable. Actual stderr
657        // capture lives in the segment wrapper that owns output
658        // routing.
659        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        // Regression guard for the i64 overload: plugins writing
684        // `format_cost_usd(1)` must not hit "function not found."
685        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        // `>= 1_000` branch triggers at 1000 exactly; guard against a
693        // future refactor to `>` that would silently regress.
694        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        // The spec's sample plugin (plugin-api.md §Plugin script
705        // contract) uses `model_name.len()`. If StandardPackage isn't
706        // loaded, that call fails with "function not found."
707        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        // Regression guard for rounding drift: 999_950 / 1_000 rounds
724        // to 1000.0, which would display as "1000.0k" without the
725        // boundary guard. Must roll to "1.0M" instead.
726        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        // Paired with the above: 999_499 is comfortably under the
733        // M threshold; must format as "999.5k".
734        assert_eq!(rhai_format_tokens(999_499), "999.5k");
735    }
736
737    #[test]
738    fn format_countdown_until_future_timestamp_renders_duration() {
739        // Exercises the RFC 3339 → DateTime → countdown path end-to-
740        // end (not just the bad-input fallback). Shape assertion only
741        // so the test isn't time-sensitive: result is neither "?"
742        // (parse failure) nor "now" (for a timestamp > 1 minute out).
743        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}