1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::context_field::{
6 ContextItemId, ContextKind, ContextState, Provenance, ViewCosts, ViewKind,
7};
8
9const DEFAULT_CONTEXT_WINDOW: usize = 128_000;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ContextLedger {
13 pub window_size: usize,
14 pub entries: Vec<LedgerEntry>,
15 pub total_tokens_sent: usize,
16 pub total_tokens_saved: usize,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct LedgerEntry {
21 pub path: String,
22 pub mode: String,
23 pub original_tokens: usize,
24 pub sent_tokens: usize,
25 pub timestamp: i64,
26 #[serde(default)]
27 pub id: Option<ContextItemId>,
28 #[serde(default)]
29 pub kind: Option<ContextKind>,
30 #[serde(default)]
31 pub source_hash: Option<String>,
32 #[serde(default)]
33 pub state: Option<ContextState>,
34 #[serde(default)]
35 pub phi: Option<f64>,
36 #[serde(default)]
37 pub view_costs: Option<ViewCosts>,
38 #[serde(default)]
39 pub active_view: Option<ViewKind>,
40 #[serde(default)]
41 pub provenance: Option<Provenance>,
42}
43
44#[derive(Debug, Clone)]
45pub struct ContextPressure {
46 pub utilization: f64,
47 pub remaining_tokens: usize,
48 pub entries_count: usize,
49 pub recommendation: PressureAction,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum PressureAction {
54 NoAction,
55 SuggestCompression,
56 ForceCompression,
57 EvictLeastRelevant,
58}
59
60impl ContextLedger {
61 pub fn new() -> Self {
62 Self {
63 window_size: DEFAULT_CONTEXT_WINDOW,
64 entries: Vec::new(),
65 total_tokens_sent: 0,
66 total_tokens_saved: 0,
67 }
68 }
69
70 pub fn with_window_size(size: usize) -> Self {
71 Self {
72 window_size: size,
73 entries: Vec::new(),
74 total_tokens_sent: 0,
75 total_tokens_saved: 0,
76 }
77 }
78
79 pub fn record(&mut self, path: &str, mode: &str, original_tokens: usize, sent_tokens: usize) {
80 let item_id = ContextItemId::from_file(path);
81 if let Some(existing) = self.entries.iter_mut().find(|e| e.path == path) {
82 self.total_tokens_sent -= existing.sent_tokens;
83 self.total_tokens_saved -= existing
84 .original_tokens
85 .saturating_sub(existing.sent_tokens);
86 existing.mode = mode.to_string();
87 existing.original_tokens = original_tokens;
88 existing.sent_tokens = sent_tokens;
89 existing.timestamp = chrono::Utc::now().timestamp();
90 existing.active_view = Some(ViewKind::parse(mode));
91 if existing.id.is_none() {
92 existing.id = Some(item_id);
93 }
94 if existing.state.is_none() || existing.state == Some(ContextState::Candidate) {
95 existing.state = Some(ContextState::Included);
96 }
97 } else {
98 self.entries.push(LedgerEntry {
99 path: path.to_string(),
100 mode: mode.to_string(),
101 original_tokens,
102 sent_tokens,
103 timestamp: chrono::Utc::now().timestamp(),
104 id: Some(item_id),
105 kind: Some(ContextKind::File),
106 source_hash: None,
107 state: Some(ContextState::Included),
108 phi: None,
109 view_costs: Some(ViewCosts::from_full_tokens(original_tokens)),
110 active_view: Some(ViewKind::parse(mode)),
111 provenance: None,
112 });
113 }
114 self.total_tokens_sent += sent_tokens;
115 self.total_tokens_saved += original_tokens.saturating_sub(sent_tokens);
116 }
117
118 pub fn upsert(
120 &mut self,
121 path: &str,
122 mode: &str,
123 original_tokens: usize,
124 sent_tokens: usize,
125 source_hash: Option<&str>,
126 kind: ContextKind,
127 provenance: Option<Provenance>,
128 ) {
129 self.record(path, mode, original_tokens, sent_tokens);
130 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
131 entry.kind = Some(kind);
132 if let Some(h) = source_hash {
133 if entry.source_hash.as_deref() != Some(h) {
134 if entry.source_hash.is_some() {
135 entry.state = Some(ContextState::Stale);
136 }
137 entry.source_hash = Some(h.to_string());
138 }
139 }
140 if let Some(prov) = provenance {
141 entry.provenance = Some(prov);
142 }
143 }
144 }
145
146 pub fn update_phi(&mut self, path: &str, phi: f64) {
148 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
149 entry.phi = Some(phi);
150 }
151 }
152
153 pub fn set_state(&mut self, path: &str, state: ContextState) {
155 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
156 entry.state = Some(state);
157 }
158 }
159
160 pub fn find_by_id(&self, id: &ContextItemId) -> Option<&LedgerEntry> {
162 self.entries.iter().find(|e| e.id.as_ref() == Some(id))
163 }
164
165 pub fn items_by_state(&self, state: ContextState) -> Vec<&LedgerEntry> {
167 self.entries
168 .iter()
169 .filter(|e| e.state == Some(state))
170 .collect()
171 }
172
173 pub fn eviction_candidates_by_phi(&self, keep_count: usize) -> Vec<String> {
176 if self.entries.len() <= keep_count {
177 return Vec::new();
178 }
179 let mut sorted = self.entries.clone();
180 sorted.sort_by(|a, b| {
181 let a_phi = a.phi.unwrap_or(0.0);
182 let b_phi = b.phi.unwrap_or(0.0);
183 a_phi
184 .partial_cmp(&b_phi)
185 .unwrap_or(std::cmp::Ordering::Equal)
186 .then_with(|| a.timestamp.cmp(&b.timestamp))
187 });
188 sorted
189 .iter()
190 .filter(|e| e.state != Some(ContextState::Pinned))
191 .take(self.entries.len() - keep_count)
192 .map(|e| e.path.clone())
193 .collect()
194 }
195
196 pub fn mark_stale_by_hash(&mut self, path: &str, new_hash: &str) {
198 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
199 if let Some(ref old_hash) = entry.source_hash {
200 if old_hash != new_hash {
201 entry.state = Some(ContextState::Stale);
202 entry.source_hash = Some(new_hash.to_string());
203 }
204 }
205 }
206 }
207
208 pub fn pressure(&self) -> ContextPressure {
209 let utilization = self.total_tokens_sent as f64 / self.window_size as f64;
210
211 let pinned_count = self
212 .entries
213 .iter()
214 .filter(|e| e.state == Some(ContextState::Pinned))
215 .count();
216 let stale_count = self
217 .entries
218 .iter()
219 .filter(|e| e.state == Some(ContextState::Stale))
220 .count();
221 let pinned_pressure = pinned_count as f64 * 0.02;
222 let stale_penalty = stale_count as f64 * 0.01;
223 let effective_utilization = (utilization + pinned_pressure + stale_penalty).min(1.0);
224
225 let effective_used = (effective_utilization * self.window_size as f64).round() as usize;
226 let remaining = self.window_size.saturating_sub(effective_used);
227
228 let recommendation = if effective_utilization > 0.9 {
229 PressureAction::EvictLeastRelevant
230 } else if effective_utilization > 0.75 {
231 PressureAction::ForceCompression
232 } else if effective_utilization > 0.5 {
233 PressureAction::SuggestCompression
234 } else {
235 PressureAction::NoAction
236 };
237
238 ContextPressure {
239 utilization: effective_utilization,
240 remaining_tokens: remaining,
241 entries_count: self.entries.len(),
242 recommendation,
243 }
244 }
245
246 pub fn compression_ratio(&self) -> f64 {
247 let total_original: usize = self.entries.iter().map(|e| e.original_tokens).sum();
248 if total_original == 0 {
249 return 1.0;
250 }
251 self.total_tokens_sent as f64 / total_original as f64
252 }
253
254 pub fn files_by_token_cost(&self) -> Vec<(String, usize)> {
255 let mut costs: Vec<(String, usize)> = self
256 .entries
257 .iter()
258 .map(|e| (e.path.clone(), e.sent_tokens))
259 .collect();
260 costs.sort_by_key(|b| std::cmp::Reverse(b.1));
261 costs
262 }
263
264 pub fn mode_distribution(&self) -> HashMap<String, usize> {
265 let mut dist: HashMap<String, usize> = HashMap::new();
266 for entry in &self.entries {
267 *dist.entry(entry.mode.clone()).or_insert(0) += 1;
268 }
269 dist
270 }
271
272 pub fn eviction_candidates(&self, keep_count: usize) -> Vec<String> {
273 if self.entries.len() <= keep_count {
274 return Vec::new();
275 }
276 let mut sorted = self.entries.clone();
277 sorted.sort_by_key(|e| e.timestamp);
278 sorted
279 .iter()
280 .take(self.entries.len() - keep_count)
281 .map(|e| e.path.clone())
282 .collect()
283 }
284
285 pub fn remove(&mut self, path: &str) {
286 if let Some(idx) = self.entries.iter().position(|e| e.path == path) {
287 let entry = &self.entries[idx];
288 self.total_tokens_sent -= entry.sent_tokens;
289 self.total_tokens_saved -= entry.original_tokens.saturating_sub(entry.sent_tokens);
290 self.entries.remove(idx);
291 }
292 }
293
294 pub fn save(&self) {
295 if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
296 let path = dir.join("context_ledger.json");
297 if let Ok(json) = serde_json::to_string(self) {
298 let _ = std::fs::write(path, json);
299 }
300 }
301 }
302
303 pub fn load() -> Self {
304 crate::core::data_dir::lean_ctx_data_dir()
305 .ok()
306 .map(|d| d.join("context_ledger.json"))
307 .and_then(|p| std::fs::read_to_string(p).ok())
308 .and_then(|s| serde_json::from_str(&s).ok())
309 .unwrap_or_default()
310 }
311
312 pub fn format_summary(&self) -> String {
313 let pressure = self.pressure();
314 format!(
315 "CTX: {}/{} tokens ({:.0}%), {} files, ratio {:.2}, action: {:?}",
316 self.total_tokens_sent,
317 self.window_size,
318 pressure.utilization * 100.0,
319 self.entries.len(),
320 self.compression_ratio(),
321 pressure.recommendation,
322 )
323 }
324}
325
326#[derive(Debug, Clone)]
327pub struct ReinjectionAction {
328 pub path: String,
329 pub current_mode: String,
330 pub new_mode: String,
331 pub tokens_freed: usize,
332}
333
334#[derive(Debug, Clone)]
335pub struct ReinjectionPlan {
336 pub actions: Vec<ReinjectionAction>,
337 pub total_tokens_freed: usize,
338 pub new_utilization: f64,
339}
340
341impl ContextLedger {
342 pub fn reinjection_plan(
343 &self,
344 intent: &super::intent_engine::StructuredIntent,
345 target_utilization: f64,
346 ) -> ReinjectionPlan {
347 let current_util = self.total_tokens_sent as f64 / self.window_size as f64;
348 if current_util <= target_utilization {
349 return ReinjectionPlan {
350 actions: Vec::new(),
351 total_tokens_freed: 0,
352 new_utilization: current_util,
353 };
354 }
355
356 let tokens_to_free =
357 self.total_tokens_sent - (self.window_size as f64 * target_utilization) as usize;
358
359 let target_set: std::collections::HashSet<&str> = intent
360 .targets
361 .iter()
362 .map(std::string::String::as_str)
363 .collect();
364
365 let mut candidates: Vec<(usize, &LedgerEntry)> = self
366 .entries
367 .iter()
368 .enumerate()
369 .filter(|(_, e)| !target_set.iter().any(|t| e.path.contains(t)))
370 .collect();
371
372 candidates.sort_by(|a, b| {
373 let a_phi = a.1.phi.unwrap_or(0.0);
374 let b_phi = b.1.phi.unwrap_or(0.0);
375 a_phi
376 .partial_cmp(&b_phi)
377 .unwrap_or_else(|| a.1.timestamp.cmp(&b.1.timestamp))
378 });
379
380 let mut actions = Vec::new();
381 let mut freed = 0usize;
382
383 for (_, entry) in &candidates {
384 if freed >= tokens_to_free {
385 break;
386 }
387 if let Some((new_mode, new_tokens)) = downgrade_mode(&entry.mode, entry.sent_tokens) {
388 let saving = entry.sent_tokens.saturating_sub(new_tokens);
389 if saving > 0 {
390 actions.push(ReinjectionAction {
391 path: entry.path.clone(),
392 current_mode: entry.mode.clone(),
393 new_mode,
394 tokens_freed: saving,
395 });
396 freed += saving;
397 }
398 }
399 }
400
401 let new_sent = self.total_tokens_sent.saturating_sub(freed);
402 let new_utilization = new_sent as f64 / self.window_size as f64;
403
404 ReinjectionPlan {
405 actions,
406 total_tokens_freed: freed,
407 new_utilization,
408 }
409 }
410}
411
412fn downgrade_mode(current_mode: &str, current_tokens: usize) -> Option<(String, usize)> {
413 match current_mode {
414 "full" => Some(("signatures".to_string(), current_tokens / 5)),
415 "aggressive" => Some(("signatures".to_string(), current_tokens / 3)),
416 "signatures" => Some(("map".to_string(), current_tokens / 2)),
417 "map" => Some(("reference".to_string(), current_tokens / 4)),
418 _ => None,
419 }
420}
421
422impl Default for ContextLedger {
423 fn default() -> Self {
424 Self::new()
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn new_ledger_is_empty() {
434 let ledger = ContextLedger::new();
435 assert_eq!(ledger.total_tokens_sent, 0);
436 assert_eq!(ledger.entries.len(), 0);
437 assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
438 }
439
440 #[test]
441 fn record_tracks_tokens() {
442 let mut ledger = ContextLedger::with_window_size(10000);
443 ledger.record("src/main.rs", "full", 500, 500);
444 ledger.record("src/lib.rs", "signatures", 1000, 200);
445 assert_eq!(ledger.total_tokens_sent, 700);
446 assert_eq!(ledger.total_tokens_saved, 800);
447 assert_eq!(ledger.entries.len(), 2);
448 }
449
450 #[test]
451 fn record_updates_existing_entry() {
452 let mut ledger = ContextLedger::with_window_size(10000);
453 ledger.record("src/main.rs", "full", 500, 500);
454 ledger.record("src/main.rs", "signatures", 500, 100);
455 assert_eq!(ledger.entries.len(), 1);
456 assert_eq!(ledger.total_tokens_sent, 100);
457 assert_eq!(ledger.total_tokens_saved, 400);
458 }
459
460 #[test]
461 fn pressure_escalates() {
462 let mut ledger = ContextLedger::with_window_size(1000);
463 ledger.record("a.rs", "full", 600, 600);
464 assert_eq!(
465 ledger.pressure().recommendation,
466 PressureAction::SuggestCompression
467 );
468 ledger.record("b.rs", "full", 200, 200);
469 assert_eq!(
470 ledger.pressure().recommendation,
471 PressureAction::ForceCompression
472 );
473 ledger.record("c.rs", "full", 150, 150);
474 assert_eq!(
475 ledger.pressure().recommendation,
476 PressureAction::EvictLeastRelevant
477 );
478 }
479
480 #[test]
481 fn compression_ratio_accurate() {
482 let mut ledger = ContextLedger::with_window_size(10000);
483 ledger.record("a.rs", "full", 1000, 1000);
484 ledger.record("b.rs", "signatures", 1000, 200);
485 let ratio = ledger.compression_ratio();
486 assert!((ratio - 0.6).abs() < 0.01);
487 }
488
489 #[test]
490 fn eviction_returns_oldest() {
491 let mut ledger = ContextLedger::with_window_size(10000);
492 ledger.record("old.rs", "full", 100, 100);
493 std::thread::sleep(std::time::Duration::from_millis(10));
494 ledger.record("new.rs", "full", 100, 100);
495 let candidates = ledger.eviction_candidates(1);
496 assert_eq!(candidates, vec!["old.rs"]);
497 }
498
499 #[test]
500 fn remove_updates_totals() {
501 let mut ledger = ContextLedger::with_window_size(10000);
502 ledger.record("a.rs", "full", 500, 500);
503 ledger.record("b.rs", "full", 300, 300);
504 ledger.remove("a.rs");
505 assert_eq!(ledger.total_tokens_sent, 300);
506 assert_eq!(ledger.entries.len(), 1);
507 }
508
509 #[test]
510 fn mode_distribution_counts() {
511 let mut ledger = ContextLedger::new();
512 ledger.record("a.rs", "full", 100, 100);
513 ledger.record("b.rs", "signatures", 100, 50);
514 ledger.record("c.rs", "full", 100, 100);
515 let dist = ledger.mode_distribution();
516 assert_eq!(dist.get("full"), Some(&2));
517 assert_eq!(dist.get("signatures"), Some(&1));
518 }
519
520 #[test]
521 fn format_summary_includes_key_info() {
522 let mut ledger = ContextLedger::with_window_size(10000);
523 ledger.record("a.rs", "full", 500, 500);
524 let summary = ledger.format_summary();
525 assert!(summary.contains("500/10000"));
526 assert!(summary.contains("1 files"));
527 }
528
529 #[test]
530 fn reinjection_no_action_when_low_pressure() {
531 use crate::core::intent_engine::StructuredIntent;
532
533 let mut ledger = ContextLedger::with_window_size(10000);
534 ledger.record("a.rs", "full", 100, 100);
535 let intent = StructuredIntent::from_query("fix bug in a.rs");
536 let plan = ledger.reinjection_plan(&intent, 0.7);
537 assert!(plan.actions.is_empty());
538 assert_eq!(plan.total_tokens_freed, 0);
539 }
540
541 #[test]
542 fn reinjection_downgrades_non_target_files() {
543 use crate::core::intent_engine::StructuredIntent;
544
545 let mut ledger = ContextLedger::with_window_size(1000);
546 ledger.record("src/target.rs", "full", 400, 400);
547 std::thread::sleep(std::time::Duration::from_millis(10));
548 ledger.record("src/other.rs", "full", 400, 400);
549 std::thread::sleep(std::time::Duration::from_millis(10));
550 ledger.record("src/utils.rs", "full", 200, 200);
551
552 let intent = StructuredIntent::from_query("fix bug in target.rs");
553 let plan = ledger.reinjection_plan(&intent, 0.5);
554
555 assert!(!plan.actions.is_empty());
556 assert!(
557 plan.actions.iter().all(|a| !a.path.contains("target")),
558 "should not downgrade target file"
559 );
560 assert!(plan.total_tokens_freed > 0);
561 }
562
563 #[test]
564 fn reinjection_preserves_targets() {
565 use crate::core::intent_engine::StructuredIntent;
566
567 let mut ledger = ContextLedger::with_window_size(1000);
568 ledger.record("src/auth.rs", "full", 900, 900);
569 let intent = StructuredIntent::from_query("fix bug in auth.rs");
570 let plan = ledger.reinjection_plan(&intent, 0.5);
571 assert!(
572 plan.actions.is_empty(),
573 "should not downgrade target files even under pressure"
574 );
575 }
576
577 #[test]
578 fn downgrade_mode_chain() {
579 assert_eq!(
580 downgrade_mode("full", 1000),
581 Some(("signatures".to_string(), 200))
582 );
583 assert_eq!(
584 downgrade_mode("signatures", 200),
585 Some(("map".to_string(), 100))
586 );
587 assert_eq!(
588 downgrade_mode("map", 100),
589 Some(("reference".to_string(), 25))
590 );
591 assert_eq!(downgrade_mode("reference", 25), None);
592 }
593
594 #[test]
595 fn record_assigns_item_id() {
596 let mut ledger = ContextLedger::new();
597 ledger.record("src/main.rs", "full", 500, 500);
598 let entry = &ledger.entries[0];
599 assert!(entry.id.is_some());
600 assert_eq!(entry.id.as_ref().unwrap().as_str(), "file:src/main.rs");
601 }
602
603 #[test]
604 fn record_sets_state_to_included() {
605 let mut ledger = ContextLedger::new();
606 ledger.record("src/main.rs", "full", 500, 500);
607 assert_eq!(
608 ledger.entries[0].state,
609 Some(crate::core::context_field::ContextState::Included)
610 );
611 }
612
613 #[test]
614 fn record_generates_view_costs() {
615 let mut ledger = ContextLedger::new();
616 ledger.record("src/main.rs", "full", 5000, 5000);
617 let vc = ledger.entries[0].view_costs.as_ref().unwrap();
618 assert_eq!(vc.get(&crate::core::context_field::ViewKind::Full), 5000);
619 assert_eq!(
620 vc.get(&crate::core::context_field::ViewKind::Signatures),
621 1000
622 );
623 }
624
625 #[test]
626 fn update_phi_works() {
627 let mut ledger = ContextLedger::new();
628 ledger.record("a.rs", "full", 100, 100);
629 ledger.update_phi("a.rs", 0.85);
630 assert_eq!(ledger.entries[0].phi, Some(0.85));
631 }
632
633 #[test]
634 fn set_state_works() {
635 let mut ledger = ContextLedger::new();
636 ledger.record("a.rs", "full", 100, 100);
637 ledger.set_state("a.rs", crate::core::context_field::ContextState::Pinned);
638 assert_eq!(
639 ledger.entries[0].state,
640 Some(crate::core::context_field::ContextState::Pinned)
641 );
642 }
643
644 #[test]
645 fn items_by_state_filters() {
646 let mut ledger = ContextLedger::new();
647 ledger.record("a.rs", "full", 100, 100);
648 ledger.record("b.rs", "full", 100, 100);
649 ledger.set_state("b.rs", crate::core::context_field::ContextState::Excluded);
650 let included = ledger.items_by_state(crate::core::context_field::ContextState::Included);
651 assert_eq!(included.len(), 1);
652 assert_eq!(included[0].path, "a.rs");
653 }
654
655 #[test]
656 fn eviction_by_phi_prefers_low_phi() {
657 let mut ledger = ContextLedger::with_window_size(10000);
658 ledger.record("high.rs", "full", 100, 100);
659 ledger.update_phi("high.rs", 0.9);
660 ledger.record("low.rs", "full", 100, 100);
661 ledger.update_phi("low.rs", 0.1);
662 let candidates = ledger.eviction_candidates_by_phi(1);
663 assert_eq!(candidates, vec!["low.rs"]);
664 }
665
666 #[test]
667 fn eviction_by_phi_skips_pinned() {
668 let mut ledger = ContextLedger::with_window_size(10000);
669 ledger.record("pinned.rs", "full", 100, 100);
670 ledger.update_phi("pinned.rs", 0.01);
671 ledger.set_state(
672 "pinned.rs",
673 crate::core::context_field::ContextState::Pinned,
674 );
675 ledger.record("normal.rs", "full", 100, 100);
676 ledger.update_phi("normal.rs", 0.5);
677 let candidates = ledger.eviction_candidates_by_phi(1);
678 assert_eq!(candidates, vec!["normal.rs"]);
679 }
680
681 #[test]
682 fn mark_stale_by_hash_detects_change() {
683 let mut ledger = ContextLedger::new();
684 ledger.record("a.rs", "full", 100, 100);
685 ledger.entries[0].source_hash = Some("hash_v1".to_string());
686 ledger.mark_stale_by_hash("a.rs", "hash_v2");
687 assert_eq!(
688 ledger.entries[0].state,
689 Some(crate::core::context_field::ContextState::Stale)
690 );
691 }
692
693 #[test]
694 fn find_by_id_works() {
695 let mut ledger = ContextLedger::new();
696 ledger.record("src/lib.rs", "full", 100, 100);
697 let id = crate::core::context_field::ContextItemId::from_file("src/lib.rs");
698 assert!(ledger.find_by_id(&id).is_some());
699 }
700
701 #[test]
702 fn upsert_sets_source_hash_and_kind() {
703 let mut ledger = ContextLedger::new();
704 ledger.upsert(
705 "src/main.rs",
706 "full",
707 500,
708 500,
709 Some("sha256_abc"),
710 crate::core::context_field::ContextKind::File,
711 None,
712 );
713 let entry = &ledger.entries[0];
714 assert_eq!(entry.source_hash.as_deref(), Some("sha256_abc"));
715 assert_eq!(
716 entry.kind,
717 Some(crate::core::context_field::ContextKind::File)
718 );
719 }
720}