1use 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
34const MAX_JSON_DEPTH: usize = MAX_EXPR_DEPTH;
40
41#[derive(Clone, Copy)]
48enum CapPosture {
49 Strict,
54 EscapeHatch,
57}
58
59impl CapPosture {
60 fn is_strict(self) -> bool {
61 matches!(self, CapPosture::Strict)
62 }
63}
64
65#[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
128pub 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(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
197fn 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
208fn 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 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 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 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 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 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 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 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
613fn build_git_context(gc: &GitContext) -> Dynamic {
616 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
719fn 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
735fn env_snapshot() -> Dynamic {
738 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
761fn 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 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 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 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 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 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 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 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 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 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 assert!(!payload.get("seven_day").unwrap().is_unit());
1167 }
1168
1169 #[test]
1170 fn declared_source_shows_up_as_tagged_error_when_stub() {
1171 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 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 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 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 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 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 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 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 let leaf = serde_json::json!({ "leaf": "bottom" });
1752 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 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 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 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 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 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 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 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 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 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 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 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 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 use crate::data_context::{EndpointUsage, UsageData};
2231 let leaf = serde_json::json!("leaf");
2232 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 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}