1use 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
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_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 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_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
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 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 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 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 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 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 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 assert!(!payload.get("seven_day").unwrap().is_unit());
1181 }
1182
1183 #[test]
1184 fn declared_source_shows_up_as_tagged_error_when_stub() {
1185 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 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 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 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 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 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 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 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 let leaf = serde_json::json!({ "leaf": "bottom" });
1766 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 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 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 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 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 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 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 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 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 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 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 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 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 use crate::data_context::{EndpointUsage, UsageData};
2245 let leaf = serde_json::json!("leaf");
2246 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 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}