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
26use chrono::Utc;
27use rhai::packages::{Package, StandardPackage};
28use rhai::{Dynamic, Engine, EvalAltResult};
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/// Process-global emitter for plugin `log()` output. Set once by the
95/// consumer so plugin diagnostics route through the host's logging
96/// level (e.g. `LINESMITH_LOG`) without dragging linesmith-core into
97/// this crate's dep graph. The consumer is expected to install at
98/// the same chokepoint where it builds plugin engines, so the
99/// emitter is in place before the first render.
100///
101/// Stderr fallback (when unset) is for unit tests and direct
102/// embedders that never go through a host. Production consumers
103/// install an emitter before any plugin renders, so the fallback
104/// never fires in production.
105type WarnEmitter = Box<dyn Fn(&str) + Send + Sync>;
106static WARN_EMITTER: OnceLock<WarnEmitter> = OnceLock::new();
107
108/// Install the host's warn-emitter. The `OnceLock` keeps the first
109/// installation authoritative so consumers like
110/// `linesmith-core::runtime::plugins::load_plugins` can call this
111/// from a `Once::call_once` without worrying about racing tests.
112///
113/// `debug_assert` catches accidental double-install with conflicting
114/// emitters (a buggy embedder wiring two bridges) so dev/test surfaces
115/// the silent-mis-route loudly while production keeps the safe ignore.
116pub 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
124/// Emit a warn-level diagnostic through the installed host emitter,
125/// or fall back to stderr when none is installed.
126fn 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
134/// Install a per-render deadline visible to the engine's `on_progress`
135/// callback. Pass `None` to clear after the render completes.
136pub fn set_render_deadline(deadline: Option<Instant>) {
137    RENDER_DEADLINE.with(|d| d.set(deadline));
138}
139
140/// Tag the active plugin so the host `log()` function can attribute
141/// output for rate-limiting. Pass `None` to clear after the render.
142pub 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/// Drop every emission count for the current thread. Wholesale clear
149/// is the contract — callers depending on per-id reset will need a
150/// new helper. Test-only; the production rate-limit is process-
151/// lifetime and intentionally does not expose a reset path to plugins.
152#[cfg(test)]
153pub(crate) fn reset_log_counts() {
154    LOG_EMITTED.with(|cell| cell.borrow_mut().clear());
155}
156
157/// Snapshot of the current thread's `RENDER_DEADLINE`. Used by the
158/// segment wrapper's `debug_assert!` leak-check and by tests; the
159/// production render path doesn't need to read the deadline back.
160pub fn render_deadline_snapshot() -> Option<Instant> {
161    RENDER_DEADLINE.with(Cell::get)
162}
163
164/// True when `err` was produced by the per-render wallclock deadline
165/// aborting the script (the engine's `on_progress` callback). Lets
166/// the consumer-side segment wrapper distinguish a host-imposed
167/// timeout from a script-issued `throw`/runtime error without
168/// reaching for the host-only marker type directly.
169///
170/// A plugin cannot forge this classification: the marker is a
171/// host-only Rust type that's never registered with the rhai engine,
172/// so a `throw "..."` payload — even one carrying an identical
173/// string message — won't satisfy the inner type-id check.
174#[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
183/// Snapshot of the current thread's `CURRENT_PLUGIN_ID`. Same niche
184/// as [`render_deadline_snapshot`].
185pub fn current_plugin_id_snapshot() -> Option<String> {
186    CURRENT_PLUGIN_ID.with(|c| c.borrow().clone())
187}
188
189/// Build the shared rhai engine used by every plugin segment. Returns
190/// an `Arc` so the consumer's layout engine can clone cheaply into
191/// each segment adapter that wraps a [`crate::CompiledPlugin`]. The
192/// engine is immutable after this call.
193#[must_use]
194pub fn build_engine() -> Arc<Engine> {
195    let mut engine = Engine::new_raw();
196    // `new_raw()` registers nothing; StandardPackage adds the common
197    // script helpers (`str.len()`, `arr.push(x)`, iterators, …).
198    engine.register_global_module(StandardPackage::new().as_shared_module());
199    // No-op `print`/`debug` overrides; default routing leaks to host
200    // stdout/stderr. The no-op-routing test pins this contract.
201    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    // `import` loads other rhai files — plugins get one script file;
235    // loading siblings would bypass the registry and discovery model.
236    engine.disable_symbol("import");
237    // `eval` compiles an arbitrary string at runtime — lets a plugin
238    // author evade the operation-count budget of the original AST.
239    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    // Rhai's Dynamic dispatch does not promote i64 → f64 for overload
246    // resolution, so register both arities. Without the i64 variant a
247    // plugin author writing `format_cost_usd(1)` instead of
248    // `format_cost_usd(1.0)` hits "function not found" at render time.
249    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
255// Compile-time guarantee that `Arc<Engine>` stays thread-safe. Relies
256// on the `sync` feature flag in `Cargo.toml`; if that flag gets
257// dropped, this line breaks with a clear trait-bound error at the
258// engine constructor instead of surfacing later in the segment
259// wrapper that depends on the trait bound.
260const _: fn() = || {
261    fn assert_send_sync<T: Send + Sync>() {}
262    assert_send_sync::<Arc<Engine>>();
263};
264
265/// Host-registered `log(msg)` for plugin scripts. Emits one warn-
266/// level line per plugin per process through the installed host
267/// emitter; subsequent calls from the same plugin are silently
268/// dropped to keep a chatty plugin from flooding stderr. The active
269/// plugin id comes from `CURRENT_PLUGIN_ID`, set by the consumer's
270/// segment wrapper via [`set_current_plugin_id`] for the duration
271/// of a render; calls outside a render (e.g. tests that `eval`
272/// directly) attribute to a synthetic `<unscoped>` bucket.
273///
274/// `log()` is a diagnostic channel, not a user-feedback channel.
275/// It routes through the host's logging level (e.g. `LINESMITH_LOG`)
276/// when a host emitter is installed; a user who turns that off will
277/// not see plugin log lines. Plugins that want to communicate with
278/// users should emit via segment output (the return of `fn render(ctx)`),
279/// not `log()`.
280///
281/// Bumping the counter *before* emitting is deliberate: a chatty
282/// plugin should pay at most a single `to_owned` per process for its
283/// id, not one per dropped call.
284fn rhai_log(msg: &str) {
285    /// Sentinel id for `log()` calls outside a render scope.
286    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
309/// Format a duration in milliseconds as `"1h 23m"` / `"45m"` / `"12s"`.
310/// Negative inputs render as `"0s"`.
311fn 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
332/// Format a dollar amount as `"$1.23"` (two decimal places).
333fn rhai_format_cost_usd(dollars: f64) -> String {
334    format!("${dollars:.2}")
335}
336
337/// Format a token count as `"1.2k"` / `"3.5M"` / `"999"`. rhai's
338/// integer type is i64; we clamp negatives to 0 and format unsigned
339/// magnitudes.
340///
341/// The `M` threshold is set slightly below 1_000_000 (at 999_500) so
342/// that one-decimal rounding of the `k` branch can't produce the
343/// nonsensical `"1000.0k"` for values like 999_950.
344fn 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
357/// Format an RFC 3339 timestamp string as a coarse countdown relative
358/// to now (`"2h 13m"` / `"45m"` / `"6d"` / `"now"`). Parse failures
359/// surface as the literal `"?"` so the statusline degrades visibly.
360/// Inlined here rather than sharing with the rate-limit segments
361/// (which use a different format per spec) — this is a stable
362/// plugin-facing API and moving cadence shouldn't track segment refactors.
363fn 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        // Rhai wraps this as a `NumberOfOperations` variant; the
406        // message contains the limit literal.
407        assert!(
408            format!("{err}").contains("operations"),
409            "expected operation-limit error, got: {err}"
410        );
411    }
412
413    /// RAII guard for the per-render thread-locals so a test panic
414    /// can't leak state into siblings on the same thread. The
415    /// consumer-side segment wrapper (linesmith-core's
416    /// `RhaiSegment::render`) uses the same RAII pattern in production.
417    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        // Belt-and-suspenders: ensure cleared deadlines don't abort
463        // normal evaluation. A leak from a prior test on this thread
464        // would surface here as an unexpected error.
465        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        // Pin the per-plugin rate-limit: three `log()` calls under
474        // the same id collapse to exactly LOG_LINES_PER_PLUGIN
475        // emissions. Reset first so cross-test ordering on this
476        // thread can't preload the counter.
477        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        // Pin the sentinel id used when CURRENT_PLUGIN_ID is unset so
516        // a future rename (`<none>`, `<anon>`) doesn't silently
517        // scatter eval-callsite logs across new buckets.
518        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        // `import "foo"` would normally parse; disabling the symbol
529        // turns it into a parse error.
530        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        // We register nothing filesystem-related. A plugin that calls
550        // `fs::read("/etc/passwd")` hits "function not found."
551        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        // `print` / `debug` are rhai built-ins that route through the
563        // engine's on_print / on_debug callbacks. Our builder points
564        // both at no-op closures so plugin scripts can call them
565        // without crashing AND without reaching the host's stdout or
566        // stderr. `Engine::new()` defaults would leak to stdout; this
567        // test pins the no-op routing as the required posture.
568        let engine = build_engine();
569        // Eval returns Ok(()) — the call succeeded but produced
570        // nothing the host can observe.
571        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        // 2001-09-09 is safely in the past forever.
638        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        // Smoke test: `log` is registered and callable. Actual stderr
658        // capture lives in the segment wrapper that owns output
659        // routing.
660        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        // Regression guard for the i64 overload: plugins writing
685        // `format_cost_usd(1)` must not hit "function not found."
686        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        // `>= 1_000` branch triggers at 1000 exactly; guard against a
694        // future refactor to `>` that would silently regress.
695        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        // The spec's sample plugin (plugin-api.md §Plugin script
706        // contract) uses `model_name.len()`. If StandardPackage isn't
707        // loaded, that call fails with "function not found."
708        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        // Regression guard for rounding drift: 999_950 / 1_000 rounds
725        // to 1000.0, which would display as "1000.0k" without the
726        // boundary guard. Must roll to "1.0M" instead.
727        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        // Paired with the above: 999_499 is comfortably under the
734        // M threshold; must format as "999.5k".
735        assert_eq!(rhai_format_tokens(999_499), "999.5k");
736    }
737
738    #[test]
739    fn format_countdown_until_future_timestamp_renders_duration() {
740        // Exercises the RFC 3339 → DateTime → countdown path end-to-
741        // end (not just the bad-input fallback). Shape assertion only
742        // so the test isn't time-sensitive: result is neither "?"
743        // (parse failure) nor "now" (for a timestamp > 1 minute out).
744        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}