Skip to main content

linesmith_core/plugins/
ctx_mirror.rs

1//! Converts a [`DataContext`] into the immutable rhai `Map` a plugin's
2//! `render(ctx)` receives.
3//!
4//! Shape lives in `docs/specs/plugin-api.md` §ctx shape exposed to rhai.
5//! Highlights: enum variants render as snake_case `kind` tags,
6//! `Option<T>` becomes rhai `()`, lazy `Arc<Result<T, E>>` sources
7//! become `#{ kind: "ok", data: ... } | #{ kind: "error", error: ... }`
8//! tagged maps, and `ctx.git`'s nested `Option` collapses `Ok(None)`
9//! to `#{ kind: "ok", data: () }`.
10//!
11//! Declared-dep gating: `ctx.settings|claude_json|usage|sessions|git`
12//! only appear when the plugin's `@data_deps` header declared them.
13//! Undeclared accessors return `()` on the rhai side (not an error).
14
15use std::sync::OnceLock;
16
17use rhai::{Array, Dynamic, Map};
18use serde_json::Value as JsonValue;
19
20use super::engine::{MAX_ARRAY_SIZE, MAX_EXPR_DEPTH, MAX_MAP_SIZE, MAX_STRING_SIZE};
21use crate::data_context::{
22    DataContext, DataDep, DirtyCounts, DirtyState, EndpointUsage, ExtraUsage, FiveHourWindow,
23    GitContext, Head, JsonlUsage, RepoKind, SevenDayWindow, TokenCounts, UpstreamState,
24    UsageBucket, UsageData,
25};
26use crate::input::{
27    ContextWindow, CostMetrics, GitWorktree, ModelInfo, OutputStyle, StatusContext, Tool,
28    TurnUsage, WorkspaceInfo,
29};
30use crate::segments::RenderContext;
31
32const ENV_WHITELIST: &[&str] = &["TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "LANG"];
33
34/// Max nesting depth when converting server- or stdin-supplied JSON
35/// into a rhai `Dynamic`. Caps host-side recursion so a nested-object
36/// bomb cannot blow the stack — rhai's `MAX_EXPR_DEPTH` gates script
37/// parsing, not the mirror walk. Tied to the same ceiling so script
38/// and host walks share a consistent policy.
39const MAX_JSON_DEPTH: usize = MAX_EXPR_DEPTH;
40
41/// Controls which host-side caps apply during a JSON → rhai
42/// conversion. The escape-hatch variant preserves the documented
43/// round-trip of `ctx.status.raw` (see `docs/specs/plugin-api.md`);
44/// stack safety is non-negotiable so the depth guard fires in both
45/// postures, but breadth/string/key caps would silently break
46/// plugins reading tool-specific fields through the escape hatch.
47#[derive(Clone, Copy)]
48enum CapPosture {
49    /// Full caps: depth, map breadth, array breadth, string size, key
50    /// size. For server-controlled payloads such as
51    /// `ctx.usage.unknown_buckets` values where the source has no
52    /// reason to emit unbounded content.
53    Strict,
54    /// Depth cap only. For `ctx.status.raw` — the escape hatch that
55    /// must round-trip whatever the upstream tool emits.
56    EscapeHatch,
57}
58
59impl CapPosture {
60    fn is_strict(self) -> bool {
61        matches!(self, CapPosture::Strict)
62    }
63}
64
65/// Accumulated counts of host-side cap hits during one JSON conversion.
66/// The walk mutates this in place so a pathological payload emits at
67/// most one aggregated `lsm_warn!` per top-level call — a single line
68/// instead of one per offending subtree. Counters use `saturating_add`
69/// to stay correct on adversarial payloads even if the same category
70/// fires more than `usize::MAX` times.
71#[derive(Default)]
72struct ConversionLimits {
73    depth_collapsed: usize,
74    map_truncated: usize,
75    array_truncated: usize,
76    string_truncated: usize,
77    map_key_dropped: usize,
78}
79
80impl ConversionLimits {
81    fn is_empty(&self) -> bool {
82        self.depth_collapsed == 0
83            && self.map_truncated == 0
84            && self.array_truncated == 0
85            && self.string_truncated == 0
86            && self.map_key_dropped == 0
87    }
88
89    fn emit_warn(&self, label: &str) {
90        if self.is_empty() {
91            return;
92        }
93        let mut parts: Vec<String> = Vec::new();
94        if self.depth_collapsed > 0 {
95            parts.push(format!(
96                "{} subtree(s) collapsed at depth {MAX_JSON_DEPTH}",
97                self.depth_collapsed
98            ));
99        }
100        if self.map_truncated > 0 {
101            parts.push(format!(
102                "{} map(s) truncated at {MAX_MAP_SIZE} entries",
103                self.map_truncated
104            ));
105        }
106        if self.array_truncated > 0 {
107            parts.push(format!(
108                "{} array(s) truncated at {MAX_ARRAY_SIZE} items",
109                self.array_truncated
110            ));
111        }
112        if self.string_truncated > 0 {
113            parts.push(format!(
114                "{} string(s) truncated at {MAX_STRING_SIZE} bytes",
115                self.string_truncated
116            ));
117        }
118        if self.map_key_dropped > 0 {
119            parts.push(format!(
120                "{} entries dropped for keys longer than {MAX_STRING_SIZE} bytes",
121                self.map_key_dropped
122            ));
123        }
124        crate::lsm_warn!("{label}: {}", parts.join("; "));
125    }
126}
127
128/// Build the `ctx` value a plugin's `render(ctx)` function sees.
129///
130/// `declared_deps` gates which lazy sources get mirrored. `config` is
131/// the plugin's `[segments.<id>]` TOML table already converted to a
132/// rhai-compatible `Dynamic` (use `()` when no table is configured).
133/// `rc` is the per-render layout state surfaced as `ctx.render` —
134/// `ctx.render.terminal_width` today, more fields later.
135pub fn build_ctx(
136    dc: &DataContext,
137    rc: &RenderContext,
138    declared_deps: &[DataDep],
139    config: Dynamic,
140) -> Dynamic {
141    let mut map = Map::new();
142    map.insert("status".into(), build_status(&dc.status));
143    map.insert("config".into(), config);
144    map.insert("env".into(), env_snapshot());
145    map.insert("render".into(), build_render(rc));
146
147    let declared = |d: DataDep| declared_deps.contains(&d);
148
149    if declared(DataDep::Settings) {
150        let arc = dc.settings();
151        let value = match &*arc {
152            Ok(_) => tagged_ok(Dynamic::from_map(Map::new())),
153            Err(e) => tagged_error(e.code()),
154        };
155        map.insert("settings".into(), value);
156    }
157    if declared(DataDep::ClaudeJson) {
158        let arc = dc.claude_json();
159        let value = match &*arc {
160            Ok(_) => tagged_ok(Dynamic::from_map(Map::new())),
161            Err(e) => tagged_error(e.code()),
162        };
163        map.insert("claude_json".into(), value);
164    }
165    if declared(DataDep::Usage) {
166        let arc = dc.usage();
167        let value = match &*arc {
168            Ok(data) => tagged_ok(build_usage_data(data)),
169            Err(e) => tagged_error(e.code()),
170        };
171        map.insert("usage".into(), value);
172    }
173    if declared(DataDep::Sessions) {
174        let arc = dc.sessions();
175        let value = match &*arc {
176            Ok(_) => tagged_ok(Dynamic::from_map(Map::new())),
177            Err(e) => tagged_error(e.code()),
178        };
179        map.insert("sessions".into(), value);
180    }
181    if declared(DataDep::Git) {
182        let arc = dc.git();
183        let value = match &*arc {
184            // Ok(Some) → data: <map>; Ok(None) → data: () per
185            // plugin-api.md §Special cases (no-git-repo distinct from
186            // gix failure).
187            Ok(Some(gc)) => tagged_ok(build_git_context(gc)),
188            Ok(None) => tagged_ok(Dynamic::UNIT),
189            Err(e) => tagged_error(e.code()),
190        };
191        map.insert("git".into(), value);
192    }
193
194    Dynamic::from_map(map)
195}
196
197// --- RenderContext mirror ------------------------------------------------
198
199fn build_render(rc: &RenderContext) -> Dynamic {
200    let mut m = Map::new();
201    m.insert(
202        "terminal_width".into(),
203        Dynamic::from_int(i64::from(rc.terminal_width)),
204    );
205    Dynamic::from_map(m)
206}
207
208// --- StatusContext mirror ------------------------------------------------
209
210fn build_status(s: &StatusContext) -> Dynamic {
211    let mut m = Map::new();
212    m.insert("tool".into(), build_tool(&s.tool));
213    m.insert(
214        "model".into(),
215        s.model.as_ref().map_or(Dynamic::UNIT, build_model),
216    );
217    m.insert(
218        "workspace".into(),
219        s.workspace.as_ref().map_or(Dynamic::UNIT, build_workspace),
220    );
221    m.insert(
222        "context_window".into(),
223        s.context_window
224            .as_ref()
225            .map_or(Dynamic::UNIT, build_context_window),
226    );
227    m.insert(
228        "cost".into(),
229        s.cost.as_ref().map_or(Dynamic::UNIT, build_cost),
230    );
231    // `rate_limits` is not on StatusContext; plugins read `ctx.usage`
232    // via `DataDep::Usage` (see plugin-api.md §ctx.usage shape).
233    m.insert(
234        "effort".into(),
235        s.effort
236            .map_or(Dynamic::UNIT, |e| Dynamic::from(e.as_str().to_string())),
237    );
238    m.insert(
239        "vim".into(),
240        s.vim
241            .map_or(Dynamic::UNIT, |v| Dynamic::from(v.as_str().to_string())),
242    );
243    m.insert(
244        "output_style".into(),
245        s.output_style
246            .as_ref()
247            .map_or(Dynamic::UNIT, build_output_style),
248    );
249    m.insert(
250        "agent_name".into(),
251        s.agent_name
252            .as_ref()
253            .map_or(Dynamic::UNIT, |n| Dynamic::from(n.clone())),
254    );
255    m.insert(
256        "version".into(),
257        s.version
258            .as_ref()
259            .map_or(Dynamic::UNIT, |v| Dynamic::from(v.clone())),
260    );
261    m.insert("raw".into(), json_to_dynamic(&s.raw));
262    Dynamic::from_map(m)
263}
264
265fn build_tool(t: &Tool) -> Dynamic {
266    let mut m = Map::new();
267    let (kind, name) = match t {
268        Tool::ClaudeCode => ("claude_code", None),
269        Tool::QwenCode => ("qwen_code", None),
270        Tool::CodexCli => ("codex_cli", None),
271        Tool::CopilotCli => ("copilot_cli", None),
272        Tool::Other(n) => ("other", Some(n.to_string())),
273    };
274    m.insert("kind".into(), Dynamic::from(kind.to_string()));
275    if let Some(n) = name {
276        m.insert("name".into(), Dynamic::from(n));
277    }
278    Dynamic::from_map(m)
279}
280
281fn build_model(m: &ModelInfo) -> Dynamic {
282    let mut out = Map::new();
283    out.insert("display_name".into(), Dynamic::from(m.display_name.clone()));
284    Dynamic::from_map(out)
285}
286
287fn build_output_style(o: &OutputStyle) -> Dynamic {
288    let mut out = Map::new();
289    out.insert("name".into(), Dynamic::from(o.name.clone()));
290    Dynamic::from_map(out)
291}
292
293fn build_workspace(w: &WorkspaceInfo) -> Dynamic {
294    let mut m = Map::new();
295    m.insert(
296        "project_dir".into(),
297        Dynamic::from(w.project_dir.to_string_lossy().into_owned()),
298    );
299    m.insert(
300        "git_worktree".into(),
301        w.git_worktree
302            .as_ref()
303            .map_or(Dynamic::UNIT, build_worktree),
304    );
305    Dynamic::from_map(m)
306}
307
308fn build_worktree(wt: &GitWorktree) -> Dynamic {
309    let mut m = Map::new();
310    m.insert("name".into(), Dynamic::from(wt.name.clone()));
311    m.insert(
312        "path".into(),
313        Dynamic::from(wt.path.to_string_lossy().into_owned()),
314    );
315    Dynamic::from_map(m)
316}
317
318fn build_context_window(cw: &ContextWindow) -> Dynamic {
319    let mut m = Map::new();
320    m.insert(
321        "used".into(),
322        cw.used
323            .map_or(Dynamic::UNIT, |p| Dynamic::from(f64::from(p.value()))),
324    );
325    m.insert(
326        "remaining".into(),
327        cw.remaining()
328            .map_or(Dynamic::UNIT, |p| Dynamic::from(f64::from(p.value()))),
329    );
330    m.insert(
331        "size".into(),
332        cw.size
333            .map_or(Dynamic::UNIT, |s| Dynamic::from_int(i64::from(s))),
334    );
335    m.insert(
336        "total_input_tokens".into(),
337        cw.total_input_tokens.map_or(Dynamic::UNIT, int_from_u64),
338    );
339    m.insert(
340        "total_output_tokens".into(),
341        cw.total_output_tokens.map_or(Dynamic::UNIT, int_from_u64),
342    );
343    m.insert(
344        "current_usage".into(),
345        cw.current_usage
346            .as_ref()
347            .map_or(Dynamic::UNIT, build_turn_usage),
348    );
349    Dynamic::from_map(m)
350}
351
352fn build_turn_usage(u: &TurnUsage) -> Dynamic {
353    // Destructure so a new field on TurnUsage fails to compile here
354    // rather than silently dropping from the rhai mirror.
355    let TurnUsage {
356        input_tokens,
357        output_tokens,
358        cache_creation_input_tokens,
359        cache_read_input_tokens,
360    } = u;
361    let mut m = Map::new();
362    m.insert("input_tokens".into(), int_from_u64(*input_tokens));
363    m.insert("output_tokens".into(), int_from_u64(*output_tokens));
364    m.insert(
365        "cache_creation_input_tokens".into(),
366        int_from_u64(*cache_creation_input_tokens),
367    );
368    m.insert(
369        "cache_read_input_tokens".into(),
370        int_from_u64(*cache_read_input_tokens),
371    );
372    Dynamic::from_map(m)
373}
374
375fn build_cost(c: &CostMetrics) -> Dynamic {
376    let mut m = Map::new();
377    m.insert(
378        "total_cost_usd".into(),
379        c.total_cost_usd.map_or(Dynamic::UNIT, Dynamic::from),
380    );
381    m.insert(
382        "total_duration_ms".into(),
383        c.total_duration_ms.map_or(Dynamic::UNIT, int_from_u64),
384    );
385    m.insert(
386        "total_api_duration_ms".into(),
387        c.total_api_duration_ms.map_or(Dynamic::UNIT, int_from_u64),
388    );
389    m.insert(
390        "total_lines_added".into(),
391        c.total_lines_added.map_or(Dynamic::UNIT, int_from_u64),
392    );
393    m.insert(
394        "total_lines_removed".into(),
395        c.total_lines_removed.map_or(Dynamic::UNIT, int_from_u64),
396    );
397    Dynamic::from_map(m)
398}
399
400fn build_usage_data(data: &UsageData) -> Dynamic {
401    // Tagged-map shape per `plugin-api.md` §ctx shape + ADR-0013:
402    // `kind` discriminates the variant; the sibling fields differ
403    // between `endpoint` and `jsonl`. Adding a new `UsageData`
404    // variant surfaces here as a non-exhaustive match error.
405    match data {
406        UsageData::Endpoint(e) => build_endpoint_usage(e),
407        UsageData::Jsonl(j) => build_jsonl_usage(j),
408    }
409}
410
411fn build_endpoint_usage(e: &EndpointUsage) -> Dynamic {
412    // Destructure so adding a field to EndpointUsage surfaces as a
413    // compile error rather than silently dropping from the rhai mirror.
414    let EndpointUsage {
415        five_hour,
416        seven_day,
417        seven_day_opus,
418        seven_day_sonnet,
419        seven_day_oauth_apps,
420        extra_usage,
421        unknown_buckets,
422    } = e;
423    let mut m = Map::new();
424    m.insert("kind".into(), Dynamic::from("endpoint".to_string()));
425    m.insert(
426        "five_hour".into(),
427        five_hour.as_ref().map_or(Dynamic::UNIT, build_usage_bucket),
428    );
429    m.insert(
430        "seven_day".into(),
431        seven_day.as_ref().map_or(Dynamic::UNIT, build_usage_bucket),
432    );
433    m.insert(
434        "seven_day_opus".into(),
435        seven_day_opus
436            .as_ref()
437            .map_or(Dynamic::UNIT, build_usage_bucket),
438    );
439    m.insert(
440        "seven_day_sonnet".into(),
441        seven_day_sonnet
442            .as_ref()
443            .map_or(Dynamic::UNIT, build_usage_bucket),
444    );
445    m.insert(
446        "seven_day_oauth_apps".into(),
447        seven_day_oauth_apps
448            .as_ref()
449            .map_or(Dynamic::UNIT, build_usage_bucket),
450    );
451    m.insert(
452        "extra_usage".into(),
453        extra_usage
454            .as_ref()
455            .map_or(Dynamic::UNIT, build_extra_usage),
456    );
457    m.insert(
458        "unknown_buckets".into(),
459        build_unknown_buckets(unknown_buckets),
460    );
461    Dynamic::from_map(m)
462}
463
464fn build_jsonl_usage(j: &JsonlUsage) -> Dynamic {
465    let mut m = Map::new();
466    m.insert("kind".into(), Dynamic::from("jsonl".to_string()));
467    m.insert(
468        "five_hour".into(),
469        j.five_hour
470            .as_ref()
471            .map_or(Dynamic::UNIT, build_five_hour_window),
472    );
473    m.insert("seven_day".into(), build_seven_day_window(&j.seven_day));
474    Dynamic::from_map(m)
475}
476
477fn build_five_hour_window(w: &FiveHourWindow) -> Dynamic {
478    // Destructure so adding a field to FiveHourWindow forces a mirror
479    // update. `ends_at` is derived from `start` per the type's invariant
480    // — we expose it to plugins alongside `start` since scripts rarely
481    // want to recompute the derivation themselves.
482    let FiveHourWindow { tokens, start } = w;
483    let mut m = Map::new();
484    m.insert("tokens".into(), build_token_counts(tokens));
485    m.insert("start".into(), Dynamic::from(start.to_rfc3339()));
486    m.insert("ends_at".into(), Dynamic::from(w.ends_at().to_rfc3339()));
487    Dynamic::from_map(m)
488}
489
490fn build_seven_day_window(w: &SevenDayWindow) -> Dynamic {
491    let SevenDayWindow { tokens } = w;
492    let mut m = Map::new();
493    m.insert("tokens".into(), build_token_counts(tokens));
494    Dynamic::from_map(m)
495}
496
497fn build_token_counts(t: &TokenCounts) -> Dynamic {
498    let TokenCounts {
499        input,
500        output,
501        cache_creation,
502        cache_read,
503    } = t;
504    let mut m = Map::new();
505    m.insert("input".into(), int_from_u64(*input));
506    m.insert("output".into(), int_from_u64(*output));
507    m.insert("cache_creation".into(), int_from_u64(*cache_creation));
508    m.insert("cache_read".into(), int_from_u64(*cache_read));
509    m.insert("total".into(), int_from_u64(t.total()));
510    Dynamic::from_map(m)
511}
512
513fn build_unknown_buckets(map: &std::collections::HashMap<String, JsonValue>) -> Dynamic {
514    // Server-controlled payload: cap entry count at MAX_MAP_SIZE and
515    // skip keys longer than MAX_STRING_SIZE. rhai's script-side limits
516    // don't apply to host-constructed Maps, so a buggy or malicious
517    // /api/oauth/usage response has to be bounded here. Sort keys so
518    // truncation is deterministic across renders — HashMap iteration
519    // order is process-randomized, and without sorting the surviving
520    // subset would rotate per render, flickering a specific bucket in
521    // and out of a plugin's view.
522    let mut sorted: Vec<(&String, &JsonValue)> = map.iter().collect();
523    sorted.sort_by(|a, b| a.0.cmp(b.0));
524
525    let mut m = Map::new();
526    let mut dropped_oversize_keys: usize = 0;
527    let mut truncated = false;
528    let mut limits = ConversionLimits::default();
529    for (k, v) in sorted {
530        // INVARIANT: the breadth and key-size guards must fire *before*
531        // `json_to_dynamic_walk`. Reorder and this scope's `limits`
532        // would double-count top-level truncation against the per-value
533        // counters the `(values)` warn reports.
534        if m.len() >= MAX_MAP_SIZE {
535            truncated = true;
536            break;
537        }
538        if k.len() > MAX_STRING_SIZE {
539            dropped_oversize_keys = dropped_oversize_keys.saturating_add(1);
540            continue;
541        }
542        m.insert(
543            k.as_str().into(),
544            json_to_dynamic_walk(v, 0, CapPosture::Strict, &mut limits),
545        );
546    }
547    if truncated {
548        crate::lsm_warn!(
549            "ctx.usage.unknown_buckets: truncated to {MAX_MAP_SIZE} entries (source had {})",
550            map.len(),
551        );
552    }
553    if dropped_oversize_keys > 0 {
554        crate::lsm_warn!(
555            "ctx.usage.unknown_buckets: dropped {dropped_oversize_keys} entries with keys longer than {MAX_STRING_SIZE} bytes",
556        );
557    }
558    limits.emit_warn("ctx.usage.unknown_buckets (values)");
559    Dynamic::from_map(m)
560}
561
562fn build_usage_bucket(b: &UsageBucket) -> Dynamic {
563    let UsageBucket {
564        utilization,
565        resets_at,
566    } = b;
567    let mut m = Map::new();
568    m.insert(
569        "utilization".into(),
570        Dynamic::from(f64::from(utilization.value())),
571    );
572    m.insert(
573        "resets_at".into(),
574        resets_at.map_or(Dynamic::UNIT, |t| Dynamic::from(t.to_rfc3339())),
575    );
576    Dynamic::from_map(m)
577}
578
579fn build_extra_usage(x: &ExtraUsage) -> Dynamic {
580    let ExtraUsage {
581        is_enabled,
582        utilization,
583        monthly_limit,
584        used_credits,
585        currency,
586    } = x;
587    let mut m = Map::new();
588    m.insert(
589        "is_enabled".into(),
590        is_enabled.map_or(Dynamic::UNIT, Dynamic::from),
591    );
592    m.insert(
593        "utilization".into(),
594        utilization.map_or(Dynamic::UNIT, |p| Dynamic::from(f64::from(p.value()))),
595    );
596    m.insert(
597        "monthly_limit".into(),
598        monthly_limit.map_or(Dynamic::UNIT, Dynamic::from),
599    );
600    m.insert(
601        "used_credits".into(),
602        used_credits.map_or(Dynamic::UNIT, Dynamic::from),
603    );
604    m.insert(
605        "currency".into(),
606        currency
607            .as_deref()
608            .map_or(Dynamic::UNIT, |c| Dynamic::from(c.to_string())),
609    );
610    Dynamic::from_map(m)
611}
612
613// --- GitContext mirror ---------------------------------------------------
614
615fn build_git_context(gc: &GitContext) -> Dynamic {
616    // Destructure so adding a field to GitContext surfaces as a
617    // compile error here rather than silently dropping from the rhai
618    // mirror. `..` covers the two private OnceCell fields surfaced
619    // through `gc.dirty()` / `gc.upstream()` below.
620    let GitContext {
621        repo_kind,
622        repo_path,
623        head,
624        ..
625    } = gc;
626    let mut m = Map::new();
627    m.insert("repo_kind".into(), build_repo_kind(repo_kind));
628    m.insert(
629        "repo_path".into(),
630        Dynamic::from(repo_path.to_string_lossy().into_owned()),
631    );
632    m.insert("head".into(), build_head(head));
633    m.insert("dirty".into(), build_dirty(&gc.dirty()));
634    m.insert("upstream".into(), build_upstream(&gc.upstream()));
635    Dynamic::from_map(m)
636}
637
638fn build_repo_kind(kind: &RepoKind) -> Dynamic {
639    let mut m = Map::new();
640    match kind {
641        RepoKind::Main => {
642            m.insert("kind".into(), Dynamic::from("main".to_string()));
643        }
644        RepoKind::Bare => {
645            m.insert("kind".into(), Dynamic::from("bare".to_string()));
646        }
647        RepoKind::Submodule => {
648            m.insert("kind".into(), Dynamic::from("submodule".to_string()));
649        }
650        RepoKind::LinkedWorktree { name } => {
651            m.insert("kind".into(), Dynamic::from("linked_worktree".to_string()));
652            m.insert("name".into(), Dynamic::from(name.clone()));
653        }
654    }
655    Dynamic::from_map(m)
656}
657
658fn build_head(head: &Head) -> Dynamic {
659    let mut m = Map::new();
660    m.insert("kind".into(), Dynamic::from(head.kind_str().to_string()));
661    match head {
662        Head::Branch(name) => {
663            m.insert("name".into(), Dynamic::from(name.clone()));
664        }
665        Head::Detached(oid) => {
666            m.insert("sha".into(), Dynamic::from(oid.to_string()));
667        }
668        Head::Unborn { symbolic_ref } => {
669            m.insert("symbolic_ref".into(), Dynamic::from(symbolic_ref.clone()));
670        }
671        Head::OtherRef { full_name } => {
672            m.insert("full_name".into(), Dynamic::from(full_name.clone()));
673        }
674    }
675    Dynamic::from_map(m)
676}
677
678fn build_dirty(d: &DirtyState) -> Dynamic {
679    let mut m = Map::new();
680    match d {
681        DirtyState::Clean => {
682            m.insert("kind".into(), Dynamic::from("clean".to_string()));
683        }
684        DirtyState::Dirty(None) => {
685            m.insert("kind".into(), Dynamic::from("dirty_uncounted".to_string()));
686        }
687        DirtyState::Dirty(Some(counts)) => {
688            let DirtyCounts {
689                staged,
690                unstaged,
691                untracked,
692            } = counts;
693            m.insert("kind".into(), Dynamic::from("dirty_counted".to_string()));
694            m.insert("staged".into(), Dynamic::from(i64::from(*staged)));
695            m.insert("unstaged".into(), Dynamic::from(i64::from(*unstaged)));
696            m.insert("untracked".into(), Dynamic::from(i64::from(*untracked)));
697        }
698    }
699    Dynamic::from_map(m)
700}
701
702fn build_upstream(u: &Option<UpstreamState>) -> Dynamic {
703    let Some(u) = u else { return Dynamic::UNIT };
704    let UpstreamState {
705        ahead,
706        behind,
707        upstream_branch,
708    } = u;
709    let mut m = Map::new();
710    m.insert("ahead".into(), Dynamic::from(i64::from(*ahead)));
711    m.insert("behind".into(), Dynamic::from(i64::from(*behind)));
712    m.insert(
713        "upstream_branch".into(),
714        Dynamic::from(upstream_branch.clone()),
715    );
716    Dynamic::from_map(m)
717}
718
719// --- Tagged Result helpers ------------------------------------------------
720
721fn tagged_ok(data: Dynamic) -> Dynamic {
722    let mut m = Map::new();
723    m.insert("kind".into(), Dynamic::from("ok".to_string()));
724    m.insert("data".into(), data);
725    Dynamic::from_map(m)
726}
727
728fn tagged_error(code: &str) -> Dynamic {
729    let mut m = Map::new();
730    m.insert("kind".into(), Dynamic::from("error".to_string()));
731    m.insert("error".into(), Dynamic::from(code.to_string()));
732    Dynamic::from_map(m)
733}
734
735// --- env snapshot ---------------------------------------------------------
736
737fn env_snapshot() -> Dynamic {
738    // `var().ok()` collapses `NotPresent` and `NotUnicode` to the
739    // same rhai `()`. Acceptable for the 5-key whitelist (TERM,
740    // COLORTERM, NO_COLOR, FORCE_COLOR, LANG): a non-UTF-8 value in
741    // any of these is already broken upstream, and plugins have no
742    // safe way to act on garbage bytes.
743    static SNAPSHOT: OnceLock<Dynamic> = OnceLock::new();
744    SNAPSHOT
745        .get_or_init(|| build_env_map(ENV_WHITELIST, |k| std::env::var(k).ok()))
746        .clone()
747}
748
749fn build_env_map<F>(keys: &[&str], mut get: F) -> Dynamic
750where
751    F: FnMut(&str) -> Option<String>,
752{
753    let mut m = Map::new();
754    for key in keys {
755        let value = get(key).map_or(Dynamic::UNIT, Dynamic::from);
756        m.insert((*key).into(), value);
757    }
758    Dynamic::from_map(m)
759}
760
761// --- serde_json::Value → rhai::Dynamic -----------------------------------
762
763/// `Arc<serde_json::Value>` is the stdin escape hatch exposed as
764/// `ctx.status.raw`. Convert recursively so plugins can read tool-
765/// specific fields the canonical `StatusContext` doesn't model. The
766/// depth guard runs so recursion is stack-safe, but no other cap
767/// applies — see `CapPosture::EscapeHatch` and the plugin-api spec.
768fn json_to_dynamic(v: &JsonValue) -> Dynamic {
769    let mut limits = ConversionLimits::default();
770    let out = json_to_dynamic_walk(v, 0, CapPosture::EscapeHatch, &mut limits);
771    limits.emit_warn("ctx.status.raw");
772    out
773}
774
775fn json_to_dynamic_walk(
776    v: &JsonValue,
777    depth: usize,
778    posture: CapPosture,
779    limits: &mut ConversionLimits,
780) -> Dynamic {
781    if depth >= MAX_JSON_DEPTH {
782        // A nested-object bomb hits this guard before it blows the
783        // Rust stack. Collapse the subtree to `()` so the rest of the
784        // mirror survives. Note: `JsonValue::Null` also maps to `()`,
785        // so plugins cannot distinguish truncated from genuinely-null
786        // without the aggregated warn emitted by `emit_warn`.
787        limits.depth_collapsed = limits.depth_collapsed.saturating_add(1);
788        return Dynamic::UNIT;
789    }
790    match v {
791        JsonValue::Null => Dynamic::UNIT,
792        JsonValue::Bool(b) => Dynamic::from(*b),
793        JsonValue::Number(n) => {
794            if let Some(i) = n.as_i64() {
795                Dynamic::from(i)
796            } else if let Some(f) = n.as_f64() {
797                Dynamic::from(f)
798            } else {
799                // serde_json::Number is i64|u64|f64 internally; the
800                // first two arms catch i64 and any f64-representable
801                // value, leaving only u64 > i64::MAX. Round-trip via
802                // f64 (precision loss is acceptable for an escape-
803                // hatch field; rhai has no native u64).
804                Dynamic::from(n.as_u64().map_or(0.0_f64, |u| u as f64))
805            }
806        }
807        JsonValue::String(s) => {
808            if posture.is_strict() && s.len() > MAX_STRING_SIZE {
809                limits.string_truncated = limits.string_truncated.saturating_add(1);
810                Dynamic::from(truncate_utf8(s, MAX_STRING_SIZE))
811            } else {
812                Dynamic::from(s.clone())
813            }
814        }
815        JsonValue::Array(arr) => {
816            let cap = if posture.is_strict() {
817                MAX_ARRAY_SIZE
818            } else {
819                usize::MAX
820            };
821            let items: Array = arr
822                .iter()
823                .take(cap)
824                .map(|item| json_to_dynamic_walk(item, depth + 1, posture, limits))
825                .collect();
826            if posture.is_strict() && arr.len() > MAX_ARRAY_SIZE {
827                limits.array_truncated = limits.array_truncated.saturating_add(1);
828            }
829            Dynamic::from_array(items)
830        }
831        JsonValue::Object(obj) => {
832            let strict = posture.is_strict();
833            let mut m = Map::new();
834            let mut iter_broke = false;
835            for (k, val) in obj {
836                if strict && m.len() >= MAX_MAP_SIZE {
837                    iter_broke = true;
838                    break;
839                }
840                if strict && k.len() > MAX_STRING_SIZE {
841                    limits.map_key_dropped = limits.map_key_dropped.saturating_add(1);
842                    continue;
843                }
844                m.insert(
845                    k.as_str().into(),
846                    json_to_dynamic_walk(val, depth + 1, posture, limits),
847                );
848            }
849            if iter_broke {
850                limits.map_truncated = limits.map_truncated.saturating_add(1);
851            }
852            Dynamic::from_map(m)
853        }
854    }
855}
856
857fn truncate_utf8(s: &str, max_bytes: usize) -> String {
858    if s.len() <= max_bytes {
859        return s.to_string();
860    }
861    let mut end = max_bytes;
862    while end > 0 && !s.is_char_boundary(end) {
863        end -= 1;
864    }
865    s[..end].to_string()
866}
867
868fn int_from_u64(n: u64) -> Dynamic {
869    // rhai integers are i64. A u64 > i64::MAX reaching this function
870    // signals an upstream parser bug (no real Claude statusline field
871    // is even close); clamp to i64::MAX so the render never panics.
872    // Forensics survive on the raw stdin via `ctx.status.raw`.
873    Dynamic::from(i64::try_from(n).unwrap_or(i64::MAX))
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use crate::data_context::DataContext;
880    use crate::input::{EffortLevel, Percent};
881    use std::path::PathBuf;
882    use std::sync::Arc;
883
884    fn minimal_status() -> StatusContext {
885        StatusContext {
886            tool: Tool::ClaudeCode,
887            model: Some(ModelInfo {
888                display_name: "Sonnet".to_string(),
889            }),
890            workspace: Some(WorkspaceInfo {
891                project_dir: PathBuf::from("/repo"),
892                git_worktree: None,
893            }),
894            context_window: None,
895            cost: None,
896            effort: None,
897            vim: None,
898            output_style: None,
899            agent_name: None,
900            version: None,
901            raw: Arc::new(serde_json::json!({"custom": "field"})),
902        }
903    }
904
905    fn build_and_unwrap_map(dc: &DataContext, deps: &[DataDep]) -> Map {
906        let rc = RenderContext::new(80);
907        let dyn_ctx = build_ctx(dc, &rc, deps, Dynamic::UNIT);
908        dyn_ctx.try_cast::<Map>().expect("ctx is a map")
909    }
910
911    fn status_map(ctx: &Map) -> Map {
912        ctx.get("status")
913            .expect("status key")
914            .clone()
915            .try_cast::<Map>()
916            .expect("status is a map")
917    }
918
919    #[test]
920    fn top_level_has_status_config_env() {
921        let dc = DataContext::new(minimal_status());
922        let ctx = build_and_unwrap_map(&dc, &[]);
923        assert!(ctx.contains_key("status"));
924        assert!(ctx.contains_key("config"));
925        assert!(ctx.contains_key("env"));
926    }
927
928    #[test]
929    fn undeclared_sources_absent() {
930        let dc = DataContext::new(minimal_status());
931        let ctx = build_and_unwrap_map(&dc, &[]);
932        for key in ["settings", "claude_json", "usage", "sessions", "git"] {
933            assert!(!ctx.contains_key(key), "{key} should not appear");
934        }
935    }
936
937    #[test]
938    fn usage_endpoint_mirror_preserves_every_field_plugins_depend_on() {
939        // Plugin-facing contract: `ctx.usage.data.*` field names and
940        // their scalar types are load-bearing. A rename in
941        // `EndpointUsage` / `UsageBucket` / `ExtraUsage` must either
942        // break this test or update it so spec drift surfaces in CI.
943        use crate::data_context::{EndpointUsage, ExtraUsage, UsageBucket, UsageData};
944        use chrono::{TimeZone, Utc};
945
946        let mut unknown_buckets = std::collections::HashMap::new();
947        unknown_buckets.insert("iguana_necktie".to_string(), serde_json::Value::Null);
948        let data = UsageData::Endpoint(EndpointUsage {
949            five_hour: Some(UsageBucket {
950                utilization: Percent::new(42.0).unwrap(),
951                resets_at: Some(Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0).unwrap()),
952            }),
953            seven_day: Some(UsageBucket {
954                utilization: Percent::new(33.0).unwrap(),
955                resets_at: None,
956            }),
957            seven_day_opus: None,
958            seven_day_sonnet: None,
959            seven_day_oauth_apps: None,
960            extra_usage: Some(ExtraUsage {
961                is_enabled: Some(true),
962                utilization: Some(Percent::new(17.5).unwrap()),
963                monthly_limit: Some(100.0),
964                used_credits: Some(40.0),
965                currency: Some("EUR".into()),
966            }),
967            unknown_buckets,
968        });
969
970        let dc = DataContext::new(minimal_status());
971        dc.preseed_usage(Ok(data)).expect("seed");
972        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
973
974        let wrapper: Map = ctx
975            .get("usage")
976            .expect("usage key")
977            .clone()
978            .try_cast()
979            .expect("usage is a map");
980        assert_eq!(
981            wrapper
982                .get("kind")
983                .and_then(|d| d.clone().try_cast::<String>()),
984            Some("ok".to_string()),
985        );
986        let payload: Map = wrapper
987            .get("data")
988            .expect("data payload")
989            .clone()
990            .try_cast()
991            .expect("data is a map");
992
993        assert_eq!(
994            payload
995                .get("kind")
996                .and_then(|d| d.clone().try_cast::<String>()),
997            Some("endpoint".to_string()),
998        );
999        let five: Map = payload
1000            .get("five_hour")
1001            .unwrap()
1002            .clone()
1003            .try_cast()
1004            .unwrap();
1005        assert_eq!(
1006            five.get("utilization")
1007                .and_then(|d| d.clone().try_cast::<f64>()),
1008            Some(42.0),
1009        );
1010        assert!(five.get("resets_at").unwrap().is_string());
1011        let seven: Map = payload
1012            .get("seven_day")
1013            .unwrap()
1014            .clone()
1015            .try_cast()
1016            .unwrap();
1017        assert!(seven.get("resets_at").unwrap().is_unit());
1018        assert!(payload.get("seven_day_opus").unwrap().is_unit());
1019        let extra: Map = payload
1020            .get("extra_usage")
1021            .unwrap()
1022            .clone()
1023            .try_cast()
1024            .unwrap();
1025        assert_eq!(
1026            extra
1027                .get("is_enabled")
1028                .and_then(|d| d.clone().try_cast::<bool>()),
1029            Some(true),
1030        );
1031        assert_eq!(
1032            extra
1033                .get("monthly_limit")
1034                .and_then(|d| d.clone().try_cast::<f64>()),
1035            Some(100.0),
1036        );
1037        assert_eq!(
1038            extra
1039                .get("currency")
1040                .and_then(|d| d.clone().try_cast::<String>()),
1041            Some("EUR".to_string()),
1042        );
1043        let unknown: Map = payload
1044            .get("unknown_buckets")
1045            .expect("unknown_buckets present")
1046            .clone()
1047            .try_cast()
1048            .unwrap();
1049        assert!(unknown.contains_key("iguana_necktie"));
1050    }
1051
1052    #[test]
1053    fn usage_jsonl_variant_mirrors_tokens_and_ends_at() {
1054        // ADR-0013 + plugin-api.md §ctx shape: jsonl variant exposes
1055        // `kind: "jsonl"` with raw token counts and the 5h window's
1056        // `ends_at`. Plugins read this to render the JSONL fallback
1057        // — changes must break this test.
1058        use crate::data_context::{
1059            FiveHourWindow, JsonlUsage, SevenDayWindow, TokenCounts, UsageData,
1060        };
1061        use chrono::{TimeZone, Utc};
1062        let tokens = TokenCounts::from_parts(400_000, 20_000, 0, 0);
1063        // `start + 5h` = ends_at; encode as `start` per the invariant.
1064        let start = Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0).unwrap();
1065        let ends_at = Utc.with_ymd_and_hms(2099, 1, 1, 5, 0, 0).unwrap();
1066        let data = UsageData::Jsonl(JsonlUsage::new(
1067            Some(FiveHourWindow::new(tokens, start)),
1068            SevenDayWindow::new(TokenCounts::from_parts(1_000_000, 0, 0, 0)),
1069        ));
1070        let dc = DataContext::new(minimal_status());
1071        dc.preseed_usage(Ok(data)).expect("seed");
1072        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1073        let payload: Map = ctx
1074            .get("usage")
1075            .unwrap()
1076            .clone()
1077            .try_cast::<Map>()
1078            .unwrap()
1079            .get("data")
1080            .unwrap()
1081            .clone()
1082            .try_cast()
1083            .unwrap();
1084        assert_eq!(
1085            payload
1086                .get("kind")
1087                .and_then(|d| d.clone().try_cast::<String>()),
1088            Some("jsonl".to_string()),
1089        );
1090        let five: Map = payload
1091            .get("five_hour")
1092            .unwrap()
1093            .clone()
1094            .try_cast()
1095            .unwrap();
1096        assert_eq!(
1097            five.get("ends_at")
1098                .and_then(|d| d.clone().try_cast::<String>()),
1099            Some(ends_at.to_rfc3339()),
1100        );
1101        let token_map: Map = five.get("tokens").unwrap().clone().try_cast().unwrap();
1102        assert_eq!(
1103            token_map
1104                .get("total")
1105                .and_then(|d| d.clone().try_cast::<i64>()),
1106            Some(420_000),
1107        );
1108        let seven: Map = payload
1109            .get("seven_day")
1110            .unwrap()
1111            .clone()
1112            .try_cast()
1113            .unwrap();
1114        let seven_tokens: Map = seven.get("tokens").unwrap().clone().try_cast().unwrap();
1115        assert_eq!(
1116            seven_tokens
1117                .get("input")
1118                .and_then(|d| d.clone().try_cast::<i64>()),
1119            Some(1_000_000),
1120        );
1121        // Every token category must round-trip so plugin scripts that
1122        // read `tokens.cache_read` etc. don't silently get `()`.
1123        for key in ["output", "cache_creation", "cache_read"] {
1124            assert!(
1125                seven_tokens.contains_key(key),
1126                "expected tokens.{key} on jsonl mirror",
1127            );
1128        }
1129        // `unknown_buckets` is Endpoint-only per ADR-0013. A plugin
1130        // that reads `ctx.usage.data.unknown_buckets` unconditionally
1131        // against JSONL data must see it missing, not an empty map.
1132        assert!(
1133            !payload.contains_key("unknown_buckets"),
1134            "jsonl variant must not expose unknown_buckets",
1135        );
1136    }
1137
1138    #[test]
1139    fn usage_jsonl_variant_with_no_active_block_exposes_unit_five_hour() {
1140        // JSONL with `five_hour: None` (inactive block) must mirror as
1141        // `()` so rhai scripts can short-circuit with `if ctx.usage.data.five_hour != () { ... }`.
1142        use crate::data_context::{JsonlUsage, SevenDayWindow, TokenCounts, UsageData};
1143        let data = UsageData::Jsonl(JsonlUsage::new(
1144            None,
1145            SevenDayWindow::new(TokenCounts::default()),
1146        ));
1147        let dc = DataContext::new(minimal_status());
1148        dc.preseed_usage(Ok(data)).expect("seed");
1149        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1150        let payload: Map = ctx
1151            .get("usage")
1152            .unwrap()
1153            .clone()
1154            .try_cast::<Map>()
1155            .unwrap()
1156            .get("data")
1157            .unwrap()
1158            .clone()
1159            .try_cast()
1160            .unwrap();
1161        assert!(
1162            payload.get("five_hour").unwrap().is_unit(),
1163            "jsonl five_hour=None must mirror as rhai ()",
1164        );
1165        // seven_day is always present even on an empty transcript.
1166        assert!(!payload.get("seven_day").unwrap().is_unit());
1167    }
1168
1169    #[test]
1170    fn declared_source_shows_up_as_tagged_error_when_stub() {
1171        // Seeded to decouple from host-machine Keychain/network state;
1172        // the real cascade would otherwise hit the OAuth endpoint.
1173        let dc = DataContext::new(minimal_status());
1174        dc.preseed_usage(Err(crate::data_context::UsageError::Jsonl(
1175            crate::data_context::JsonlError::NoEntries,
1176        )))
1177        .expect("seed");
1178        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1179        let usage: Map = ctx
1180            .get("usage")
1181            .expect("usage key")
1182            .clone()
1183            .try_cast()
1184            .expect("usage is a map");
1185        assert_eq!(
1186            usage
1187                .get("kind")
1188                .and_then(|d| d.clone().try_cast::<String>()),
1189            Some("error".to_string())
1190        );
1191        assert_eq!(
1192            usage
1193                .get("error")
1194                .and_then(|d| d.clone().try_cast::<String>()),
1195            Some("NoEntries".to_string())
1196        );
1197    }
1198
1199    #[test]
1200    fn git_dep_maps_ok_none_to_unit_data() {
1201        // "Not in a git repo" surface: kind: "ok", data: () per
1202        // plugin-api.md §Special cases.
1203        let dc = DataContext::new(minimal_status());
1204        dc.preseed_git(Ok(None)).expect("seed");
1205        let ctx = build_and_unwrap_map(&dc, &[DataDep::Git]);
1206        let git: Map = ctx.get("git").unwrap().clone().try_cast().unwrap();
1207        assert_eq!(
1208            git.get("kind").and_then(|d| d.clone().try_cast::<String>()),
1209            Some("ok".to_string())
1210        );
1211        assert!(git.get("data").expect("data present").is_unit());
1212    }
1213
1214    #[test]
1215    fn git_dep_reports_error_variant_when_gix_failed() {
1216        use crate::data_context::GitError;
1217        let dc = DataContext::new(minimal_status());
1218        dc.preseed_git(Err(GitError::CorruptRepo {
1219            path: std::path::PathBuf::from("/tmp/bad"),
1220            message: "synthetic".into(),
1221        }))
1222        .expect("seed");
1223        let ctx = build_and_unwrap_map(&dc, &[DataDep::Git]);
1224        let git: Map = ctx.get("git").unwrap().clone().try_cast().unwrap();
1225        assert_eq!(
1226            git.get("kind").and_then(|d| d.clone().try_cast::<String>()),
1227            Some("error".to_string())
1228        );
1229        assert_eq!(
1230            git.get("error")
1231                .and_then(|d| d.clone().try_cast::<String>()),
1232            Some("CorruptRepo".to_string())
1233        );
1234    }
1235
1236    #[test]
1237    fn git_dep_maps_ok_some_to_populated_map() {
1238        use crate::data_context::{GitContext, Head, RepoKind};
1239        let dc = DataContext::new(minimal_status());
1240        dc.preseed_git(Ok(Some(GitContext::new(
1241            RepoKind::Main,
1242            std::path::PathBuf::from("/repo/.git"),
1243            Head::Branch("feature/auth".into()),
1244        ))))
1245        .expect("seed");
1246        let ctx = build_and_unwrap_map(&dc, &[DataDep::Git]);
1247        let git: Map = ctx.get("git").unwrap().clone().try_cast().unwrap();
1248        assert_eq!(
1249            git.get("kind").and_then(|d| d.clone().try_cast::<String>()),
1250            Some("ok".to_string())
1251        );
1252        let data: Map = git.get("data").unwrap().clone().try_cast().unwrap();
1253        let kind: Map = data.get("repo_kind").unwrap().clone().try_cast().unwrap();
1254        assert_eq!(
1255            kind.get("kind")
1256                .and_then(|d| d.clone().try_cast::<String>()),
1257            Some("main".to_string())
1258        );
1259        let head: Map = data.get("head").unwrap().clone().try_cast().unwrap();
1260        assert_eq!(
1261            head.get("kind")
1262                .and_then(|d| d.clone().try_cast::<String>()),
1263            Some("branch".to_string())
1264        );
1265        assert_eq!(
1266            head.get("name")
1267                .and_then(|d| d.clone().try_cast::<String>()),
1268            Some("feature/auth".to_string())
1269        );
1270    }
1271
1272    #[test]
1273    fn tool_claude_code_has_only_kind() {
1274        let dc = DataContext::new(minimal_status());
1275        let ctx = build_and_unwrap_map(&dc, &[]);
1276        let tool: Map = status_map(&ctx)
1277            .get("tool")
1278            .unwrap()
1279            .clone()
1280            .try_cast()
1281            .unwrap();
1282        assert_eq!(
1283            tool.get("kind")
1284                .and_then(|d| d.clone().try_cast::<String>()),
1285            Some("claude_code".to_string())
1286        );
1287        assert!(!tool.contains_key("name"));
1288    }
1289
1290    #[test]
1291    fn all_tool_variants_map_to_snake_case_kind() {
1292        // `build_tool`'s match is exhaustive, so a new `Tool` variant
1293        // fails to compile. This test pins the snake_case label each
1294        // variant maps to so an accidental label rename still trips.
1295        let cases: &[(Tool, &str)] = &[
1296            (Tool::ClaudeCode, "claude_code"),
1297            (Tool::QwenCode, "qwen_code"),
1298            (Tool::CodexCli, "codex_cli"),
1299            (Tool::CopilotCli, "copilot_cli"),
1300        ];
1301        for (tool, expected) in cases {
1302            let mut s = minimal_status();
1303            s.tool = tool.clone();
1304            let dc = DataContext::new(s);
1305            let ctx = build_and_unwrap_map(&dc, &[]);
1306            let map: Map = status_map(&ctx)
1307                .get("tool")
1308                .unwrap()
1309                .clone()
1310                .try_cast()
1311                .unwrap();
1312            assert_eq!(
1313                map.get("kind").and_then(|d| d.clone().try_cast::<String>()),
1314                Some((*expected).to_string()),
1315                "tool variant {tool:?}",
1316            );
1317            assert!(
1318                !map.contains_key("name"),
1319                "non-Other variant {tool:?} should not carry a name field"
1320            );
1321        }
1322    }
1323
1324    #[test]
1325    fn tool_other_carries_forensic_name() {
1326        let mut status = minimal_status();
1327        status.tool = Tool::Other("gemini".into());
1328        let dc = DataContext::new(status);
1329        let ctx = build_and_unwrap_map(&dc, &[]);
1330        let tool: Map = status_map(&ctx)
1331            .get("tool")
1332            .unwrap()
1333            .clone()
1334            .try_cast()
1335            .unwrap();
1336        assert_eq!(
1337            tool.get("kind")
1338                .and_then(|d| d.clone().try_cast::<String>()),
1339            Some("other".to_string())
1340        );
1341        assert_eq!(
1342            tool.get("name")
1343                .and_then(|d| d.clone().try_cast::<String>()),
1344            Some("gemini".to_string())
1345        );
1346    }
1347
1348    #[test]
1349    fn option_fields_become_unit_when_none() {
1350        let dc = DataContext::new(minimal_status());
1351        let ctx = build_and_unwrap_map(&dc, &[]);
1352        let status = status_map(&ctx);
1353        assert!(status.get("context_window").unwrap().is_unit());
1354        assert!(status.get("cost").unwrap().is_unit());
1355        assert!(status.get("effort").unwrap().is_unit());
1356        assert!(status.get("vim").unwrap().is_unit());
1357        assert!(status.get("output_style").unwrap().is_unit());
1358        assert!(status.get("agent_name").unwrap().is_unit());
1359        assert!(status.get("version").unwrap().is_unit());
1360        assert!(
1361            !status.contains_key("rate_limits"),
1362            "rate_limits is no longer mirrored; plugins read ctx.usage",
1363        );
1364    }
1365
1366    #[test]
1367    fn version_surfaces_as_string_when_present() {
1368        // Plugins gate behavior on Claude Code version (e.g. workaround
1369        // segments that activate only on a specific CC release). The
1370        // built-in path reads `s.version`; this test pins that the
1371        // plugin mirror exposes the same field so the two views stay
1372        // in sync.
1373        let mut s = minimal_status();
1374        s.version = Some("2.1.90".into());
1375        let dc = DataContext::new(s);
1376        let ctx = build_and_unwrap_map(&dc, &[]);
1377        let status = status_map(&ctx);
1378        assert_eq!(
1379            status
1380                .get("version")
1381                .unwrap()
1382                .clone()
1383                .try_cast::<String>()
1384                .unwrap(),
1385            "2.1.90"
1386        );
1387    }
1388
1389    #[test]
1390    fn effort_surfaces_as_snake_case_string() {
1391        let mut s = minimal_status();
1392        s.effort = Some(EffortLevel::XHigh);
1393        let dc = DataContext::new(s);
1394        let ctx = build_and_unwrap_map(&dc, &[]);
1395        let effort = status_map(&ctx)
1396            .get("effort")
1397            .unwrap()
1398            .clone()
1399            .try_cast::<String>()
1400            .unwrap();
1401        assert_eq!(effort, "xhigh");
1402    }
1403
1404    #[test]
1405    fn vim_output_style_agent_name_surface_as_strings_when_present() {
1406        use crate::input::{OutputStyle, VimMode};
1407        let mut s = minimal_status();
1408        s.vim = Some(VimMode::Insert);
1409        s.output_style = Some(OutputStyle {
1410            name: "concise".into(),
1411        });
1412        s.agent_name = Some("research".into());
1413        let dc = DataContext::new(s);
1414        let ctx = build_and_unwrap_map(&dc, &[]);
1415        let status = status_map(&ctx);
1416        assert_eq!(
1417            status
1418                .get("vim")
1419                .unwrap()
1420                .clone()
1421                .try_cast::<String>()
1422                .unwrap(),
1423            "insert"
1424        );
1425        let output_style: Map = status
1426            .get("output_style")
1427            .unwrap()
1428            .clone()
1429            .try_cast()
1430            .unwrap();
1431        assert_eq!(
1432            output_style
1433                .get("name")
1434                .and_then(|d| d.clone().try_cast::<String>()),
1435            Some("concise".to_string())
1436        );
1437        assert_eq!(
1438            status
1439                .get("agent_name")
1440                .unwrap()
1441                .clone()
1442                .try_cast::<String>()
1443                .unwrap(),
1444            "research"
1445        );
1446    }
1447
1448    #[test]
1449    fn each_lazy_dep_surfaces_as_tagged_error_when_stub() {
1450        // Every non-Git lazy source independently builds its tagged
1451        // map. Test all four arms so a copy-paste bug (wrong source,
1452        // wrong key) is caught here rather than in a downstream plugin
1453        // that suddenly sees `()` instead of an error. Expected code
1454        // varies per source — stubs emit "NotImplemented"; Usage is
1455        // seeded with the JSONL sentinel so the test doesn't hit the
1456        // real cascade (Keychain + network) on dev machines.
1457        let cases: &[(DataDep, &str, &str)] = &[
1458            (DataDep::Settings, "settings", "NotImplemented"),
1459            (DataDep::ClaudeJson, "claude_json", "NotImplemented"),
1460            (DataDep::Sessions, "sessions", "NotImplemented"),
1461            (DataDep::Usage, "usage", "NoEntries"),
1462        ];
1463        for (dep, key, expected_code) in cases {
1464            let dc = DataContext::new(minimal_status());
1465            if matches!(dep, DataDep::Usage) {
1466                dc.preseed_usage(Err(crate::data_context::UsageError::Jsonl(
1467                    crate::data_context::JsonlError::NoEntries,
1468                )))
1469                .expect("seed");
1470            }
1471            let ctx = build_and_unwrap_map(&dc, &[*dep]);
1472            let entry: Map = ctx
1473                .get(*key)
1474                .unwrap_or_else(|| panic!("dep {dep:?} should populate `{key}`"))
1475                .clone()
1476                .try_cast()
1477                .expect("source map");
1478            assert_eq!(
1479                entry
1480                    .get("kind")
1481                    .and_then(|d| d.clone().try_cast::<String>()),
1482                Some("error".to_string()),
1483                "dep {dep:?} should surface a tagged error",
1484            );
1485            assert_eq!(
1486                entry
1487                    .get("error")
1488                    .and_then(|d| d.clone().try_cast::<String>()),
1489                Some((*expected_code).to_string()),
1490                "dep {dep:?} expected code {expected_code}",
1491            );
1492        }
1493    }
1494
1495    #[test]
1496    fn context_window_exposes_used_and_remaining_as_floats() {
1497        let mut s = minimal_status();
1498        s.context_window = Some(ContextWindow {
1499            used: Some(Percent::new(42.5).unwrap()),
1500            size: Some(200_000),
1501            total_input_tokens: Some(1_000),
1502            total_output_tokens: Some(2_000),
1503            current_usage: None,
1504        });
1505        let dc = DataContext::new(s);
1506        let ctx = build_and_unwrap_map(&dc, &[]);
1507        let cw: Map = status_map(&ctx)
1508            .get("context_window")
1509            .unwrap()
1510            .clone()
1511            .try_cast()
1512            .unwrap();
1513        assert_eq!(
1514            cw.get("used").unwrap().clone().try_cast::<f64>().unwrap(),
1515            42.5
1516        );
1517        assert_eq!(
1518            cw.get("remaining")
1519                .unwrap()
1520                .clone()
1521                .try_cast::<f64>()
1522                .unwrap(),
1523            57.5
1524        );
1525        assert_eq!(
1526            cw.get("size").unwrap().clone().try_cast::<i64>().unwrap(),
1527            200_000
1528        );
1529        // None current_usage mirrors as rhai `()` so plugins can check
1530        // `if ctx.status.context_window.current_usage != () { ... }`.
1531        assert!(cw.get("current_usage").unwrap().is_unit());
1532    }
1533
1534    #[test]
1535    fn context_window_current_usage_mirrors_all_four_fields() {
1536        let mut s = minimal_status();
1537        s.context_window = Some(ContextWindow {
1538            used: Some(Percent::new(12.4).unwrap()),
1539            size: Some(200_000),
1540            total_input_tokens: Some(24_800),
1541            total_output_tokens: Some(3_200),
1542            current_usage: Some(TurnUsage {
1543                input_tokens: 2_000,
1544                output_tokens: 500,
1545                cache_creation_input_tokens: 0,
1546                cache_read_input_tokens: 500,
1547            }),
1548        });
1549        let dc = DataContext::new(s);
1550        let ctx = build_and_unwrap_map(&dc, &[]);
1551        let usage: Map = status_map(&ctx)
1552            .get("context_window")
1553            .unwrap()
1554            .clone()
1555            .try_cast::<Map>()
1556            .unwrap()
1557            .get("current_usage")
1558            .unwrap()
1559            .clone()
1560            .try_cast()
1561            .unwrap();
1562        assert_eq!(
1563            usage
1564                .get("input_tokens")
1565                .unwrap()
1566                .clone()
1567                .try_cast::<i64>()
1568                .unwrap(),
1569            2_000
1570        );
1571        assert_eq!(
1572            usage
1573                .get("output_tokens")
1574                .unwrap()
1575                .clone()
1576                .try_cast::<i64>()
1577                .unwrap(),
1578            500
1579        );
1580        assert_eq!(
1581            usage
1582                .get("cache_creation_input_tokens")
1583                .unwrap()
1584                .clone()
1585                .try_cast::<i64>()
1586                .unwrap(),
1587            0
1588        );
1589        assert_eq!(
1590            usage
1591                .get("cache_read_input_tokens")
1592                .unwrap()
1593                .clone()
1594                .try_cast::<i64>()
1595                .unwrap(),
1596            500
1597        );
1598    }
1599
1600    #[test]
1601    fn cost_lines_fields_round_trip_as_i64() {
1602        let mut s = minimal_status();
1603        s.cost = Some(CostMetrics {
1604            total_cost_usd: Some(1.23),
1605            total_duration_ms: Some(60_000),
1606            total_api_duration_ms: Some(30_000),
1607            total_lines_added: Some(500),
1608            total_lines_removed: Some(10),
1609        });
1610        let dc = DataContext::new(s);
1611        let ctx = build_and_unwrap_map(&dc, &[]);
1612        let cost: Map = status_map(&ctx)
1613            .get("cost")
1614            .unwrap()
1615            .clone()
1616            .try_cast()
1617            .unwrap();
1618        assert_eq!(
1619            cost.get("total_lines_added")
1620                .unwrap()
1621                .clone()
1622                .try_cast::<i64>()
1623                .unwrap(),
1624            500
1625        );
1626        assert_eq!(
1627            cost.get("total_cost_usd")
1628                .unwrap()
1629                .clone()
1630                .try_cast::<f64>()
1631                .unwrap(),
1632            1.23
1633        );
1634    }
1635
1636    #[test]
1637    fn raw_json_object_round_trips_recursively() {
1638        let raw = serde_json::json!({
1639            "nested": {
1640                "list": [1, "two", true, null],
1641                "flag": false
1642            }
1643        });
1644        let mut s = minimal_status();
1645        s.raw = Arc::new(raw);
1646        let dc = DataContext::new(s);
1647        let ctx = build_and_unwrap_map(&dc, &[]);
1648        let raw_map: Map = status_map(&ctx)
1649            .get("raw")
1650            .unwrap()
1651            .clone()
1652            .try_cast()
1653            .unwrap();
1654        let nested: Map = raw_map.get("nested").unwrap().clone().try_cast().unwrap();
1655        let list: Array = nested.get("list").unwrap().clone().try_cast().unwrap();
1656        assert_eq!(list[0].clone().try_cast::<i64>().unwrap(), 1);
1657        assert_eq!(
1658            list[1].clone().try_cast::<String>().unwrap(),
1659            "two".to_string()
1660        );
1661        assert!(list[2].clone().try_cast::<bool>().unwrap());
1662        assert!(list[3].is_unit());
1663    }
1664
1665    #[test]
1666    fn raw_empty_array_and_object_round_trip() {
1667        let raw = serde_json::json!({ "arr": [], "obj": {} });
1668        let mut s = minimal_status();
1669        s.raw = Arc::new(raw);
1670        let dc = DataContext::new(s);
1671        let ctx = build_and_unwrap_map(&dc, &[]);
1672        let raw_map: Map = status_map(&ctx)
1673            .get("raw")
1674            .unwrap()
1675            .clone()
1676            .try_cast()
1677            .unwrap();
1678        let arr: Array = raw_map.get("arr").unwrap().clone().try_cast().unwrap();
1679        assert!(arr.is_empty());
1680        let obj: Map = raw_map.get("obj").unwrap().clone().try_cast().unwrap();
1681        assert!(obj.is_empty());
1682    }
1683
1684    #[test]
1685    fn unknown_buckets_drop_oversize_keys() {
1686        // Keys longer than MAX_STRING_SIZE are skipped — rhai's engine
1687        // limit is enforced script-side, not on host-built maps, so
1688        // the mirror is the only place a key bomb can be caught.
1689        use crate::data_context::{EndpointUsage, UsageData};
1690        let mut unknown = std::collections::HashMap::new();
1691        let huge_key = "x".repeat(MAX_STRING_SIZE + 1);
1692        unknown.insert(huge_key.clone(), serde_json::Value::Null);
1693        unknown.insert("ok_key".to_string(), serde_json::Value::Bool(true));
1694        let data = UsageData::Endpoint(EndpointUsage {
1695            five_hour: None,
1696            seven_day: None,
1697            seven_day_opus: None,
1698            seven_day_sonnet: None,
1699            seven_day_oauth_apps: None,
1700            extra_usage: None,
1701            unknown_buckets: unknown,
1702        });
1703        let dc = DataContext::new(minimal_status());
1704        dc.preseed_usage(Ok(data)).expect("seed");
1705        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1706        let payload: Map = ctx
1707            .get("usage")
1708            .unwrap()
1709            .clone()
1710            .try_cast::<Map>()
1711            .unwrap()
1712            .get("data")
1713            .unwrap()
1714            .clone()
1715            .try_cast()
1716            .unwrap();
1717        let mirrored: Map = payload
1718            .get("unknown_buckets")
1719            .unwrap()
1720            .clone()
1721            .try_cast()
1722            .unwrap();
1723        assert!(
1724            mirrored.contains_key("ok_key"),
1725            "normal-sized key must survive",
1726        );
1727        assert!(
1728            !mirrored.contains_key(huge_key.as_str()),
1729            "oversize key must be dropped",
1730        );
1731    }
1732
1733    fn build_nested_object_chain(depth_links: usize, leaf: JsonValue) -> JsonValue {
1734        // Wrap a leaf value in `depth_links` layers of `{"nest": ...}`,
1735        // so the root is a Map and the value at depth `depth_links` is
1736        // `leaf`. Caller chooses the exact position relative to the cap.
1737        let mut v = leaf;
1738        for _ in 0..depth_links {
1739            v = serde_json::json!({ "nest": v });
1740        }
1741        v
1742    }
1743
1744    #[test]
1745    fn raw_json_at_exact_max_depth_survives_one_deeper_collapses() {
1746        // Pin the `>=` boundary: the value at depth MAX_JSON_DEPTH - 1
1747        // must still be a real Dynamic, and the value at depth
1748        // MAX_JSON_DEPTH must collapse to `()`. A future refactor to
1749        // `>` would survive the "beyond the cap" test but break this
1750        // one.
1751        let leaf = serde_json::json!({ "leaf": "bottom" });
1752        // Root is depth 0. `build_nested_object_chain(N, leaf)` puts
1753        // `leaf` at depth N. Build exactly MAX_JSON_DEPTH links so the
1754        // deepest Map in the input sits at depth MAX_JSON_DEPTH.
1755        let nested = build_nested_object_chain(MAX_JSON_DEPTH, leaf);
1756        let mut s = minimal_status();
1757        s.raw = Arc::new(nested);
1758        let dc = DataContext::new(s);
1759        let ctx = build_and_unwrap_map(&dc, &[]);
1760        let mut cursor = status_map(&ctx)
1761            .get("raw")
1762            .unwrap()
1763            .clone()
1764            .try_cast::<Map>()
1765            .unwrap();
1766        // Walk MAX_JSON_DEPTH - 1 links. After each `.get("nest")` +
1767        // cast, cursor sits one level deeper. After MAX_JSON_DEPTH - 1
1768        // iterations we are at depth MAX_JSON_DEPTH - 1 (still a Map).
1769        for _ in 0..(MAX_JSON_DEPTH - 1) {
1770            let next = cursor.get("nest").expect("nest key below cap").clone();
1771            cursor = next.try_cast::<Map>().expect("map below cap");
1772        }
1773        // Depth MAX_JSON_DEPTH - 1 is still below the cap: `.get("nest")`
1774        // returns the value at depth MAX_JSON_DEPTH, which is where the
1775        // guard fires.
1776        let capped = cursor.get("nest").expect("nest at cap").clone();
1777        assert!(
1778            capped.is_unit(),
1779            "value at depth MAX_JSON_DEPTH must collapse to ()",
1780        );
1781    }
1782
1783    #[test]
1784    fn raw_json_nested_arrays_beyond_max_depth_collapse_to_unit() {
1785        // The depth guard runs in the Array arm too; pin it with a
1786        // pure-array chain so a refactor that drops the array-side
1787        // increment is caught.
1788        let mut nested = serde_json::json!("leaf");
1789        for _ in 0..(MAX_JSON_DEPTH + 2) {
1790            nested = serde_json::json!([nested]);
1791        }
1792        let mut s = minimal_status();
1793        s.raw = Arc::new(nested);
1794        let dc = DataContext::new(s);
1795        let ctx = build_and_unwrap_map(&dc, &[]);
1796        let mut cursor: Array = status_map(&ctx)
1797            .get("raw")
1798            .unwrap()
1799            .clone()
1800            .try_cast()
1801            .unwrap();
1802        for _ in 0..(MAX_JSON_DEPTH - 1) {
1803            let next = cursor[0].clone();
1804            cursor = next.try_cast::<Array>().expect("array below cap");
1805        }
1806        assert!(
1807            cursor[0].is_unit(),
1808            "array element at depth MAX_JSON_DEPTH must collapse to ()",
1809        );
1810    }
1811
1812    #[test]
1813    fn raw_json_nested_object_preserves_all_entries_as_escape_hatch() {
1814        // `ctx.status.raw` is the documented escape hatch for tool-
1815        // specific fields; breadth caps would silently break plugins
1816        // that read through it. Under EscapeHatch posture, every
1817        // entry of a nested object must round-trip.
1818        let mut obj = serde_json::Map::new();
1819        for i in 0..(MAX_MAP_SIZE + 50) {
1820            obj.insert(format!("key_{i:04}"), serde_json::Value::Bool(true));
1821        }
1822        let expected = obj.len();
1823        let mut s = minimal_status();
1824        s.raw = Arc::new(serde_json::Value::Object(obj));
1825        let dc = DataContext::new(s);
1826        let ctx = build_and_unwrap_map(&dc, &[]);
1827        let raw_map: Map = status_map(&ctx)
1828            .get("raw")
1829            .unwrap()
1830            .clone()
1831            .try_cast()
1832            .unwrap();
1833        assert_eq!(raw_map.len(), expected);
1834    }
1835
1836    #[test]
1837    fn raw_json_nested_array_preserves_all_items_as_escape_hatch() {
1838        // Symmetric with the nested-object EscapeHatch test above:
1839        // array breadth must round-trip on `ctx.status.raw` so plugins
1840        // reading tool-specific lists (e.g. diagnostic traces) see the
1841        // full payload.
1842        let arr: Vec<JsonValue> = (0..(MAX_ARRAY_SIZE + 50))
1843            .map(|i| serde_json::Value::from(i as i64))
1844            .collect();
1845        let expected = arr.len();
1846        let mut s = minimal_status();
1847        s.raw = Arc::new(serde_json::Value::Array(arr));
1848        let dc = DataContext::new(s);
1849        let ctx = build_and_unwrap_map(&dc, &[]);
1850        let raw_arr: Array = status_map(&ctx)
1851            .get("raw")
1852            .unwrap()
1853            .clone()
1854            .try_cast()
1855            .unwrap();
1856        assert_eq!(raw_arr.len(), expected);
1857    }
1858
1859    #[test]
1860    fn raw_json_oversize_string_preserves_full_content_as_escape_hatch() {
1861        // Strings on the raw path must round-trip regardless of size
1862        // — see the `ctx.status.raw` resource-posture note in
1863        // docs/specs/plugin-api.md.
1864        let oversized = "a".repeat(MAX_STRING_SIZE * 2);
1865        let mut s = minimal_status();
1866        s.raw = Arc::new(serde_json::json!({ "big": oversized.clone() }));
1867        let dc = DataContext::new(s);
1868        let ctx = build_and_unwrap_map(&dc, &[]);
1869        let raw_map: Map = status_map(&ctx)
1870            .get("raw")
1871            .unwrap()
1872            .clone()
1873            .try_cast()
1874            .unwrap();
1875        let big = raw_map
1876            .get("big")
1877            .unwrap()
1878            .clone()
1879            .try_cast::<String>()
1880            .unwrap();
1881        assert_eq!(big.len(), oversized.len());
1882    }
1883
1884    #[test]
1885    fn unknown_buckets_value_nested_object_truncates_under_strict() {
1886        // Strict posture (bucket values): nested objects are breadth-
1887        // capped. Server-controlled data has no escape-hatch exemption.
1888        use crate::data_context::{EndpointUsage, UsageData};
1889        let mut obj = serde_json::Map::new();
1890        for i in 0..(MAX_MAP_SIZE + 50) {
1891            obj.insert(format!("key_{i:04}"), serde_json::Value::Bool(true));
1892        }
1893        let mut unknown = std::collections::HashMap::new();
1894        unknown.insert("wide_value".to_string(), serde_json::Value::Object(obj));
1895        let data = UsageData::Endpoint(EndpointUsage {
1896            five_hour: None,
1897            seven_day: None,
1898            seven_day_opus: None,
1899            seven_day_sonnet: None,
1900            seven_day_oauth_apps: None,
1901            extra_usage: None,
1902            unknown_buckets: unknown,
1903        });
1904        let dc = DataContext::new(minimal_status());
1905        dc.preseed_usage(Ok(data)).expect("seed");
1906        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1907        let value: Map = ctx
1908            .get("usage")
1909            .unwrap()
1910            .clone()
1911            .try_cast::<Map>()
1912            .unwrap()
1913            .get("data")
1914            .unwrap()
1915            .clone()
1916            .try_cast::<Map>()
1917            .unwrap()
1918            .get("unknown_buckets")
1919            .unwrap()
1920            .clone()
1921            .try_cast::<Map>()
1922            .unwrap()
1923            .get("wide_value")
1924            .unwrap()
1925            .clone()
1926            .try_cast()
1927            .unwrap();
1928        assert_eq!(value.len(), MAX_MAP_SIZE);
1929    }
1930
1931    #[test]
1932    fn unknown_buckets_value_nested_array_truncates_under_strict() {
1933        use crate::data_context::{EndpointUsage, UsageData};
1934        let arr: Vec<JsonValue> = (0..(MAX_ARRAY_SIZE + 50))
1935            .map(|i| serde_json::Value::from(i as i64))
1936            .collect();
1937        let mut unknown = std::collections::HashMap::new();
1938        unknown.insert("wide_array".to_string(), serde_json::Value::Array(arr));
1939        let data = UsageData::Endpoint(EndpointUsage {
1940            five_hour: None,
1941            seven_day: None,
1942            seven_day_opus: None,
1943            seven_day_sonnet: None,
1944            seven_day_oauth_apps: None,
1945            extra_usage: None,
1946            unknown_buckets: unknown,
1947        });
1948        let dc = DataContext::new(minimal_status());
1949        dc.preseed_usage(Ok(data)).expect("seed");
1950        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1951        let value: Array = ctx
1952            .get("usage")
1953            .unwrap()
1954            .clone()
1955            .try_cast::<Map>()
1956            .unwrap()
1957            .get("data")
1958            .unwrap()
1959            .clone()
1960            .try_cast::<Map>()
1961            .unwrap()
1962            .get("unknown_buckets")
1963            .unwrap()
1964            .clone()
1965            .try_cast::<Map>()
1966            .unwrap()
1967            .get("wide_array")
1968            .unwrap()
1969            .clone()
1970            .try_cast()
1971            .unwrap();
1972        assert_eq!(value.len(), MAX_ARRAY_SIZE);
1973    }
1974
1975    #[test]
1976    fn unknown_buckets_value_oversize_string_is_truncated_under_strict() {
1977        use crate::data_context::{EndpointUsage, UsageData};
1978        let oversized = "a".repeat(MAX_STRING_SIZE * 2);
1979        let mut unknown = std::collections::HashMap::new();
1980        unknown.insert(
1981            "big".to_string(),
1982            serde_json::Value::String(oversized.clone()),
1983        );
1984        let data = UsageData::Endpoint(EndpointUsage {
1985            five_hour: None,
1986            seven_day: None,
1987            seven_day_opus: None,
1988            seven_day_sonnet: None,
1989            seven_day_oauth_apps: None,
1990            extra_usage: None,
1991            unknown_buckets: unknown,
1992        });
1993        let dc = DataContext::new(minimal_status());
1994        dc.preseed_usage(Ok(data)).expect("seed");
1995        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1996        let big: String = ctx
1997            .get("usage")
1998            .unwrap()
1999            .clone()
2000            .try_cast::<Map>()
2001            .unwrap()
2002            .get("data")
2003            .unwrap()
2004            .clone()
2005            .try_cast::<Map>()
2006            .unwrap()
2007            .get("unknown_buckets")
2008            .unwrap()
2009            .clone()
2010            .try_cast::<Map>()
2011            .unwrap()
2012            .get("big")
2013            .unwrap()
2014            .clone()
2015            .try_cast()
2016            .unwrap();
2017        assert_eq!(big.len(), MAX_STRING_SIZE);
2018    }
2019
2020    #[test]
2021    fn unknown_buckets_value_multibyte_string_truncates_at_utf8_boundary() {
2022        // 3-byte char ('€' = 0xE2 0x82 0xAC). MAX_STRING_SIZE = 1024
2023        // does not align with 3 bytes (1024 = 341*3 + 1), so a byte-
2024        // index-1024 truncation lands mid-codepoint. `truncate_utf8`
2025        // must back up to the nearest char boundary — byte index 1023,
2026        // which retains 341 full characters. A `s[..max_bytes]` naïve
2027        // slice would panic; `from_utf8_lossy` would contaminate with
2028        // replacement chars.
2029        use crate::data_context::{EndpointUsage, UsageData};
2030        let char_bytes = "€".len();
2031        let char_count = MAX_STRING_SIZE / char_bytes + 1;
2032        let oversized: String = "€".repeat(char_count);
2033        let mut unknown = std::collections::HashMap::new();
2034        unknown.insert(
2035            "euros".to_string(),
2036            serde_json::Value::String(oversized.clone()),
2037        );
2038        let data = UsageData::Endpoint(EndpointUsage {
2039            five_hour: None,
2040            seven_day: None,
2041            seven_day_opus: None,
2042            seven_day_sonnet: None,
2043            seven_day_oauth_apps: None,
2044            extra_usage: None,
2045            unknown_buckets: unknown,
2046        });
2047        let dc = DataContext::new(minimal_status());
2048        dc.preseed_usage(Ok(data)).expect("seed");
2049        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2050        let euros: String = ctx
2051            .get("usage")
2052            .unwrap()
2053            .clone()
2054            .try_cast::<Map>()
2055            .unwrap()
2056            .get("data")
2057            .unwrap()
2058            .clone()
2059            .try_cast::<Map>()
2060            .unwrap()
2061            .get("unknown_buckets")
2062            .unwrap()
2063            .clone()
2064            .try_cast::<Map>()
2065            .unwrap()
2066            .get("euros")
2067            .unwrap()
2068            .clone()
2069            .try_cast()
2070            .unwrap();
2071        assert!(
2072            euros.len() <= MAX_STRING_SIZE,
2073            "truncation must not exceed MAX_STRING_SIZE",
2074        );
2075        assert!(
2076            euros.chars().all(|c| c == '€'),
2077            "truncation must land on a UTF-8 char boundary",
2078        );
2079        assert_eq!(
2080            euros.len() % char_bytes,
2081            0,
2082            "byte length divisible by char size"
2083        );
2084    }
2085
2086    #[test]
2087    fn unknown_buckets_at_exact_max_map_size_are_not_truncated() {
2088        // Pin the `>=` off-by-one: exactly MAX_MAP_SIZE entries must
2089        // all survive. One fewer than this count was the bug vector
2090        // for the upstream `>` vs `>=` corner.
2091        use crate::data_context::{EndpointUsage, UsageData};
2092        let mut unknown = std::collections::HashMap::new();
2093        for i in 0..MAX_MAP_SIZE {
2094            unknown.insert(format!("bucket_{i:04}"), serde_json::Value::Null);
2095        }
2096        let data = UsageData::Endpoint(EndpointUsage {
2097            five_hour: None,
2098            seven_day: None,
2099            seven_day_opus: None,
2100            seven_day_sonnet: None,
2101            seven_day_oauth_apps: None,
2102            extra_usage: None,
2103            unknown_buckets: unknown,
2104        });
2105        let dc = DataContext::new(minimal_status());
2106        dc.preseed_usage(Ok(data)).expect("seed");
2107        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2108        let mirrored: Map = ctx
2109            .get("usage")
2110            .unwrap()
2111            .clone()
2112            .try_cast::<Map>()
2113            .unwrap()
2114            .get("data")
2115            .unwrap()
2116            .clone()
2117            .try_cast::<Map>()
2118            .unwrap()
2119            .get("unknown_buckets")
2120            .unwrap()
2121            .clone()
2122            .try_cast()
2123            .unwrap();
2124        assert_eq!(mirrored.len(), MAX_MAP_SIZE);
2125    }
2126
2127    #[test]
2128    fn unknown_buckets_key_at_exact_max_string_size_survives() {
2129        // The key check uses `>`, so a key of exactly MAX_STRING_SIZE
2130        // must pass through. This pins that boundary against a future
2131        // `>=` refactor.
2132        use crate::data_context::{EndpointUsage, UsageData};
2133        let boundary_key = "x".repeat(MAX_STRING_SIZE);
2134        let mut unknown = std::collections::HashMap::new();
2135        unknown.insert(boundary_key.clone(), serde_json::Value::Bool(true));
2136        let data = UsageData::Endpoint(EndpointUsage {
2137            five_hour: None,
2138            seven_day: None,
2139            seven_day_opus: None,
2140            seven_day_sonnet: None,
2141            seven_day_oauth_apps: None,
2142            extra_usage: None,
2143            unknown_buckets: unknown,
2144        });
2145        let dc = DataContext::new(minimal_status());
2146        dc.preseed_usage(Ok(data)).expect("seed");
2147        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2148        let mirrored: Map = ctx
2149            .get("usage")
2150            .unwrap()
2151            .clone()
2152            .try_cast::<Map>()
2153            .unwrap()
2154            .get("data")
2155            .unwrap()
2156            .clone()
2157            .try_cast::<Map>()
2158            .unwrap()
2159            .get("unknown_buckets")
2160            .unwrap()
2161            .clone()
2162            .try_cast()
2163            .unwrap();
2164        assert!(mirrored.contains_key(boundary_key.as_str()));
2165    }
2166
2167    #[test]
2168    fn unknown_buckets_truncation_survives_deterministically() {
2169        // HashMap iteration is process-randomized; without the sort in
2170        // `build_unknown_buckets` the survivor set would rotate across
2171        // renders and plugins would see specific buckets flicker in
2172        // and out. This test freezes the expected survivor set.
2173        use crate::data_context::{EndpointUsage, UsageData};
2174        let mut unknown = std::collections::HashMap::new();
2175        for i in 0..(MAX_MAP_SIZE + 10) {
2176            unknown.insert(format!("bucket_{i:04}"), serde_json::Value::Null);
2177        }
2178        let data = UsageData::Endpoint(EndpointUsage {
2179            five_hour: None,
2180            seven_day: None,
2181            seven_day_opus: None,
2182            seven_day_sonnet: None,
2183            seven_day_oauth_apps: None,
2184            extra_usage: None,
2185            unknown_buckets: unknown,
2186        });
2187        let dc = DataContext::new(minimal_status());
2188        dc.preseed_usage(Ok(data)).expect("seed");
2189        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2190        let mirrored: Map = ctx
2191            .get("usage")
2192            .unwrap()
2193            .clone()
2194            .try_cast::<Map>()
2195            .unwrap()
2196            .get("data")
2197            .unwrap()
2198            .clone()
2199            .try_cast::<Map>()
2200            .unwrap()
2201            .get("unknown_buckets")
2202            .unwrap()
2203            .clone()
2204            .try_cast()
2205            .unwrap();
2206        // Sorted lexicographically; MAX_MAP_SIZE survivors are the
2207        // first MAX_MAP_SIZE lex-ordered keys.
2208        for i in 0..MAX_MAP_SIZE {
2209            let key = format!("bucket_{i:04}");
2210            assert!(
2211                mirrored.contains_key(key.as_str()),
2212                "deterministic-sort survivor {key} missing",
2213            );
2214        }
2215        for i in MAX_MAP_SIZE..(MAX_MAP_SIZE + 10) {
2216            let key = format!("bucket_{i:04}");
2217            assert!(
2218                !mirrored.contains_key(key.as_str()),
2219                "lex-larger key {key} should have been truncated",
2220            );
2221        }
2222    }
2223
2224    #[test]
2225    fn unknown_buckets_value_depth_resets_per_entry() {
2226        // Each bucket value gets its own MAX_JSON_DEPTH budget — the
2227        // walk is called with depth=0 per value so siblings don't
2228        // share recursion budget. A bucket value nested to exactly
2229        // MAX_JSON_DEPTH - 1 levels must fully survive.
2230        use crate::data_context::{EndpointUsage, UsageData};
2231        let leaf = serde_json::json!("leaf");
2232        // Place `leaf` at exactly depth MAX_JSON_DEPTH - 1 so the
2233        // walk's depth counter reaches MAX_JSON_DEPTH - 1 on the leaf
2234        // and does not fire the guard.
2235        let deep_value = build_nested_object_chain(MAX_JSON_DEPTH - 1, leaf);
2236        let mut unknown = std::collections::HashMap::new();
2237        unknown.insert("deep".to_string(), deep_value);
2238        let data = UsageData::Endpoint(EndpointUsage {
2239            five_hour: None,
2240            seven_day: None,
2241            seven_day_opus: None,
2242            seven_day_sonnet: None,
2243            seven_day_oauth_apps: None,
2244            extra_usage: None,
2245            unknown_buckets: unknown,
2246        });
2247        let dc = DataContext::new(minimal_status());
2248        dc.preseed_usage(Ok(data)).expect("seed");
2249        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2250        let mirrored: Map = ctx
2251            .get("usage")
2252            .unwrap()
2253            .clone()
2254            .try_cast::<Map>()
2255            .unwrap()
2256            .get("data")
2257            .unwrap()
2258            .clone()
2259            .try_cast::<Map>()
2260            .unwrap()
2261            .get("unknown_buckets")
2262            .unwrap()
2263            .clone()
2264            .try_cast()
2265            .unwrap();
2266        let mut cursor: Map = mirrored.get("deep").unwrap().clone().try_cast().unwrap();
2267        for _ in 0..(MAX_JSON_DEPTH - 2) {
2268            let next = cursor.get("nest").expect("nest key").clone();
2269            cursor = next.try_cast::<Map>().expect("map below cap");
2270        }
2271        let leaf_value = cursor.get("nest").expect("leaf node").clone();
2272        assert_eq!(
2273            leaf_value.try_cast::<String>().as_deref(),
2274            Some("leaf"),
2275            "value nested to depth MAX_JSON_DEPTH - 1 must fully survive because each bucket gets its own depth budget",
2276        );
2277    }
2278
2279    #[test]
2280    fn raw_u64_above_i64_max_falls_through_to_f64() {
2281        // serde_json::Number can hold u64 > i64::MAX; rhai has no
2282        // native u64 so we round-trip via f64 (some precision loss
2283        // expected for the very large values, but no panic / drop).
2284        let raw = serde_json::json!({ "huge": u64::MAX });
2285        let mut s = minimal_status();
2286        s.raw = Arc::new(raw);
2287        let dc = DataContext::new(s);
2288        let ctx = build_and_unwrap_map(&dc, &[]);
2289        let raw_map: Map = status_map(&ctx)
2290            .get("raw")
2291            .unwrap()
2292            .clone()
2293            .try_cast()
2294            .unwrap();
2295        let huge = raw_map.get("huge").unwrap().clone().try_cast::<f64>();
2296        assert!(huge.is_some(), "u64 > i64::MAX must surface as a number");
2297    }
2298
2299    #[test]
2300    fn env_whitelist_keys_present_even_when_env_is_empty() {
2301        let ctx = build_env_map(ENV_WHITELIST, |_| None)
2302            .try_cast::<Map>()
2303            .unwrap();
2304        for key in ENV_WHITELIST {
2305            assert!(ctx.contains_key(*key), "{key} should be present as ()");
2306            assert!(ctx.get(*key).unwrap().is_unit());
2307        }
2308    }
2309
2310    #[test]
2311    fn env_non_whitelisted_key_absent() {
2312        let ctx = build_env_map(ENV_WHITELIST, |k| match k {
2313            "TERM" => Some("xterm".to_string()),
2314            _ => None,
2315        })
2316        .try_cast::<Map>()
2317        .unwrap();
2318        assert_eq!(
2319            ctx.get("TERM")
2320                .unwrap()
2321                .clone()
2322                .try_cast::<String>()
2323                .unwrap(),
2324            "xterm"
2325        );
2326        assert!(!ctx.contains_key("HOME"));
2327        assert!(!ctx.contains_key("PATH"));
2328    }
2329
2330    #[test]
2331    fn workspace_without_worktree_emits_unit() {
2332        let dc = DataContext::new(minimal_status());
2333        let ctx = build_and_unwrap_map(&dc, &[]);
2334        let ws: Map = status_map(&ctx)
2335            .get("workspace")
2336            .unwrap()
2337            .clone()
2338            .try_cast()
2339            .unwrap();
2340        assert!(ws.get("git_worktree").unwrap().is_unit());
2341    }
2342
2343    #[test]
2344    fn workspace_worktree_preserves_name_and_path() {
2345        let mut s = minimal_status();
2346        s.workspace.as_mut().expect("workspace").git_worktree = Some(GitWorktree {
2347            name: "feature".to_string(),
2348            path: PathBuf::from("/wt/feature"),
2349        });
2350        let dc = DataContext::new(s);
2351        let ctx = build_and_unwrap_map(&dc, &[]);
2352        let wt: Map = status_map(&ctx)
2353            .get("workspace")
2354            .unwrap()
2355            .clone()
2356            .try_cast::<Map>()
2357            .unwrap()
2358            .get("git_worktree")
2359            .unwrap()
2360            .clone()
2361            .try_cast()
2362            .unwrap();
2363        assert_eq!(
2364            wt.get("name")
2365                .unwrap()
2366                .clone()
2367                .try_cast::<String>()
2368                .unwrap(),
2369            "feature"
2370        );
2371        assert_eq!(
2372            wt.get("path")
2373                .unwrap()
2374                .clone()
2375                .try_cast::<String>()
2376                .unwrap(),
2377            "/wt/feature"
2378        );
2379    }
2380
2381    #[test]
2382    fn config_is_passed_through_as_provided() {
2383        let dc = DataContext::new(minimal_status());
2384        let mut config_map = Map::new();
2385        config_map.insert("threshold".into(), Dynamic::from(42_i64));
2386        let rc = RenderContext::new(80);
2387        let ctx: Map = build_ctx(&dc, &rc, &[], Dynamic::from_map(config_map))
2388            .try_cast()
2389            .unwrap();
2390        let config: Map = ctx.get("config").unwrap().clone().try_cast().unwrap();
2391        assert_eq!(
2392            config
2393                .get("threshold")
2394                .unwrap()
2395                .clone()
2396                .try_cast::<i64>()
2397                .unwrap(),
2398            42
2399        );
2400    }
2401}