1use crate::core::context_field::{ContextItemId, ContextState};
2use crate::core::context_ledger::{ContextLedger, PressureAction};
3use crate::core::context_overlay::{OverlayOp, OverlayStore};
4
5fn is_precise_pinned_mode(requested_mode: &str) -> bool {
16 requested_mode
17 .parse::<crate::tools::ctx_read::ReadMode>()
18 .is_ok_and(|m| m.is_precise_pinned_read())
19}
20
21#[derive(Debug, Clone)]
22pub struct PreDispatchResult {
23 pub overridden_mode: Option<String>,
24 pub reason: Option<&'static str>,
25 pub pressure_downgraded: bool,
26 pub budget_blocked: bool,
27 pub budget_warning: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct PostDispatchResult {
32 pub eviction_hint: Option<String>,
33 pub elicitation_hint: Option<String>,
34 pub resource_changed: bool,
35 pub prefetch_hint: Option<String>,
38}
39
40pub fn pre_dispatch_read(
41 path: &str,
42 requested_mode: &str,
43 task: Option<&str>,
44 project_root: Option<&str>,
45 pressure: Option<&PressureAction>,
46) -> PreDispatchResult {
47 pre_dispatch_read_for_agent(path, requested_mode, task, project_root, pressure, None)
48}
49
50pub fn pre_dispatch_read_for_agent(
51 path: &str,
52 requested_mode: &str,
53 task: Option<&str>,
54 project_root: Option<&str>,
55 pressure: Option<&PressureAction>,
56 agent_id: Option<&str>,
57) -> PreDispatchResult {
58 let no_change = PreDispatchResult {
59 overridden_mode: None,
60 reason: None,
61 pressure_downgraded: false,
62 budget_blocked: false,
63 budget_warning: None,
64 };
65
66 if let Some(aid) = agent_id {
67 let estimated_tokens = estimate_read_tokens(path, requested_mode);
68 match crate::core::agent_budget::check_budget(aid, estimated_tokens) {
69 crate::core::agent_budget::BudgetCheckResult::Exceeded { limit, consumed } => {
70 return PreDispatchResult {
71 overridden_mode: None,
72 reason: Some("agent-budget-exceeded"),
73 pressure_downgraded: false,
74 budget_blocked: true,
75 budget_warning: Some(format!(
76 "Agent budget exceeded: {consumed}/{limit} tokens consumed. Reset via ctx_session or set a higher limit."
77 )),
78 };
79 }
80 crate::core::agent_budget::BudgetCheckResult::Warning {
81 remaining,
82 percent_used,
83 } => {
84 let warning = format!(
85 "[BUDGET WARNING] Agent '{aid}' at {:.0}% budget ({remaining} tokens remaining)",
86 percent_used * 100.0
87 );
88 let mut result = no_change.clone();
89 result.budget_warning = Some(warning);
90 if is_precise_pinned_mode(requested_mode) {
91 return result;
92 }
93 let rest = pre_dispatch_inner(path, requested_mode, task, project_root, pressure);
94 return PreDispatchResult {
95 budget_warning: result.budget_warning,
96 ..rest
97 };
98 }
99 crate::core::agent_budget::BudgetCheckResult::Allowed { .. } => {}
100 }
101 }
102
103 pre_dispatch_inner(path, requested_mode, task, project_root, pressure)
104}
105
106fn pre_dispatch_inner(
107 path: &str,
108 requested_mode: &str,
109 task: Option<&str>,
110 project_root: Option<&str>,
111 pressure: Option<&PressureAction>,
112) -> PreDispatchResult {
113 let no_change = PreDispatchResult {
114 overridden_mode: None,
115 reason: None,
116 pressure_downgraded: false,
117 budget_blocked: false,
118 budget_warning: None,
119 };
120
121 if is_precise_pinned_mode(requested_mode) {
122 return no_change;
123 }
124
125 if let Some(root) = project_root {
126 let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
127 if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
128 return result;
129 }
130 }
131
132 if requested_mode == "full" {
135 return no_change;
136 }
137
138 if let Some(action) = pressure {
139 let no_degrade = crate::core::config::Config::load().no_degrade_effective();
140 let profile = crate::core::profiles::active_profile();
141 if !no_degrade
142 && profile.degradation.enforce_effective()
143 && let Some(downgraded) = pressure_downgrade(requested_mode, action)
144 {
145 return PreDispatchResult {
146 overridden_mode: Some(downgraded),
147 reason: Some("pressure-auto-downgrade"),
148 pressure_downgraded: true,
149 budget_blocked: false,
150 budget_warning: None,
151 };
152 }
153 }
154
155 if let Ok(bt) = crate::core::bounce_tracker::global().lock()
156 && bt.should_force_full(path)
157 {
158 return PreDispatchResult {
159 overridden_mode: Some("full".to_string()),
160 reason: Some("bounce-prevention"),
161 pressure_downgraded: false,
162 budget_blocked: false,
163 budget_warning: None,
164 };
165 }
166
167 if let Some(task_str) = task {
168 let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
169 let norm = crate::core::pathutil::normalize_tool_path(path);
170 let is_target = intent
171 .targets
172 .iter()
173 .any(|t| norm.ends_with(t) || norm.contains(t));
174 if is_target {
175 return PreDispatchResult {
176 overridden_mode: Some("full".to_string()),
177 reason: Some("intent-target"),
178 pressure_downgraded: false,
179 budget_blocked: false,
180 budget_warning: None,
181 };
182 }
183 }
184
185 if let Some(root) = project_root
186 && let Some(open) = try_load_graph(root)
187 {
188 let gp = &open.provider;
189 let related = gp.related(path, 1);
190 if let Some(task_str) = task {
191 let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
192 for target in &intent.targets {
193 let target_related = gp.related(target, 1);
194 let norm = crate::core::pathutil::normalize_tool_path(path);
195 if target_related
196 .iter()
197 .any(|r| r.contains(&norm) || norm.contains(r))
198 {
199 return PreDispatchResult {
200 overridden_mode: Some("map".to_string()),
201 reason: Some("graph-direct-import"),
202 pressure_downgraded: false,
203 budget_blocked: false,
204 budget_warning: None,
205 };
206 }
207 }
208 }
209 if !related.is_empty() && requested_mode == "auto" {
210 let reverse_deps = gp.dependents(path);
211 if reverse_deps.len() > 3 {
212 return PreDispatchResult {
213 overridden_mode: Some("map".to_string()),
214 reason: Some("graph-hub-file"),
215 pressure_downgraded: false,
216 budget_blocked: false,
217 budget_warning: None,
218 };
219 }
220 }
221 }
222
223 if let Some(root) = project_root
224 && let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root)
225 {
226 let norm = crate::core::pathutil::normalize_tool_path(path);
227 let mentions = knowledge
228 .facts
229 .iter()
230 .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
231 .count();
232 if mentions >= 3 {
233 return PreDispatchResult {
234 overridden_mode: Some("map".to_string()),
235 reason: Some("knowledge-high-relevance"),
236 pressure_downgraded: false,
237 budget_blocked: false,
238 budget_warning: None,
239 };
240 }
241 }
242
243 no_change
244}
245
246fn estimate_read_tokens(path: &str, mode: &str) -> usize {
247 let file_size = std::fs::metadata(path).map_or(4000, |m| m.len() as usize);
248 let char_estimate = file_size;
249 let full_tokens = char_estimate / 4;
250 match mode {
251 "signatures" => full_tokens / 5,
252 "map" => full_tokens / 3,
253 "aggressive" | "entropy" => full_tokens / 4,
254 "diff" => full_tokens / 10,
255 _ if mode.starts_with("lines:") => {
256 if let Some(range) = mode.strip_prefix("lines:") {
257 let parts: Vec<&str> = range.split('-').collect();
258 if parts.len() == 2 {
259 let start = parts[0].parse::<usize>().unwrap_or(1);
260 let end = parts[1].parse::<usize>().unwrap_or(start + 100);
261 (end.saturating_sub(start) + 1) * 10
262 } else {
263 full_tokens / 10
264 }
265 } else {
266 full_tokens / 10
267 }
268 }
269 _ => full_tokens,
270 }
271}
272
273fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
274 crate::core::auto_mode_resolver::pressure_downgrade(requested_mode, action)
275}
276
277fn check_overlay_mode_override(
278 path: &str,
279 requested_mode: &str,
280 overlay: &OverlayStore,
281) -> Option<PreDispatchResult> {
282 let item_id = ContextItemId::from_file(path);
283 let overlays = overlay.for_item(&item_id);
284
285 for ov in overlays.iter().rev() {
286 match &ov.operation {
287 OverlayOp::SetView(view) => {
288 let mode_str = view.as_str();
289 if mode_str != requested_mode {
290 return Some(PreDispatchResult {
291 overridden_mode: Some(mode_str.to_string()),
292 reason: Some("overlay-set-view"),
293 pressure_downgraded: false,
294 budget_blocked: false,
295 budget_warning: None,
296 });
297 }
298 }
299 OverlayOp::Pin { .. } if requested_mode != "full" => {
300 return Some(PreDispatchResult {
301 overridden_mode: Some("full".to_string()),
302 reason: Some("pinned"),
303 pressure_downgraded: false,
304 budget_blocked: false,
305 budget_warning: None,
306 });
307 }
308 OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
309 return Some(PreDispatchResult {
310 overridden_mode: Some("signatures".to_string()),
311 reason: Some("excluded"),
312 pressure_downgraded: false,
313 budget_blocked: false,
314 budget_warning: None,
315 });
316 }
317 _ => {}
318 }
319 }
320 None
321}
322
323pub fn post_dispatch_record(
324 path: &str,
325 mode: &str,
326 original_tokens: usize,
327 sent_tokens: usize,
328 ledger: &mut ContextLedger,
329 overlay: &OverlayStore,
330) -> PostDispatchResult {
331 post_dispatch_record_with_task(
332 path,
333 mode,
334 original_tokens,
335 sent_tokens,
336 ledger,
337 overlay,
338 None,
339 None,
340 )
341}
342
343pub fn post_dispatch_record_with_task(
344 path: &str,
345 mode: &str,
346 original_tokens: usize,
347 sent_tokens: usize,
348 ledger: &mut ContextLedger,
349 overlay: &OverlayStore,
350 task: Option<&str>,
351 project_root: Option<&str>,
352) -> PostDispatchResult {
353 let prev_count = ledger.entries.len();
354 let prev_pressure = ledger.pressure().recommendation;
355
356 ledger.record_with_task(path, mode, original_tokens, sent_tokens, task);
357
358 let item_id = ContextItemId::from_file(path);
359 let state = overlay.apply_to_state(&item_id, ContextState::Included);
360
361 if state == ContextState::Excluded {
362 return PostDispatchResult {
363 eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
364 elicitation_hint: None,
365 resource_changed: true,
366 prefetch_hint: None,
367 };
368 }
369
370 let elicitation =
371 super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
372 .map(|s| s.format_fallback_hint());
373
374 let pressure = ledger.pressure();
375
376 let ignited = ledger.ignite_high_salience();
380
381 apply_reinjection_plan(ledger, &pressure.recommendation);
382
383 let new_entry = ledger.entries.len() != prev_count;
384 let pressure_shifted = pressure.recommendation != prev_pressure;
385 let resource_changed = new_entry || pressure_shifted || !ignited.is_empty();
386
387 if pressure.utilization > 0.9 {
388 let candidates = ledger.eviction_candidates_by_phi(3);
389 if !candidates.is_empty() {
390 let names: Vec<_> = candidates
394 .iter()
395 .take(3)
396 .map(|p| eviction_target_display(p, project_root))
397 .collect();
398 return PostDispatchResult {
399 eviction_hint: Some(format!(
400 "Context pressure {:.0}%. Evict: ctx_ledger(action=\"evict\", targets=\"{}\")",
401 pressure.utilization * 100.0,
402 names.join(", ")
403 )),
404 elicitation_hint: elicitation,
405 resource_changed,
406 prefetch_hint: None,
408 };
409 }
410 }
411
412 let prefetch_hint =
416 project_root.and_then(|root| crate::core::fep_prefetch::prefetch_hint(root, path, ledger));
417
418 PostDispatchResult {
419 eviction_hint: None,
420 elicitation_hint: elicitation,
421 resource_changed,
422 prefetch_hint,
423 }
424}
425
426fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
430 if let Some(root) = project_root.filter(|r| !r.is_empty()) {
431 let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
432 if let Some(rel) = path.strip_prefix(&root_prefix)
433 && !rel.is_empty()
434 {
435 return rel.to_string();
436 }
437 }
438 path.to_string()
439}
440
441fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
442 if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
443 {
444 return;
445 }
446 for entry in &mut ledger.entries {
447 if entry.state == Some(ContextState::Pinned) {
449 continue;
450 }
451 if entry.mode == "full" {
452 entry.mode = "map".to_string();
453 }
454 }
455}
456
457fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
458 crate::core::graph_provider::open_best_effort(project_root)
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 #[test]
466 fn eviction_target_display_emits_resolvable_targets() {
467 assert_eq!(
470 eviction_target_display("/w/proj/src/a.rs", Some("/w/proj")),
471 "src/a.rs"
472 );
473 assert_eq!(
474 eviction_target_display("/other/b.rs", Some("/w/proj")),
475 "/other/b.rs"
476 );
477 assert_eq!(eviction_target_display("/x/c.rs", None), "/x/c.rs");
478 }
479
480 #[test]
481 fn pre_dispatch_passthrough_for_full() {
482 let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
483 assert!(result.overridden_mode.is_none());
484 }
485
486 #[test]
487 fn pre_dispatch_passthrough_for_diff() {
488 let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
489 assert!(result.overridden_mode.is_none());
490 }
491
492 #[test]
493 fn pre_dispatch_passthrough_for_anchored_window() {
494 {
498 let mut bt = crate::core::bounce_tracker::global()
499 .lock()
500 .unwrap_or_else(std::sync::PoisonError::into_inner);
501 bt.set_seq(101);
502 bt.record_read("anchored-bouncy.yml", "map", 30, 400);
503 bt.set_seq(102);
504 bt.record_read("anchored-bouncy.yml", "full", 400, 400);
505 bt.set_seq(103);
506 bt.record_read("a2.yml", "map", 30, 400);
507 bt.set_seq(104);
508 bt.record_read("a2.yml", "full", 400, 400);
509 bt.set_seq(105);
510 bt.record_read("a3.yml", "map", 30, 400);
511 bt.set_seq(106);
512 bt.record_read("a3.yml", "full", 400, 400);
513 }
514 let result = pre_dispatch_read("anchored-new.yml", "anchored:10-40", None, None, None);
515 assert!(
516 result.overridden_mode.is_none(),
517 "anchored:N-M must not be overridden by bounce-prevention"
518 );
519 let bare = pre_dispatch_read("anchored-new.yml", "anchored", None, None, None);
520 assert!(
521 bare.overridden_mode.is_none(),
522 "bare anchored mode must not be overridden by bounce-prevention"
523 );
524 }
525
526 #[test]
527 fn pre_dispatch_no_override_without_signals() {
528 let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
529 assert!(result.overridden_mode.is_none());
530 }
531
532 #[test]
533 fn pre_dispatch_bounce_prevention_forces_full() {
534 {
535 let mut bt = crate::core::bounce_tracker::global()
536 .lock()
537 .unwrap_or_else(std::sync::PoisonError::into_inner);
538 bt.set_seq(1);
539 bt.record_read("src/bouncy.yml", "map", 30, 400);
540 bt.set_seq(2);
541 bt.record_read("src/bouncy.yml", "full", 400, 400);
542 bt.set_seq(3);
543 bt.record_read("a2.yml", "map", 30, 400);
544 bt.set_seq(4);
545 bt.record_read("a2.yml", "full", 400, 400);
546 bt.set_seq(5);
547 bt.record_read("a3.yml", "map", 30, 400);
548 bt.set_seq(6);
549 bt.record_read("a3.yml", "full", 400, 400);
550 }
551 let result = pre_dispatch_read("new.yml", "auto", None, None, None);
552 assert_eq!(result.overridden_mode, Some("full".to_string()));
553 assert_eq!(result.reason, Some("bounce-prevention"));
554 }
555
556 #[test]
557 fn pressure_does_not_downgrade_explicit_full() {
558 let result = pre_dispatch_read(
559 "c.rs",
560 "full",
561 None,
562 None,
563 Some(&PressureAction::ForceCompression),
564 );
565 assert!(
566 result.overridden_mode.is_none(),
567 "explicit mode=full must never be downgraded by pressure"
568 );
569 assert!(!result.pressure_downgraded);
570 }
571
572 #[test]
573 fn pressure_does_not_downgrade_when_enforce_off() {
574 let result = pre_dispatch_read(
577 "c.rs",
578 "map",
579 None,
580 None,
581 Some(&PressureAction::EvictLeastRelevant),
582 );
583 assert!(
584 result.overridden_mode.is_none(),
585 "pressure must not downgrade when degradation.enforce is off"
586 );
587 assert!(!result.pressure_downgraded);
588 }
589
590 #[test]
591 fn no_pressure_downgrade_when_low() {
592 let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
593 assert!(result.overridden_mode.is_none());
594 assert!(!result.pressure_downgraded);
595 }
596
597 #[test]
598 fn suggest_compression_does_not_downgrade_when_enforce_off() {
599 let result = pre_dispatch_read(
601 "c.rs",
602 "auto",
603 None,
604 None,
605 Some(&PressureAction::SuggestCompression),
606 );
607 assert!(
608 result.overridden_mode.is_none(),
609 "suggest_compression must not downgrade when enforce is off"
610 );
611 assert!(!result.pressure_downgraded);
612 }
613
614 #[test]
615 fn suggest_compression_does_not_touch_explicit_full() {
616 let result = pre_dispatch_read(
617 "c.rs",
618 "full",
619 None,
620 None,
621 Some(&PressureAction::SuggestCompression),
622 );
623 assert!(result.overridden_mode.is_none());
624 assert!(!result.pressure_downgraded);
625 }
626
627 #[test]
628 fn post_dispatch_reinjection_downgrades_entries() {
629 let mut ledger = ContextLedger::with_window_size(1000);
630 ledger.record("a.rs", "full", 400, 400);
631 ledger.record("b.rs", "full", 400, 400);
632 let overlay = OverlayStore::new();
633 let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
634 assert!(result.resource_changed);
635 let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
636 assert_eq!(a_entry.mode, "map");
637 }
638
639 #[test]
640 fn ignited_item_resists_reinjection_downgrade() {
641 let mut ledger = ContextLedger::with_window_size(1000);
644 for i in 0..5 {
645 ledger.record(&format!("bg{i}.rs"), "full", 250, 250);
646 }
647 ledger.record("hot.rs", "full", 250, 250);
648 for e in &mut ledger.entries {
651 e.phi = Some(if e.path == "hot.rs" { 0.97 } else { 0.1 });
652 }
653 let ignited = ledger.ignite_high_salience();
654 assert_eq!(ignited, vec!["hot.rs".to_string()], "outlier should ignite");
655
656 apply_reinjection_plan(&mut ledger, &PressureAction::ForceCompression);
657 let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
658 assert_eq!(hot.mode, "full", "ignited item keeps its full view");
659 let bg = ledger.entries.iter().find(|e| e.path == "bg0.rs").unwrap();
660 assert_eq!(bg.mode, "map", "non-ignited items are downgraded");
661 }
662
663 #[test]
664 fn overlay_pin_forces_full_mode() {
665 let dir = tempfile::tempdir().expect("tmp dir");
666 let root = dir.path();
667 let mut store = OverlayStore::new();
668 let target = ContextItemId::from_file("src/important.rs");
669 store.add(crate::core::context_overlay::ContextOverlay::new(
670 target,
671 OverlayOp::Pin { verbatim: false },
672 crate::core::context_overlay::OverlayScope::Project,
673 String::new(),
674 crate::core::context_overlay::OverlayAuthor::User,
675 ));
676 store.save_project(root).unwrap();
677
678 let result = pre_dispatch_read(
679 "src/important.rs",
680 "auto",
681 None,
682 Some(root.to_str().unwrap()),
683 None,
684 );
685 assert_eq!(result.overridden_mode, Some("full".to_string()));
686 assert_eq!(result.reason, Some("pinned"));
687 }
688
689 #[test]
690 fn overlay_exclude_forces_signatures_mode() {
691 let dir = tempfile::tempdir().expect("tmp dir");
692 let root = dir.path();
693 let mut store = OverlayStore::new();
694 let target = ContextItemId::from_file("src/noisy.rs");
695 store.add(crate::core::context_overlay::ContextOverlay::new(
696 target,
697 OverlayOp::Exclude {
698 reason: "noise".to_string(),
699 },
700 crate::core::context_overlay::OverlayScope::Project,
701 String::new(),
702 crate::core::context_overlay::OverlayAuthor::User,
703 ));
704 store.save_project(root).unwrap();
705
706 let result = pre_dispatch_read(
707 "src/noisy.rs",
708 "auto",
709 None,
710 Some(root.to_str().unwrap()),
711 None,
712 );
713 assert_eq!(result.overridden_mode, Some("signatures".to_string()));
714 assert_eq!(result.reason, Some("excluded"));
715 }
716
717 #[test]
720 fn pressure_downgrade_suggest_auto_to_map() {
721 let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
722 assert_eq!(result, Some("map".to_string()));
723 }
724
725 #[test]
726 fn pressure_downgrade_suggest_full_to_map() {
727 let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
728 assert_eq!(result, Some("map".to_string()));
729 }
730
731 #[test]
732 fn pressure_downgrade_suggest_does_not_touch_signatures() {
733 let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
734 assert!(result.is_none());
735 }
736
737 #[test]
738 fn pressure_downgrade_suggest_does_not_touch_diff() {
739 let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
740 assert!(result.is_none());
741 }
742
743 #[test]
744 fn pressure_downgrade_force_full_to_map() {
745 let result = pressure_downgrade("full", &PressureAction::ForceCompression);
746 assert_eq!(result, Some("map".to_string()));
747 }
748
749 #[test]
750 fn pressure_downgrade_force_auto_to_signatures() {
751 let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
752 assert_eq!(result, Some("signatures".to_string()));
753 }
754
755 #[test]
756 fn pressure_downgrade_force_map_to_signatures() {
757 let result = pressure_downgrade("map", &PressureAction::ForceCompression);
758 assert_eq!(result, Some("signatures".to_string()));
759 }
760
761 #[test]
762 fn pressure_downgrade_force_does_not_touch_signatures() {
763 let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
764 assert!(result.is_none());
765 }
766
767 #[test]
768 fn pressure_downgrade_force_does_not_touch_lines() {
769 let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
770 assert!(result.is_none());
771 }
772
773 #[test]
774 fn pressure_downgrade_evict_full_to_map() {
775 let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
776 assert_eq!(result, Some("map".to_string()));
777 }
778
779 #[test]
780 fn pressure_downgrade_evict_auto_to_signatures() {
781 let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
782 assert_eq!(result, Some("signatures".to_string()));
783 }
784
785 #[test]
786 fn pressure_downgrade_evict_map_to_signatures() {
787 let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
788 assert_eq!(result, Some("signatures".to_string()));
789 }
790
791 #[test]
792 fn pressure_downgrade_noaction_returns_none() {
793 let result = pressure_downgrade("full", &PressureAction::NoAction);
794 assert!(result.is_none());
795 }
796
797 #[test]
798 fn pressure_downgrade_noaction_auto_returns_none() {
799 let result = pressure_downgrade("auto", &PressureAction::NoAction);
800 assert!(result.is_none());
801 }
802
803 #[test]
807 fn pre_dispatch_does_not_downgrade_full_under_force() {
808 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
809 return;
810 }
811 let result = pre_dispatch_read(
813 "nd_test.rs",
814 "full",
815 None,
816 None,
817 Some(&PressureAction::ForceCompression),
818 );
819 assert!(result.overridden_mode.is_none());
820 assert!(!result.pressure_downgraded);
821 }
822
823 #[test]
824 fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
825 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
826 return;
827 }
828 let result = pre_dispatch_read(
831 "nd_test2.rs",
832 "auto",
833 None,
834 None,
835 Some(&PressureAction::EvictLeastRelevant),
836 );
837 assert!(result.overridden_mode.is_none());
838 assert!(!result.pressure_downgraded);
839 }
840
841 #[test]
844 fn estimate_tokens_diff_mode_is_small() {
845 let tokens = estimate_read_tokens("nonexistent.rs", "diff");
846 assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
847 }
848
849 #[test]
850 fn estimate_tokens_signatures_smaller_than_full() {
851 let sig = estimate_read_tokens("nonexistent.rs", "signatures");
852 let full = estimate_read_tokens("nonexistent.rs", "full");
853 assert!(sig < full, "signatures={sig} should be < full={full}");
854 }
855
856 #[test]
857 fn estimate_tokens_lines_range() {
858 let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
859 assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
860 }
861
862 #[test]
863 fn overlay_set_view_forces_specified_mode() {
864 let dir = tempfile::tempdir().expect("tmp dir");
865 let root = dir.path();
866 let mut store = OverlayStore::new();
867 let target = ContextItemId::from_file("src/big.rs");
868 store.add(crate::core::context_overlay::ContextOverlay::new(
869 target,
870 OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
871 crate::core::context_overlay::OverlayScope::Project,
872 String::new(),
873 crate::core::context_overlay::OverlayAuthor::User,
874 ));
875 store.save_project(root).unwrap();
876
877 let result = pre_dispatch_read(
878 "src/big.rs",
879 "auto",
880 None,
881 Some(root.to_str().unwrap()),
882 None,
883 );
884 assert_eq!(result.overridden_mode, Some("map".to_string()));
885 assert_eq!(result.reason, Some("overlay-set-view"));
886 }
887}