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 linesmith_plugin::engine::{MAX_ARRAY_SIZE, MAX_EXPR_DEPTH, MAX_MAP_SIZE, MAX_STRING_SIZE};
18use rhai::{Array, Dynamic, Map};
19use serde_json::Value as JsonValue;
20
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_string()));
486    m.insert("ends_at".into(), Dynamic::from(w.ends_at().to_string()));
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_string())),
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 jiff::civil;
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(
952                    civil::date(2099, 1, 1)
953                        .at(0, 0, 0, 0)
954                        .in_tz("UTC")
955                        .unwrap()
956                        .timestamp(),
957                ),
958            }),
959            seven_day: Some(UsageBucket {
960                utilization: Percent::new(33.0).unwrap(),
961                resets_at: None,
962            }),
963            seven_day_opus: None,
964            seven_day_sonnet: None,
965            seven_day_oauth_apps: None,
966            extra_usage: Some(ExtraUsage {
967                is_enabled: Some(true),
968                utilization: Some(Percent::new(17.5).unwrap()),
969                monthly_limit: Some(100.0),
970                used_credits: Some(40.0),
971                currency: Some("EUR".into()),
972            }),
973            unknown_buckets,
974        });
975
976        let dc = DataContext::new(minimal_status());
977        dc.preseed_usage(Ok(data)).expect("seed");
978        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
979
980        let wrapper: Map = ctx
981            .get("usage")
982            .expect("usage key")
983            .clone()
984            .try_cast()
985            .expect("usage is a map");
986        assert_eq!(
987            wrapper
988                .get("kind")
989                .and_then(|d| d.clone().try_cast::<String>()),
990            Some("ok".to_string()),
991        );
992        let payload: Map = wrapper
993            .get("data")
994            .expect("data payload")
995            .clone()
996            .try_cast()
997            .expect("data is a map");
998
999        assert_eq!(
1000            payload
1001                .get("kind")
1002                .and_then(|d| d.clone().try_cast::<String>()),
1003            Some("endpoint".to_string()),
1004        );
1005        let five: Map = payload
1006            .get("five_hour")
1007            .unwrap()
1008            .clone()
1009            .try_cast()
1010            .unwrap();
1011        assert_eq!(
1012            five.get("utilization")
1013                .and_then(|d| d.clone().try_cast::<f64>()),
1014            Some(42.0),
1015        );
1016        assert!(five.get("resets_at").unwrap().is_string());
1017        let seven: Map = payload
1018            .get("seven_day")
1019            .unwrap()
1020            .clone()
1021            .try_cast()
1022            .unwrap();
1023        assert!(seven.get("resets_at").unwrap().is_unit());
1024        assert!(payload.get("seven_day_opus").unwrap().is_unit());
1025        let extra: Map = payload
1026            .get("extra_usage")
1027            .unwrap()
1028            .clone()
1029            .try_cast()
1030            .unwrap();
1031        assert_eq!(
1032            extra
1033                .get("is_enabled")
1034                .and_then(|d| d.clone().try_cast::<bool>()),
1035            Some(true),
1036        );
1037        assert_eq!(
1038            extra
1039                .get("monthly_limit")
1040                .and_then(|d| d.clone().try_cast::<f64>()),
1041            Some(100.0),
1042        );
1043        assert_eq!(
1044            extra
1045                .get("currency")
1046                .and_then(|d| d.clone().try_cast::<String>()),
1047            Some("EUR".to_string()),
1048        );
1049        let unknown: Map = payload
1050            .get("unknown_buckets")
1051            .expect("unknown_buckets present")
1052            .clone()
1053            .try_cast()
1054            .unwrap();
1055        assert!(unknown.contains_key("iguana_necktie"));
1056    }
1057
1058    #[test]
1059    fn usage_jsonl_variant_mirrors_tokens_and_ends_at() {
1060        // ADR-0013 + plugin-api.md §ctx shape: jsonl variant exposes
1061        // `kind: "jsonl"` with raw token counts and the 5h window's
1062        // `ends_at`. Plugins read this to render the JSONL fallback
1063        // — changes must break this test.
1064        use crate::data_context::{
1065            FiveHourWindow, JsonlUsage, SevenDayWindow, TokenCounts, UsageData,
1066        };
1067        use jiff::civil;
1068        let tokens = TokenCounts::from_parts(400_000, 20_000, 0, 0);
1069        // `start + 5h` = ends_at; encode as `start` per the invariant.
1070        let start = civil::date(2099, 1, 1)
1071            .at(0, 0, 0, 0)
1072            .in_tz("UTC")
1073            .unwrap()
1074            .timestamp();
1075        let ends_at = civil::date(2099, 1, 1)
1076            .at(5, 0, 0, 0)
1077            .in_tz("UTC")
1078            .unwrap()
1079            .timestamp();
1080        let data = UsageData::Jsonl(JsonlUsage::new(
1081            Some(FiveHourWindow::new(tokens, start)),
1082            SevenDayWindow::new(TokenCounts::from_parts(1_000_000, 0, 0, 0)),
1083        ));
1084        let dc = DataContext::new(minimal_status());
1085        dc.preseed_usage(Ok(data)).expect("seed");
1086        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1087        let payload: Map = ctx
1088            .get("usage")
1089            .unwrap()
1090            .clone()
1091            .try_cast::<Map>()
1092            .unwrap()
1093            .get("data")
1094            .unwrap()
1095            .clone()
1096            .try_cast()
1097            .unwrap();
1098        assert_eq!(
1099            payload
1100                .get("kind")
1101                .and_then(|d| d.clone().try_cast::<String>()),
1102            Some("jsonl".to_string()),
1103        );
1104        let five: Map = payload
1105            .get("five_hour")
1106            .unwrap()
1107            .clone()
1108            .try_cast()
1109            .unwrap();
1110        assert_eq!(
1111            five.get("ends_at")
1112                .and_then(|d| d.clone().try_cast::<String>()),
1113            Some(ends_at.to_string()),
1114        );
1115        let token_map: Map = five.get("tokens").unwrap().clone().try_cast().unwrap();
1116        assert_eq!(
1117            token_map
1118                .get("total")
1119                .and_then(|d| d.clone().try_cast::<i64>()),
1120            Some(420_000),
1121        );
1122        let seven: Map = payload
1123            .get("seven_day")
1124            .unwrap()
1125            .clone()
1126            .try_cast()
1127            .unwrap();
1128        let seven_tokens: Map = seven.get("tokens").unwrap().clone().try_cast().unwrap();
1129        assert_eq!(
1130            seven_tokens
1131                .get("input")
1132                .and_then(|d| d.clone().try_cast::<i64>()),
1133            Some(1_000_000),
1134        );
1135        // Every token category must round-trip so plugin scripts that
1136        // read `tokens.cache_read` etc. don't silently get `()`.
1137        for key in ["output", "cache_creation", "cache_read"] {
1138            assert!(
1139                seven_tokens.contains_key(key),
1140                "expected tokens.{key} on jsonl mirror",
1141            );
1142        }
1143        // `unknown_buckets` is Endpoint-only per ADR-0013. A plugin
1144        // that reads `ctx.usage.data.unknown_buckets` unconditionally
1145        // against JSONL data must see it missing, not an empty map.
1146        assert!(
1147            !payload.contains_key("unknown_buckets"),
1148            "jsonl variant must not expose unknown_buckets",
1149        );
1150    }
1151
1152    #[test]
1153    fn usage_jsonl_variant_with_no_active_block_exposes_unit_five_hour() {
1154        // JSONL with `five_hour: None` (inactive block) must mirror as
1155        // `()` so rhai scripts can short-circuit with `if ctx.usage.data.five_hour != () { ... }`.
1156        use crate::data_context::{JsonlUsage, SevenDayWindow, TokenCounts, UsageData};
1157        let data = UsageData::Jsonl(JsonlUsage::new(
1158            None,
1159            SevenDayWindow::new(TokenCounts::default()),
1160        ));
1161        let dc = DataContext::new(minimal_status());
1162        dc.preseed_usage(Ok(data)).expect("seed");
1163        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1164        let payload: Map = ctx
1165            .get("usage")
1166            .unwrap()
1167            .clone()
1168            .try_cast::<Map>()
1169            .unwrap()
1170            .get("data")
1171            .unwrap()
1172            .clone()
1173            .try_cast()
1174            .unwrap();
1175        assert!(
1176            payload.get("five_hour").unwrap().is_unit(),
1177            "jsonl five_hour=None must mirror as rhai ()",
1178        );
1179        // seven_day is always present even on an empty transcript.
1180        assert!(!payload.get("seven_day").unwrap().is_unit());
1181    }
1182
1183    #[test]
1184    fn declared_source_shows_up_as_tagged_error_when_stub() {
1185        // Seeded to decouple from host-machine Keychain/network state;
1186        // the real cascade would otherwise hit the OAuth endpoint.
1187        let dc = DataContext::new(minimal_status());
1188        dc.preseed_usage(Err(crate::data_context::UsageError::Jsonl(
1189            crate::data_context::JsonlError::NoEntries,
1190        )))
1191        .expect("seed");
1192        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1193        let usage: Map = ctx
1194            .get("usage")
1195            .expect("usage key")
1196            .clone()
1197            .try_cast()
1198            .expect("usage is a map");
1199        assert_eq!(
1200            usage
1201                .get("kind")
1202                .and_then(|d| d.clone().try_cast::<String>()),
1203            Some("error".to_string())
1204        );
1205        assert_eq!(
1206            usage
1207                .get("error")
1208                .and_then(|d| d.clone().try_cast::<String>()),
1209            Some("NoEntries".to_string())
1210        );
1211    }
1212
1213    #[test]
1214    fn git_dep_maps_ok_none_to_unit_data() {
1215        // "Not in a git repo" surface: kind: "ok", data: () per
1216        // plugin-api.md §Special cases.
1217        let dc = DataContext::new(minimal_status());
1218        dc.preseed_git(Ok(None)).expect("seed");
1219        let ctx = build_and_unwrap_map(&dc, &[DataDep::Git]);
1220        let git: Map = ctx.get("git").unwrap().clone().try_cast().unwrap();
1221        assert_eq!(
1222            git.get("kind").and_then(|d| d.clone().try_cast::<String>()),
1223            Some("ok".to_string())
1224        );
1225        assert!(git.get("data").expect("data present").is_unit());
1226    }
1227
1228    #[test]
1229    fn git_dep_reports_error_variant_when_gix_failed() {
1230        use crate::data_context::GitError;
1231        let dc = DataContext::new(minimal_status());
1232        dc.preseed_git(Err(GitError::CorruptRepo {
1233            path: std::path::PathBuf::from("/tmp/bad"),
1234            message: "synthetic".into(),
1235        }))
1236        .expect("seed");
1237        let ctx = build_and_unwrap_map(&dc, &[DataDep::Git]);
1238        let git: Map = ctx.get("git").unwrap().clone().try_cast().unwrap();
1239        assert_eq!(
1240            git.get("kind").and_then(|d| d.clone().try_cast::<String>()),
1241            Some("error".to_string())
1242        );
1243        assert_eq!(
1244            git.get("error")
1245                .and_then(|d| d.clone().try_cast::<String>()),
1246            Some("CorruptRepo".to_string())
1247        );
1248    }
1249
1250    #[test]
1251    fn git_dep_maps_ok_some_to_populated_map() {
1252        use crate::data_context::{GitContext, Head, RepoKind};
1253        let dc = DataContext::new(minimal_status());
1254        dc.preseed_git(Ok(Some(GitContext::new(
1255            RepoKind::Main,
1256            std::path::PathBuf::from("/repo/.git"),
1257            Head::Branch("feature/auth".into()),
1258        ))))
1259        .expect("seed");
1260        let ctx = build_and_unwrap_map(&dc, &[DataDep::Git]);
1261        let git: Map = ctx.get("git").unwrap().clone().try_cast().unwrap();
1262        assert_eq!(
1263            git.get("kind").and_then(|d| d.clone().try_cast::<String>()),
1264            Some("ok".to_string())
1265        );
1266        let data: Map = git.get("data").unwrap().clone().try_cast().unwrap();
1267        let kind: Map = data.get("repo_kind").unwrap().clone().try_cast().unwrap();
1268        assert_eq!(
1269            kind.get("kind")
1270                .and_then(|d| d.clone().try_cast::<String>()),
1271            Some("main".to_string())
1272        );
1273        let head: Map = data.get("head").unwrap().clone().try_cast().unwrap();
1274        assert_eq!(
1275            head.get("kind")
1276                .and_then(|d| d.clone().try_cast::<String>()),
1277            Some("branch".to_string())
1278        );
1279        assert_eq!(
1280            head.get("name")
1281                .and_then(|d| d.clone().try_cast::<String>()),
1282            Some("feature/auth".to_string())
1283        );
1284    }
1285
1286    #[test]
1287    fn tool_claude_code_has_only_kind() {
1288        let dc = DataContext::new(minimal_status());
1289        let ctx = build_and_unwrap_map(&dc, &[]);
1290        let tool: Map = status_map(&ctx)
1291            .get("tool")
1292            .unwrap()
1293            .clone()
1294            .try_cast()
1295            .unwrap();
1296        assert_eq!(
1297            tool.get("kind")
1298                .and_then(|d| d.clone().try_cast::<String>()),
1299            Some("claude_code".to_string())
1300        );
1301        assert!(!tool.contains_key("name"));
1302    }
1303
1304    #[test]
1305    fn all_tool_variants_map_to_snake_case_kind() {
1306        // `build_tool`'s match is exhaustive, so a new `Tool` variant
1307        // fails to compile. This test pins the snake_case label each
1308        // variant maps to so an accidental label rename still trips.
1309        let cases: &[(Tool, &str)] = &[
1310            (Tool::ClaudeCode, "claude_code"),
1311            (Tool::QwenCode, "qwen_code"),
1312            (Tool::CodexCli, "codex_cli"),
1313            (Tool::CopilotCli, "copilot_cli"),
1314        ];
1315        for (tool, expected) in cases {
1316            let mut s = minimal_status();
1317            s.tool = tool.clone();
1318            let dc = DataContext::new(s);
1319            let ctx = build_and_unwrap_map(&dc, &[]);
1320            let map: Map = status_map(&ctx)
1321                .get("tool")
1322                .unwrap()
1323                .clone()
1324                .try_cast()
1325                .unwrap();
1326            assert_eq!(
1327                map.get("kind").and_then(|d| d.clone().try_cast::<String>()),
1328                Some((*expected).to_string()),
1329                "tool variant {tool:?}",
1330            );
1331            assert!(
1332                !map.contains_key("name"),
1333                "non-Other variant {tool:?} should not carry a name field"
1334            );
1335        }
1336    }
1337
1338    #[test]
1339    fn tool_other_carries_forensic_name() {
1340        let mut status = minimal_status();
1341        status.tool = Tool::Other("gemini".into());
1342        let dc = DataContext::new(status);
1343        let ctx = build_and_unwrap_map(&dc, &[]);
1344        let tool: Map = status_map(&ctx)
1345            .get("tool")
1346            .unwrap()
1347            .clone()
1348            .try_cast()
1349            .unwrap();
1350        assert_eq!(
1351            tool.get("kind")
1352                .and_then(|d| d.clone().try_cast::<String>()),
1353            Some("other".to_string())
1354        );
1355        assert_eq!(
1356            tool.get("name")
1357                .and_then(|d| d.clone().try_cast::<String>()),
1358            Some("gemini".to_string())
1359        );
1360    }
1361
1362    #[test]
1363    fn option_fields_become_unit_when_none() {
1364        let dc = DataContext::new(minimal_status());
1365        let ctx = build_and_unwrap_map(&dc, &[]);
1366        let status = status_map(&ctx);
1367        assert!(status.get("context_window").unwrap().is_unit());
1368        assert!(status.get("cost").unwrap().is_unit());
1369        assert!(status.get("effort").unwrap().is_unit());
1370        assert!(status.get("vim").unwrap().is_unit());
1371        assert!(status.get("output_style").unwrap().is_unit());
1372        assert!(status.get("agent_name").unwrap().is_unit());
1373        assert!(status.get("version").unwrap().is_unit());
1374        assert!(
1375            !status.contains_key("rate_limits"),
1376            "rate_limits is no longer mirrored; plugins read ctx.usage",
1377        );
1378    }
1379
1380    #[test]
1381    fn version_surfaces_as_string_when_present() {
1382        // Plugins gate behavior on Claude Code version (e.g. workaround
1383        // segments that activate only on a specific CC release). The
1384        // built-in path reads `s.version`; this test pins that the
1385        // plugin mirror exposes the same field so the two views stay
1386        // in sync.
1387        let mut s = minimal_status();
1388        s.version = Some("2.1.90".into());
1389        let dc = DataContext::new(s);
1390        let ctx = build_and_unwrap_map(&dc, &[]);
1391        let status = status_map(&ctx);
1392        assert_eq!(
1393            status
1394                .get("version")
1395                .unwrap()
1396                .clone()
1397                .try_cast::<String>()
1398                .unwrap(),
1399            "2.1.90"
1400        );
1401    }
1402
1403    #[test]
1404    fn effort_surfaces_as_snake_case_string() {
1405        let mut s = minimal_status();
1406        s.effort = Some(EffortLevel::XHigh);
1407        let dc = DataContext::new(s);
1408        let ctx = build_and_unwrap_map(&dc, &[]);
1409        let effort = status_map(&ctx)
1410            .get("effort")
1411            .unwrap()
1412            .clone()
1413            .try_cast::<String>()
1414            .unwrap();
1415        assert_eq!(effort, "xhigh");
1416    }
1417
1418    #[test]
1419    fn vim_output_style_agent_name_surface_as_strings_when_present() {
1420        use crate::input::{OutputStyle, VimMode};
1421        let mut s = minimal_status();
1422        s.vim = Some(VimMode::Insert);
1423        s.output_style = Some(OutputStyle {
1424            name: "concise".into(),
1425        });
1426        s.agent_name = Some("research".into());
1427        let dc = DataContext::new(s);
1428        let ctx = build_and_unwrap_map(&dc, &[]);
1429        let status = status_map(&ctx);
1430        assert_eq!(
1431            status
1432                .get("vim")
1433                .unwrap()
1434                .clone()
1435                .try_cast::<String>()
1436                .unwrap(),
1437            "insert"
1438        );
1439        let output_style: Map = status
1440            .get("output_style")
1441            .unwrap()
1442            .clone()
1443            .try_cast()
1444            .unwrap();
1445        assert_eq!(
1446            output_style
1447                .get("name")
1448                .and_then(|d| d.clone().try_cast::<String>()),
1449            Some("concise".to_string())
1450        );
1451        assert_eq!(
1452            status
1453                .get("agent_name")
1454                .unwrap()
1455                .clone()
1456                .try_cast::<String>()
1457                .unwrap(),
1458            "research"
1459        );
1460    }
1461
1462    #[test]
1463    fn each_lazy_dep_surfaces_as_tagged_error_when_stub() {
1464        // Every non-Git lazy source independently builds its tagged
1465        // map. Test all four arms so a copy-paste bug (wrong source,
1466        // wrong key) is caught here rather than in a downstream plugin
1467        // that suddenly sees `()` instead of an error. Expected code
1468        // varies per source — stubs emit "NotImplemented"; Usage is
1469        // seeded with the JSONL sentinel so the test doesn't hit the
1470        // real cascade (Keychain + network) on dev machines.
1471        let cases: &[(DataDep, &str, &str)] = &[
1472            (DataDep::Settings, "settings", "NotImplemented"),
1473            (DataDep::ClaudeJson, "claude_json", "NotImplemented"),
1474            (DataDep::Sessions, "sessions", "NotImplemented"),
1475            (DataDep::Usage, "usage", "NoEntries"),
1476        ];
1477        for (dep, key, expected_code) in cases {
1478            let dc = DataContext::new(minimal_status());
1479            if matches!(dep, DataDep::Usage) {
1480                dc.preseed_usage(Err(crate::data_context::UsageError::Jsonl(
1481                    crate::data_context::JsonlError::NoEntries,
1482                )))
1483                .expect("seed");
1484            }
1485            let ctx = build_and_unwrap_map(&dc, &[*dep]);
1486            let entry: Map = ctx
1487                .get(*key)
1488                .unwrap_or_else(|| panic!("dep {dep:?} should populate `{key}`"))
1489                .clone()
1490                .try_cast()
1491                .expect("source map");
1492            assert_eq!(
1493                entry
1494                    .get("kind")
1495                    .and_then(|d| d.clone().try_cast::<String>()),
1496                Some("error".to_string()),
1497                "dep {dep:?} should surface a tagged error",
1498            );
1499            assert_eq!(
1500                entry
1501                    .get("error")
1502                    .and_then(|d| d.clone().try_cast::<String>()),
1503                Some((*expected_code).to_string()),
1504                "dep {dep:?} expected code {expected_code}",
1505            );
1506        }
1507    }
1508
1509    #[test]
1510    fn context_window_exposes_used_and_remaining_as_floats() {
1511        let mut s = minimal_status();
1512        s.context_window = Some(ContextWindow {
1513            used: Some(Percent::new(42.5).unwrap()),
1514            size: Some(200_000),
1515            total_input_tokens: Some(1_000),
1516            total_output_tokens: Some(2_000),
1517            current_usage: None,
1518        });
1519        let dc = DataContext::new(s);
1520        let ctx = build_and_unwrap_map(&dc, &[]);
1521        let cw: Map = status_map(&ctx)
1522            .get("context_window")
1523            .unwrap()
1524            .clone()
1525            .try_cast()
1526            .unwrap();
1527        assert_eq!(
1528            cw.get("used").unwrap().clone().try_cast::<f64>().unwrap(),
1529            42.5
1530        );
1531        assert_eq!(
1532            cw.get("remaining")
1533                .unwrap()
1534                .clone()
1535                .try_cast::<f64>()
1536                .unwrap(),
1537            57.5
1538        );
1539        assert_eq!(
1540            cw.get("size").unwrap().clone().try_cast::<i64>().unwrap(),
1541            200_000
1542        );
1543        // None current_usage mirrors as rhai `()` so plugins can check
1544        // `if ctx.status.context_window.current_usage != () { ... }`.
1545        assert!(cw.get("current_usage").unwrap().is_unit());
1546    }
1547
1548    #[test]
1549    fn context_window_current_usage_mirrors_all_four_fields() {
1550        let mut s = minimal_status();
1551        s.context_window = Some(ContextWindow {
1552            used: Some(Percent::new(12.4).unwrap()),
1553            size: Some(200_000),
1554            total_input_tokens: Some(24_800),
1555            total_output_tokens: Some(3_200),
1556            current_usage: Some(TurnUsage {
1557                input_tokens: 2_000,
1558                output_tokens: 500,
1559                cache_creation_input_tokens: 0,
1560                cache_read_input_tokens: 500,
1561            }),
1562        });
1563        let dc = DataContext::new(s);
1564        let ctx = build_and_unwrap_map(&dc, &[]);
1565        let usage: Map = status_map(&ctx)
1566            .get("context_window")
1567            .unwrap()
1568            .clone()
1569            .try_cast::<Map>()
1570            .unwrap()
1571            .get("current_usage")
1572            .unwrap()
1573            .clone()
1574            .try_cast()
1575            .unwrap();
1576        assert_eq!(
1577            usage
1578                .get("input_tokens")
1579                .unwrap()
1580                .clone()
1581                .try_cast::<i64>()
1582                .unwrap(),
1583            2_000
1584        );
1585        assert_eq!(
1586            usage
1587                .get("output_tokens")
1588                .unwrap()
1589                .clone()
1590                .try_cast::<i64>()
1591                .unwrap(),
1592            500
1593        );
1594        assert_eq!(
1595            usage
1596                .get("cache_creation_input_tokens")
1597                .unwrap()
1598                .clone()
1599                .try_cast::<i64>()
1600                .unwrap(),
1601            0
1602        );
1603        assert_eq!(
1604            usage
1605                .get("cache_read_input_tokens")
1606                .unwrap()
1607                .clone()
1608                .try_cast::<i64>()
1609                .unwrap(),
1610            500
1611        );
1612    }
1613
1614    #[test]
1615    fn cost_lines_fields_round_trip_as_i64() {
1616        let mut s = minimal_status();
1617        s.cost = Some(CostMetrics {
1618            total_cost_usd: Some(1.23),
1619            total_duration_ms: Some(60_000),
1620            total_api_duration_ms: Some(30_000),
1621            total_lines_added: Some(500),
1622            total_lines_removed: Some(10),
1623        });
1624        let dc = DataContext::new(s);
1625        let ctx = build_and_unwrap_map(&dc, &[]);
1626        let cost: Map = status_map(&ctx)
1627            .get("cost")
1628            .unwrap()
1629            .clone()
1630            .try_cast()
1631            .unwrap();
1632        assert_eq!(
1633            cost.get("total_lines_added")
1634                .unwrap()
1635                .clone()
1636                .try_cast::<i64>()
1637                .unwrap(),
1638            500
1639        );
1640        assert_eq!(
1641            cost.get("total_cost_usd")
1642                .unwrap()
1643                .clone()
1644                .try_cast::<f64>()
1645                .unwrap(),
1646            1.23
1647        );
1648    }
1649
1650    #[test]
1651    fn raw_json_object_round_trips_recursively() {
1652        let raw = serde_json::json!({
1653            "nested": {
1654                "list": [1, "two", true, null],
1655                "flag": false
1656            }
1657        });
1658        let mut s = minimal_status();
1659        s.raw = Arc::new(raw);
1660        let dc = DataContext::new(s);
1661        let ctx = build_and_unwrap_map(&dc, &[]);
1662        let raw_map: Map = status_map(&ctx)
1663            .get("raw")
1664            .unwrap()
1665            .clone()
1666            .try_cast()
1667            .unwrap();
1668        let nested: Map = raw_map.get("nested").unwrap().clone().try_cast().unwrap();
1669        let list: Array = nested.get("list").unwrap().clone().try_cast().unwrap();
1670        assert_eq!(list[0].clone().try_cast::<i64>().unwrap(), 1);
1671        assert_eq!(
1672            list[1].clone().try_cast::<String>().unwrap(),
1673            "two".to_string()
1674        );
1675        assert!(list[2].clone().try_cast::<bool>().unwrap());
1676        assert!(list[3].is_unit());
1677    }
1678
1679    #[test]
1680    fn raw_empty_array_and_object_round_trip() {
1681        let raw = serde_json::json!({ "arr": [], "obj": {} });
1682        let mut s = minimal_status();
1683        s.raw = Arc::new(raw);
1684        let dc = DataContext::new(s);
1685        let ctx = build_and_unwrap_map(&dc, &[]);
1686        let raw_map: Map = status_map(&ctx)
1687            .get("raw")
1688            .unwrap()
1689            .clone()
1690            .try_cast()
1691            .unwrap();
1692        let arr: Array = raw_map.get("arr").unwrap().clone().try_cast().unwrap();
1693        assert!(arr.is_empty());
1694        let obj: Map = raw_map.get("obj").unwrap().clone().try_cast().unwrap();
1695        assert!(obj.is_empty());
1696    }
1697
1698    #[test]
1699    fn unknown_buckets_drop_oversize_keys() {
1700        // Keys longer than MAX_STRING_SIZE are skipped — rhai's engine
1701        // limit is enforced script-side, not on host-built maps, so
1702        // the mirror is the only place a key bomb can be caught.
1703        use crate::data_context::{EndpointUsage, UsageData};
1704        let mut unknown = std::collections::HashMap::new();
1705        let huge_key = "x".repeat(MAX_STRING_SIZE + 1);
1706        unknown.insert(huge_key.clone(), serde_json::Value::Null);
1707        unknown.insert("ok_key".to_string(), serde_json::Value::Bool(true));
1708        let data = UsageData::Endpoint(EndpointUsage {
1709            five_hour: None,
1710            seven_day: None,
1711            seven_day_opus: None,
1712            seven_day_sonnet: None,
1713            seven_day_oauth_apps: None,
1714            extra_usage: None,
1715            unknown_buckets: unknown,
1716        });
1717        let dc = DataContext::new(minimal_status());
1718        dc.preseed_usage(Ok(data)).expect("seed");
1719        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1720        let payload: Map = ctx
1721            .get("usage")
1722            .unwrap()
1723            .clone()
1724            .try_cast::<Map>()
1725            .unwrap()
1726            .get("data")
1727            .unwrap()
1728            .clone()
1729            .try_cast()
1730            .unwrap();
1731        let mirrored: Map = payload
1732            .get("unknown_buckets")
1733            .unwrap()
1734            .clone()
1735            .try_cast()
1736            .unwrap();
1737        assert!(
1738            mirrored.contains_key("ok_key"),
1739            "normal-sized key must survive",
1740        );
1741        assert!(
1742            !mirrored.contains_key(huge_key.as_str()),
1743            "oversize key must be dropped",
1744        );
1745    }
1746
1747    fn build_nested_object_chain(depth_links: usize, leaf: JsonValue) -> JsonValue {
1748        // Wrap a leaf value in `depth_links` layers of `{"nest": ...}`,
1749        // so the root is a Map and the value at depth `depth_links` is
1750        // `leaf`. Caller chooses the exact position relative to the cap.
1751        let mut v = leaf;
1752        for _ in 0..depth_links {
1753            v = serde_json::json!({ "nest": v });
1754        }
1755        v
1756    }
1757
1758    #[test]
1759    fn raw_json_at_exact_max_depth_survives_one_deeper_collapses() {
1760        // Pin the `>=` boundary: the value at depth MAX_JSON_DEPTH - 1
1761        // must still be a real Dynamic, and the value at depth
1762        // MAX_JSON_DEPTH must collapse to `()`. A future refactor to
1763        // `>` would survive the "beyond the cap" test but break this
1764        // one.
1765        let leaf = serde_json::json!({ "leaf": "bottom" });
1766        // Root is depth 0. `build_nested_object_chain(N, leaf)` puts
1767        // `leaf` at depth N. Build exactly MAX_JSON_DEPTH links so the
1768        // deepest Map in the input sits at depth MAX_JSON_DEPTH.
1769        let nested = build_nested_object_chain(MAX_JSON_DEPTH, leaf);
1770        let mut s = minimal_status();
1771        s.raw = Arc::new(nested);
1772        let dc = DataContext::new(s);
1773        let ctx = build_and_unwrap_map(&dc, &[]);
1774        let mut cursor = status_map(&ctx)
1775            .get("raw")
1776            .unwrap()
1777            .clone()
1778            .try_cast::<Map>()
1779            .unwrap();
1780        // Walk MAX_JSON_DEPTH - 1 links. After each `.get("nest")` +
1781        // cast, cursor sits one level deeper. After MAX_JSON_DEPTH - 1
1782        // iterations we are at depth MAX_JSON_DEPTH - 1 (still a Map).
1783        for _ in 0..(MAX_JSON_DEPTH - 1) {
1784            let next = cursor.get("nest").expect("nest key below cap").clone();
1785            cursor = next.try_cast::<Map>().expect("map below cap");
1786        }
1787        // Depth MAX_JSON_DEPTH - 1 is still below the cap: `.get("nest")`
1788        // returns the value at depth MAX_JSON_DEPTH, which is where the
1789        // guard fires.
1790        let capped = cursor.get("nest").expect("nest at cap").clone();
1791        assert!(
1792            capped.is_unit(),
1793            "value at depth MAX_JSON_DEPTH must collapse to ()",
1794        );
1795    }
1796
1797    #[test]
1798    fn raw_json_nested_arrays_beyond_max_depth_collapse_to_unit() {
1799        // The depth guard runs in the Array arm too; pin it with a
1800        // pure-array chain so a refactor that drops the array-side
1801        // increment is caught.
1802        let mut nested = serde_json::json!("leaf");
1803        for _ in 0..(MAX_JSON_DEPTH + 2) {
1804            nested = serde_json::json!([nested]);
1805        }
1806        let mut s = minimal_status();
1807        s.raw = Arc::new(nested);
1808        let dc = DataContext::new(s);
1809        let ctx = build_and_unwrap_map(&dc, &[]);
1810        let mut cursor: Array = status_map(&ctx)
1811            .get("raw")
1812            .unwrap()
1813            .clone()
1814            .try_cast()
1815            .unwrap();
1816        for _ in 0..(MAX_JSON_DEPTH - 1) {
1817            let next = cursor[0].clone();
1818            cursor = next.try_cast::<Array>().expect("array below cap");
1819        }
1820        assert!(
1821            cursor[0].is_unit(),
1822            "array element at depth MAX_JSON_DEPTH must collapse to ()",
1823        );
1824    }
1825
1826    #[test]
1827    fn raw_json_nested_object_preserves_all_entries_as_escape_hatch() {
1828        // `ctx.status.raw` is the documented escape hatch for tool-
1829        // specific fields; breadth caps would silently break plugins
1830        // that read through it. Under EscapeHatch posture, every
1831        // entry of a nested object must round-trip.
1832        let mut obj = serde_json::Map::new();
1833        for i in 0..(MAX_MAP_SIZE + 50) {
1834            obj.insert(format!("key_{i:04}"), serde_json::Value::Bool(true));
1835        }
1836        let expected = obj.len();
1837        let mut s = minimal_status();
1838        s.raw = Arc::new(serde_json::Value::Object(obj));
1839        let dc = DataContext::new(s);
1840        let ctx = build_and_unwrap_map(&dc, &[]);
1841        let raw_map: Map = status_map(&ctx)
1842            .get("raw")
1843            .unwrap()
1844            .clone()
1845            .try_cast()
1846            .unwrap();
1847        assert_eq!(raw_map.len(), expected);
1848    }
1849
1850    #[test]
1851    fn raw_json_nested_array_preserves_all_items_as_escape_hatch() {
1852        // Symmetric with the nested-object EscapeHatch test above:
1853        // array breadth must round-trip on `ctx.status.raw` so plugins
1854        // reading tool-specific lists (e.g. diagnostic traces) see the
1855        // full payload.
1856        let arr: Vec<JsonValue> = (0..(MAX_ARRAY_SIZE + 50))
1857            .map(|i| serde_json::Value::from(i as i64))
1858            .collect();
1859        let expected = arr.len();
1860        let mut s = minimal_status();
1861        s.raw = Arc::new(serde_json::Value::Array(arr));
1862        let dc = DataContext::new(s);
1863        let ctx = build_and_unwrap_map(&dc, &[]);
1864        let raw_arr: Array = status_map(&ctx)
1865            .get("raw")
1866            .unwrap()
1867            .clone()
1868            .try_cast()
1869            .unwrap();
1870        assert_eq!(raw_arr.len(), expected);
1871    }
1872
1873    #[test]
1874    fn raw_json_oversize_string_preserves_full_content_as_escape_hatch() {
1875        // Strings on the raw path must round-trip regardless of size
1876        // — see the `ctx.status.raw` resource-posture note in
1877        // docs/specs/plugin-api.md.
1878        let oversized = "a".repeat(MAX_STRING_SIZE * 2);
1879        let mut s = minimal_status();
1880        s.raw = Arc::new(serde_json::json!({ "big": oversized.clone() }));
1881        let dc = DataContext::new(s);
1882        let ctx = build_and_unwrap_map(&dc, &[]);
1883        let raw_map: Map = status_map(&ctx)
1884            .get("raw")
1885            .unwrap()
1886            .clone()
1887            .try_cast()
1888            .unwrap();
1889        let big = raw_map
1890            .get("big")
1891            .unwrap()
1892            .clone()
1893            .try_cast::<String>()
1894            .unwrap();
1895        assert_eq!(big.len(), oversized.len());
1896    }
1897
1898    #[test]
1899    fn unknown_buckets_value_nested_object_truncates_under_strict() {
1900        // Strict posture (bucket values): nested objects are breadth-
1901        // capped. Server-controlled data has no escape-hatch exemption.
1902        use crate::data_context::{EndpointUsage, UsageData};
1903        let mut obj = serde_json::Map::new();
1904        for i in 0..(MAX_MAP_SIZE + 50) {
1905            obj.insert(format!("key_{i:04}"), serde_json::Value::Bool(true));
1906        }
1907        let mut unknown = std::collections::HashMap::new();
1908        unknown.insert("wide_value".to_string(), serde_json::Value::Object(obj));
1909        let data = UsageData::Endpoint(EndpointUsage {
1910            five_hour: None,
1911            seven_day: None,
1912            seven_day_opus: None,
1913            seven_day_sonnet: None,
1914            seven_day_oauth_apps: None,
1915            extra_usage: None,
1916            unknown_buckets: unknown,
1917        });
1918        let dc = DataContext::new(minimal_status());
1919        dc.preseed_usage(Ok(data)).expect("seed");
1920        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1921        let value: Map = ctx
1922            .get("usage")
1923            .unwrap()
1924            .clone()
1925            .try_cast::<Map>()
1926            .unwrap()
1927            .get("data")
1928            .unwrap()
1929            .clone()
1930            .try_cast::<Map>()
1931            .unwrap()
1932            .get("unknown_buckets")
1933            .unwrap()
1934            .clone()
1935            .try_cast::<Map>()
1936            .unwrap()
1937            .get("wide_value")
1938            .unwrap()
1939            .clone()
1940            .try_cast()
1941            .unwrap();
1942        assert_eq!(value.len(), MAX_MAP_SIZE);
1943    }
1944
1945    #[test]
1946    fn unknown_buckets_value_nested_array_truncates_under_strict() {
1947        use crate::data_context::{EndpointUsage, UsageData};
1948        let arr: Vec<JsonValue> = (0..(MAX_ARRAY_SIZE + 50))
1949            .map(|i| serde_json::Value::from(i as i64))
1950            .collect();
1951        let mut unknown = std::collections::HashMap::new();
1952        unknown.insert("wide_array".to_string(), serde_json::Value::Array(arr));
1953        let data = UsageData::Endpoint(EndpointUsage {
1954            five_hour: None,
1955            seven_day: None,
1956            seven_day_opus: None,
1957            seven_day_sonnet: None,
1958            seven_day_oauth_apps: None,
1959            extra_usage: None,
1960            unknown_buckets: unknown,
1961        });
1962        let dc = DataContext::new(minimal_status());
1963        dc.preseed_usage(Ok(data)).expect("seed");
1964        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
1965        let value: Array = ctx
1966            .get("usage")
1967            .unwrap()
1968            .clone()
1969            .try_cast::<Map>()
1970            .unwrap()
1971            .get("data")
1972            .unwrap()
1973            .clone()
1974            .try_cast::<Map>()
1975            .unwrap()
1976            .get("unknown_buckets")
1977            .unwrap()
1978            .clone()
1979            .try_cast::<Map>()
1980            .unwrap()
1981            .get("wide_array")
1982            .unwrap()
1983            .clone()
1984            .try_cast()
1985            .unwrap();
1986        assert_eq!(value.len(), MAX_ARRAY_SIZE);
1987    }
1988
1989    #[test]
1990    fn unknown_buckets_value_oversize_string_is_truncated_under_strict() {
1991        use crate::data_context::{EndpointUsage, UsageData};
1992        let oversized = "a".repeat(MAX_STRING_SIZE * 2);
1993        let mut unknown = std::collections::HashMap::new();
1994        unknown.insert(
1995            "big".to_string(),
1996            serde_json::Value::String(oversized.clone()),
1997        );
1998        let data = UsageData::Endpoint(EndpointUsage {
1999            five_hour: None,
2000            seven_day: None,
2001            seven_day_opus: None,
2002            seven_day_sonnet: None,
2003            seven_day_oauth_apps: None,
2004            extra_usage: None,
2005            unknown_buckets: unknown,
2006        });
2007        let dc = DataContext::new(minimal_status());
2008        dc.preseed_usage(Ok(data)).expect("seed");
2009        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2010        let big: String = ctx
2011            .get("usage")
2012            .unwrap()
2013            .clone()
2014            .try_cast::<Map>()
2015            .unwrap()
2016            .get("data")
2017            .unwrap()
2018            .clone()
2019            .try_cast::<Map>()
2020            .unwrap()
2021            .get("unknown_buckets")
2022            .unwrap()
2023            .clone()
2024            .try_cast::<Map>()
2025            .unwrap()
2026            .get("big")
2027            .unwrap()
2028            .clone()
2029            .try_cast()
2030            .unwrap();
2031        assert_eq!(big.len(), MAX_STRING_SIZE);
2032    }
2033
2034    #[test]
2035    fn unknown_buckets_value_multibyte_string_truncates_at_utf8_boundary() {
2036        // 3-byte char ('€' = 0xE2 0x82 0xAC). MAX_STRING_SIZE = 1024
2037        // does not align with 3 bytes (1024 = 341*3 + 1), so a byte-
2038        // index-1024 truncation lands mid-codepoint. `truncate_utf8`
2039        // must back up to the nearest char boundary — byte index 1023,
2040        // which retains 341 full characters. A `s[..max_bytes]` naïve
2041        // slice would panic; `from_utf8_lossy` would contaminate with
2042        // replacement chars.
2043        use crate::data_context::{EndpointUsage, UsageData};
2044        let char_bytes = "€".len();
2045        let char_count = MAX_STRING_SIZE / char_bytes + 1;
2046        let oversized: String = "€".repeat(char_count);
2047        let mut unknown = std::collections::HashMap::new();
2048        unknown.insert(
2049            "euros".to_string(),
2050            serde_json::Value::String(oversized.clone()),
2051        );
2052        let data = UsageData::Endpoint(EndpointUsage {
2053            five_hour: None,
2054            seven_day: None,
2055            seven_day_opus: None,
2056            seven_day_sonnet: None,
2057            seven_day_oauth_apps: None,
2058            extra_usage: None,
2059            unknown_buckets: unknown,
2060        });
2061        let dc = DataContext::new(minimal_status());
2062        dc.preseed_usage(Ok(data)).expect("seed");
2063        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2064        let euros: String = ctx
2065            .get("usage")
2066            .unwrap()
2067            .clone()
2068            .try_cast::<Map>()
2069            .unwrap()
2070            .get("data")
2071            .unwrap()
2072            .clone()
2073            .try_cast::<Map>()
2074            .unwrap()
2075            .get("unknown_buckets")
2076            .unwrap()
2077            .clone()
2078            .try_cast::<Map>()
2079            .unwrap()
2080            .get("euros")
2081            .unwrap()
2082            .clone()
2083            .try_cast()
2084            .unwrap();
2085        assert!(
2086            euros.len() <= MAX_STRING_SIZE,
2087            "truncation must not exceed MAX_STRING_SIZE",
2088        );
2089        assert!(
2090            euros.chars().all(|c| c == '€'),
2091            "truncation must land on a UTF-8 char boundary",
2092        );
2093        assert_eq!(
2094            euros.len() % char_bytes,
2095            0,
2096            "byte length divisible by char size"
2097        );
2098    }
2099
2100    #[test]
2101    fn unknown_buckets_at_exact_max_map_size_are_not_truncated() {
2102        // Pin the `>=` off-by-one: exactly MAX_MAP_SIZE entries must
2103        // all survive. One fewer than this count was the bug vector
2104        // for the upstream `>` vs `>=` corner.
2105        use crate::data_context::{EndpointUsage, UsageData};
2106        let mut unknown = std::collections::HashMap::new();
2107        for i in 0..MAX_MAP_SIZE {
2108            unknown.insert(format!("bucket_{i:04}"), serde_json::Value::Null);
2109        }
2110        let data = UsageData::Endpoint(EndpointUsage {
2111            five_hour: None,
2112            seven_day: None,
2113            seven_day_opus: None,
2114            seven_day_sonnet: None,
2115            seven_day_oauth_apps: None,
2116            extra_usage: None,
2117            unknown_buckets: unknown,
2118        });
2119        let dc = DataContext::new(minimal_status());
2120        dc.preseed_usage(Ok(data)).expect("seed");
2121        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2122        let mirrored: Map = ctx
2123            .get("usage")
2124            .unwrap()
2125            .clone()
2126            .try_cast::<Map>()
2127            .unwrap()
2128            .get("data")
2129            .unwrap()
2130            .clone()
2131            .try_cast::<Map>()
2132            .unwrap()
2133            .get("unknown_buckets")
2134            .unwrap()
2135            .clone()
2136            .try_cast()
2137            .unwrap();
2138        assert_eq!(mirrored.len(), MAX_MAP_SIZE);
2139    }
2140
2141    #[test]
2142    fn unknown_buckets_key_at_exact_max_string_size_survives() {
2143        // The key check uses `>`, so a key of exactly MAX_STRING_SIZE
2144        // must pass through. This pins that boundary against a future
2145        // `>=` refactor.
2146        use crate::data_context::{EndpointUsage, UsageData};
2147        let boundary_key = "x".repeat(MAX_STRING_SIZE);
2148        let mut unknown = std::collections::HashMap::new();
2149        unknown.insert(boundary_key.clone(), serde_json::Value::Bool(true));
2150        let data = UsageData::Endpoint(EndpointUsage {
2151            five_hour: None,
2152            seven_day: None,
2153            seven_day_opus: None,
2154            seven_day_sonnet: None,
2155            seven_day_oauth_apps: None,
2156            extra_usage: None,
2157            unknown_buckets: unknown,
2158        });
2159        let dc = DataContext::new(minimal_status());
2160        dc.preseed_usage(Ok(data)).expect("seed");
2161        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2162        let mirrored: Map = ctx
2163            .get("usage")
2164            .unwrap()
2165            .clone()
2166            .try_cast::<Map>()
2167            .unwrap()
2168            .get("data")
2169            .unwrap()
2170            .clone()
2171            .try_cast::<Map>()
2172            .unwrap()
2173            .get("unknown_buckets")
2174            .unwrap()
2175            .clone()
2176            .try_cast()
2177            .unwrap();
2178        assert!(mirrored.contains_key(boundary_key.as_str()));
2179    }
2180
2181    #[test]
2182    fn unknown_buckets_truncation_survives_deterministically() {
2183        // HashMap iteration is process-randomized; without the sort in
2184        // `build_unknown_buckets` the survivor set would rotate across
2185        // renders and plugins would see specific buckets flicker in
2186        // and out. This test freezes the expected survivor set.
2187        use crate::data_context::{EndpointUsage, UsageData};
2188        let mut unknown = std::collections::HashMap::new();
2189        for i in 0..(MAX_MAP_SIZE + 10) {
2190            unknown.insert(format!("bucket_{i:04}"), serde_json::Value::Null);
2191        }
2192        let data = UsageData::Endpoint(EndpointUsage {
2193            five_hour: None,
2194            seven_day: None,
2195            seven_day_opus: None,
2196            seven_day_sonnet: None,
2197            seven_day_oauth_apps: None,
2198            extra_usage: None,
2199            unknown_buckets: unknown,
2200        });
2201        let dc = DataContext::new(minimal_status());
2202        dc.preseed_usage(Ok(data)).expect("seed");
2203        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2204        let mirrored: Map = ctx
2205            .get("usage")
2206            .unwrap()
2207            .clone()
2208            .try_cast::<Map>()
2209            .unwrap()
2210            .get("data")
2211            .unwrap()
2212            .clone()
2213            .try_cast::<Map>()
2214            .unwrap()
2215            .get("unknown_buckets")
2216            .unwrap()
2217            .clone()
2218            .try_cast()
2219            .unwrap();
2220        // Sorted lexicographically; MAX_MAP_SIZE survivors are the
2221        // first MAX_MAP_SIZE lex-ordered keys.
2222        for i in 0..MAX_MAP_SIZE {
2223            let key = format!("bucket_{i:04}");
2224            assert!(
2225                mirrored.contains_key(key.as_str()),
2226                "deterministic-sort survivor {key} missing",
2227            );
2228        }
2229        for i in MAX_MAP_SIZE..(MAX_MAP_SIZE + 10) {
2230            let key = format!("bucket_{i:04}");
2231            assert!(
2232                !mirrored.contains_key(key.as_str()),
2233                "lex-larger key {key} should have been truncated",
2234            );
2235        }
2236    }
2237
2238    #[test]
2239    fn unknown_buckets_value_depth_resets_per_entry() {
2240        // Each bucket value gets its own MAX_JSON_DEPTH budget — the
2241        // walk is called with depth=0 per value so siblings don't
2242        // share recursion budget. A bucket value nested to exactly
2243        // MAX_JSON_DEPTH - 1 levels must fully survive.
2244        use crate::data_context::{EndpointUsage, UsageData};
2245        let leaf = serde_json::json!("leaf");
2246        // Place `leaf` at exactly depth MAX_JSON_DEPTH - 1 so the
2247        // walk's depth counter reaches MAX_JSON_DEPTH - 1 on the leaf
2248        // and does not fire the guard.
2249        let deep_value = build_nested_object_chain(MAX_JSON_DEPTH - 1, leaf);
2250        let mut unknown = std::collections::HashMap::new();
2251        unknown.insert("deep".to_string(), deep_value);
2252        let data = UsageData::Endpoint(EndpointUsage {
2253            five_hour: None,
2254            seven_day: None,
2255            seven_day_opus: None,
2256            seven_day_sonnet: None,
2257            seven_day_oauth_apps: None,
2258            extra_usage: None,
2259            unknown_buckets: unknown,
2260        });
2261        let dc = DataContext::new(minimal_status());
2262        dc.preseed_usage(Ok(data)).expect("seed");
2263        let ctx = build_and_unwrap_map(&dc, &[DataDep::Usage]);
2264        let mirrored: Map = ctx
2265            .get("usage")
2266            .unwrap()
2267            .clone()
2268            .try_cast::<Map>()
2269            .unwrap()
2270            .get("data")
2271            .unwrap()
2272            .clone()
2273            .try_cast::<Map>()
2274            .unwrap()
2275            .get("unknown_buckets")
2276            .unwrap()
2277            .clone()
2278            .try_cast()
2279            .unwrap();
2280        let mut cursor: Map = mirrored.get("deep").unwrap().clone().try_cast().unwrap();
2281        for _ in 0..(MAX_JSON_DEPTH - 2) {
2282            let next = cursor.get("nest").expect("nest key").clone();
2283            cursor = next.try_cast::<Map>().expect("map below cap");
2284        }
2285        let leaf_value = cursor.get("nest").expect("leaf node").clone();
2286        assert_eq!(
2287            leaf_value.try_cast::<String>().as_deref(),
2288            Some("leaf"),
2289            "value nested to depth MAX_JSON_DEPTH - 1 must fully survive because each bucket gets its own depth budget",
2290        );
2291    }
2292
2293    #[test]
2294    fn raw_u64_above_i64_max_falls_through_to_f64() {
2295        // serde_json::Number can hold u64 > i64::MAX; rhai has no
2296        // native u64 so we round-trip via f64 (some precision loss
2297        // expected for the very large values, but no panic / drop).
2298        let raw = serde_json::json!({ "huge": u64::MAX });
2299        let mut s = minimal_status();
2300        s.raw = Arc::new(raw);
2301        let dc = DataContext::new(s);
2302        let ctx = build_and_unwrap_map(&dc, &[]);
2303        let raw_map: Map = status_map(&ctx)
2304            .get("raw")
2305            .unwrap()
2306            .clone()
2307            .try_cast()
2308            .unwrap();
2309        let huge = raw_map.get("huge").unwrap().clone().try_cast::<f64>();
2310        assert!(huge.is_some(), "u64 > i64::MAX must surface as a number");
2311    }
2312
2313    #[test]
2314    fn env_whitelist_keys_present_even_when_env_is_empty() {
2315        let ctx = build_env_map(ENV_WHITELIST, |_| None)
2316            .try_cast::<Map>()
2317            .unwrap();
2318        for key in ENV_WHITELIST {
2319            assert!(ctx.contains_key(*key), "{key} should be present as ()");
2320            assert!(ctx.get(*key).unwrap().is_unit());
2321        }
2322    }
2323
2324    #[test]
2325    fn env_non_whitelisted_key_absent() {
2326        let ctx = build_env_map(ENV_WHITELIST, |k| match k {
2327            "TERM" => Some("xterm".to_string()),
2328            _ => None,
2329        })
2330        .try_cast::<Map>()
2331        .unwrap();
2332        assert_eq!(
2333            ctx.get("TERM")
2334                .unwrap()
2335                .clone()
2336                .try_cast::<String>()
2337                .unwrap(),
2338            "xterm"
2339        );
2340        assert!(!ctx.contains_key("HOME"));
2341        assert!(!ctx.contains_key("PATH"));
2342    }
2343
2344    #[test]
2345    fn workspace_without_worktree_emits_unit() {
2346        let dc = DataContext::new(minimal_status());
2347        let ctx = build_and_unwrap_map(&dc, &[]);
2348        let ws: Map = status_map(&ctx)
2349            .get("workspace")
2350            .unwrap()
2351            .clone()
2352            .try_cast()
2353            .unwrap();
2354        assert!(ws.get("git_worktree").unwrap().is_unit());
2355    }
2356
2357    #[test]
2358    fn workspace_worktree_preserves_name_and_path() {
2359        let mut s = minimal_status();
2360        s.workspace.as_mut().expect("workspace").git_worktree = Some(GitWorktree {
2361            name: "feature".to_string(),
2362            path: PathBuf::from("/wt/feature"),
2363        });
2364        let dc = DataContext::new(s);
2365        let ctx = build_and_unwrap_map(&dc, &[]);
2366        let wt: Map = status_map(&ctx)
2367            .get("workspace")
2368            .unwrap()
2369            .clone()
2370            .try_cast::<Map>()
2371            .unwrap()
2372            .get("git_worktree")
2373            .unwrap()
2374            .clone()
2375            .try_cast()
2376            .unwrap();
2377        assert_eq!(
2378            wt.get("name")
2379                .unwrap()
2380                .clone()
2381                .try_cast::<String>()
2382                .unwrap(),
2383            "feature"
2384        );
2385        assert_eq!(
2386            wt.get("path")
2387                .unwrap()
2388                .clone()
2389                .try_cast::<String>()
2390                .unwrap(),
2391            "/wt/feature"
2392        );
2393    }
2394
2395    #[test]
2396    fn config_is_passed_through_as_provided() {
2397        let dc = DataContext::new(minimal_status());
2398        let mut config_map = Map::new();
2399        config_map.insert("threshold".into(), Dynamic::from(42_i64));
2400        let rc = RenderContext::new(80);
2401        let ctx: Map = build_ctx(&dc, &rc, &[], Dynamic::from_map(config_map))
2402            .try_cast()
2403            .unwrap();
2404        let config: Map = ctx.get("config").unwrap().clone().try_cast().unwrap();
2405        assert_eq!(
2406            config
2407                .get("threshold")
2408                .unwrap()
2409                .clone()
2410                .try_cast::<i64>()
2411                .unwrap(),
2412            42
2413        );
2414    }
2415}