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 mut selected = 0usize;
261 for part in range.split(',') {
262 let part = part.trim();
263 selected += if let Some((s, e)) = part.split_once('-') {
264 let start = s.trim().parse::<usize>().unwrap_or(1);
265 let end = e.trim().parse::<usize>().unwrap_or(start + 100);
266 end.saturating_sub(start) + 1
267 } else {
268 1
269 };
270 }
271 if selected == 0 {
272 full_tokens / 10
273 } else {
274 selected * 10
275 }
276 } else {
277 full_tokens / 10
278 }
279 }
280 _ => full_tokens,
281 }
282}
283
284fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
285 crate::core::auto_mode_resolver::pressure_downgrade(requested_mode, action)
286}
287
288fn check_overlay_mode_override(
289 path: &str,
290 requested_mode: &str,
291 overlay: &OverlayStore,
292) -> Option<PreDispatchResult> {
293 let item_id = ContextItemId::from_file(path);
294 let overlays = overlay.for_item(&item_id);
295
296 for ov in overlays.iter().rev() {
297 match &ov.operation {
298 OverlayOp::SetView(view) => {
299 let mode_str = view.as_str();
300 if mode_str != requested_mode {
301 return Some(PreDispatchResult {
302 overridden_mode: Some(mode_str.to_string()),
303 reason: Some("overlay-set-view"),
304 pressure_downgraded: false,
305 budget_blocked: false,
306 budget_warning: None,
307 });
308 }
309 }
310 OverlayOp::Pin { .. } if requested_mode != "full" => {
311 return Some(PreDispatchResult {
312 overridden_mode: Some("full".to_string()),
313 reason: Some("pinned"),
314 pressure_downgraded: false,
315 budget_blocked: false,
316 budget_warning: None,
317 });
318 }
319 OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
320 return Some(PreDispatchResult {
321 overridden_mode: Some("signatures".to_string()),
322 reason: Some("excluded"),
323 pressure_downgraded: false,
324 budget_blocked: false,
325 budget_warning: None,
326 });
327 }
328 _ => {}
329 }
330 }
331 None
332}
333
334pub fn post_dispatch_record(
335 path: &str,
336 mode: &str,
337 original_tokens: usize,
338 sent_tokens: usize,
339 ledger: &mut ContextLedger,
340 overlay: &OverlayStore,
341) -> PostDispatchResult {
342 post_dispatch_record_with_task(
343 path,
344 mode,
345 original_tokens,
346 sent_tokens,
347 ledger,
348 overlay,
349 None,
350 None,
351 )
352}
353
354pub fn post_dispatch_record_with_task(
355 path: &str,
356 mode: &str,
357 original_tokens: usize,
358 sent_tokens: usize,
359 ledger: &mut ContextLedger,
360 overlay: &OverlayStore,
361 task: Option<&str>,
362 project_root: Option<&str>,
363) -> PostDispatchResult {
364 let prev_count = ledger.entries.len();
365 let prev_pressure = ledger.pressure().recommendation;
366
367 ledger.record_with_task(path, mode, original_tokens, sent_tokens, task);
368
369 let item_id = ContextItemId::from_file(path);
370 let state = overlay.apply_to_state(&item_id, ContextState::Included);
371
372 if state == ContextState::Excluded {
373 return PostDispatchResult {
374 eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
375 elicitation_hint: None,
376 resource_changed: true,
377 prefetch_hint: None,
378 };
379 }
380
381 let elicitation =
382 super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
383 .map(|s| s.format_fallback_hint());
384
385 let pressure = ledger.pressure();
386
387 let ignited = ledger.ignite_high_salience();
391
392 apply_reinjection_plan(ledger, &pressure.recommendation);
393
394 let new_entry = ledger.entries.len() != prev_count;
395 let pressure_shifted = pressure.recommendation != prev_pressure;
396 let resource_changed = new_entry || pressure_shifted || !ignited.is_empty();
397
398 if pressure.utilization > 0.9 {
399 let candidates = ledger.eviction_candidates_by_phi(3);
400 if !candidates.is_empty() {
401 let names: Vec<_> = candidates
405 .iter()
406 .take(3)
407 .map(|p| eviction_target_display(p, project_root))
408 .collect();
409 return PostDispatchResult {
410 eviction_hint: Some(format!(
411 "Context pressure {:.0}%. Evict: ctx_ledger(action=\"evict\", targets=\"{}\")",
412 pressure.utilization * 100.0,
413 names.join(", ")
414 )),
415 elicitation_hint: elicitation,
416 resource_changed,
417 prefetch_hint: None,
419 };
420 }
421 }
422
423 let prefetch_hint =
427 project_root.and_then(|root| crate::core::fep_prefetch::prefetch_hint(root, path, ledger));
428
429 PostDispatchResult {
430 eviction_hint: None,
431 elicitation_hint: elicitation,
432 resource_changed,
433 prefetch_hint,
434 }
435}
436
437fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
441 if let Some(root) = project_root.filter(|r| !r.is_empty()) {
442 let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
443 if let Some(rel) = path.strip_prefix(&root_prefix)
444 && !rel.is_empty()
445 {
446 return rel.to_string();
447 }
448 }
449 path.to_string()
450}
451
452fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
453 if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
454 {
455 return;
456 }
457 for entry in &mut ledger.entries {
458 if entry.state == Some(ContextState::Pinned) {
460 continue;
461 }
462 if entry.mode == "full" {
463 entry.mode = "map".to_string();
464 }
465 }
466}
467
468fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
469 crate::core::graph_provider::open_best_effort(project_root)
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn eviction_target_display_emits_resolvable_targets() {
478 assert_eq!(
481 eviction_target_display("/w/proj/src/a.rs", Some("/w/proj")),
482 "src/a.rs"
483 );
484 assert_eq!(
485 eviction_target_display("/other/b.rs", Some("/w/proj")),
486 "/other/b.rs"
487 );
488 assert_eq!(eviction_target_display("/x/c.rs", None), "/x/c.rs");
489 }
490
491 #[test]
492 fn pre_dispatch_passthrough_for_full() {
493 let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
494 assert!(result.overridden_mode.is_none());
495 }
496
497 #[test]
498 fn pre_dispatch_passthrough_for_diff() {
499 let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
500 assert!(result.overridden_mode.is_none());
501 }
502
503 #[test]
504 fn pre_dispatch_passthrough_for_anchored_window() {
505 {
509 let mut bt = crate::core::bounce_tracker::global()
510 .lock()
511 .unwrap_or_else(std::sync::PoisonError::into_inner);
512 bt.set_seq(101);
513 bt.record_read("anchored-bouncy.yml", "map", 30, 400);
514 bt.set_seq(102);
515 bt.record_read("anchored-bouncy.yml", "full", 400, 400);
516 bt.set_seq(103);
517 bt.record_read("a2.yml", "map", 30, 400);
518 bt.set_seq(104);
519 bt.record_read("a2.yml", "full", 400, 400);
520 bt.set_seq(105);
521 bt.record_read("a3.yml", "map", 30, 400);
522 bt.set_seq(106);
523 bt.record_read("a3.yml", "full", 400, 400);
524 }
525 let result = pre_dispatch_read("anchored-new.yml", "anchored:10-40", None, None, None);
526 assert!(
527 result.overridden_mode.is_none(),
528 "anchored:N-M must not be overridden by bounce-prevention"
529 );
530 let bare = pre_dispatch_read("anchored-new.yml", "anchored", None, None, None);
531 assert!(
532 bare.overridden_mode.is_none(),
533 "bare anchored mode must not be overridden by bounce-prevention"
534 );
535 }
536
537 #[test]
538 fn pre_dispatch_passthrough_for_lines_multi_select() {
539 {
546 let mut bt = crate::core::bounce_tracker::global()
547 .lock()
548 .unwrap_or_else(std::sync::PoisonError::into_inner);
549 bt.set_seq(201);
550 bt.record_edit("multi-select-971.rs");
551 bt.set_seq(202);
552 }
553
554 let forced = pre_dispatch_read("multi-select-971.rs", "map", None, None, None);
557 assert_eq!(
558 forced.overridden_mode.as_deref(),
559 Some("full"),
560 "precondition: a recent edit must force a non-pinned mode to full"
561 );
562
563 let multi = pre_dispatch_read(
564 "multi-select-971.rs",
565 "lines:620-622,1214-1218",
566 None,
567 None,
568 None,
569 );
570 assert!(
571 multi.overridden_mode.is_none(),
572 "lines:A-B,C-D must not be overridden by bounce-prevention (#971)"
573 );
574
575 let single = pre_dispatch_read("multi-select-971.rs", "lines:620-622", None, None, None);
577 assert!(
578 single.overridden_mode.is_none(),
579 "lines:A-B must not be overridden by bounce-prevention"
580 );
581 }
582
583 #[test]
584 fn pre_dispatch_no_override_without_signals() {
585 let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
586 assert!(result.overridden_mode.is_none());
587 }
588
589 #[test]
590 fn pre_dispatch_bounce_prevention_forces_full() {
591 {
592 let mut bt = crate::core::bounce_tracker::global()
593 .lock()
594 .unwrap_or_else(std::sync::PoisonError::into_inner);
595 bt.set_seq(1);
596 bt.record_read("src/bouncy.yml", "map", 30, 400);
597 bt.set_seq(2);
598 bt.record_read("src/bouncy.yml", "full", 400, 400);
599 bt.set_seq(3);
600 bt.record_read("a2.yml", "map", 30, 400);
601 bt.set_seq(4);
602 bt.record_read("a2.yml", "full", 400, 400);
603 bt.set_seq(5);
604 bt.record_read("a3.yml", "map", 30, 400);
605 bt.set_seq(6);
606 bt.record_read("a3.yml", "full", 400, 400);
607 }
608 let result = pre_dispatch_read("new.yml", "auto", None, None, None);
609 assert_eq!(result.overridden_mode, Some("full".to_string()));
610 assert_eq!(result.reason, Some("bounce-prevention"));
611 }
612
613 #[test]
614 fn pressure_does_not_downgrade_explicit_full() {
615 let result = pre_dispatch_read(
616 "c.rs",
617 "full",
618 None,
619 None,
620 Some(&PressureAction::ForceCompression),
621 );
622 assert!(
623 result.overridden_mode.is_none(),
624 "explicit mode=full must never be downgraded by pressure"
625 );
626 assert!(!result.pressure_downgraded);
627 }
628
629 #[test]
630 fn pressure_does_not_downgrade_when_enforce_off() {
631 let result = pre_dispatch_read(
634 "c.rs",
635 "map",
636 None,
637 None,
638 Some(&PressureAction::EvictLeastRelevant),
639 );
640 assert!(
641 result.overridden_mode.is_none(),
642 "pressure must not downgrade when degradation.enforce is off"
643 );
644 assert!(!result.pressure_downgraded);
645 }
646
647 #[test]
648 fn no_pressure_downgrade_when_low() {
649 let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
650 assert!(result.overridden_mode.is_none());
651 assert!(!result.pressure_downgraded);
652 }
653
654 #[test]
655 fn suggest_compression_does_not_downgrade_when_enforce_off() {
656 let result = pre_dispatch_read(
658 "c.rs",
659 "auto",
660 None,
661 None,
662 Some(&PressureAction::SuggestCompression),
663 );
664 assert!(
665 result.overridden_mode.is_none(),
666 "suggest_compression must not downgrade when enforce is off"
667 );
668 assert!(!result.pressure_downgraded);
669 }
670
671 #[test]
672 fn suggest_compression_does_not_touch_explicit_full() {
673 let result = pre_dispatch_read(
674 "c.rs",
675 "full",
676 None,
677 None,
678 Some(&PressureAction::SuggestCompression),
679 );
680 assert!(result.overridden_mode.is_none());
681 assert!(!result.pressure_downgraded);
682 }
683
684 #[test]
685 fn post_dispatch_reinjection_downgrades_entries() {
686 let mut ledger = ContextLedger::with_window_size(1000);
687 ledger.record("a.rs", "full", 400, 400);
688 ledger.record("b.rs", "full", 400, 400);
689 let overlay = OverlayStore::new();
690 let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
691 assert!(result.resource_changed);
692 let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
693 assert_eq!(a_entry.mode, "map");
694 }
695
696 #[test]
697 fn ignited_item_resists_reinjection_downgrade() {
698 let mut ledger = ContextLedger::with_window_size(1000);
701 for i in 0..5 {
702 ledger.record(&format!("bg{i}.rs"), "full", 250, 250);
703 }
704 ledger.record("hot.rs", "full", 250, 250);
705 for e in &mut ledger.entries {
708 e.phi = Some(if e.path == "hot.rs" { 0.97 } else { 0.1 });
709 }
710 let ignited = ledger.ignite_high_salience();
711 assert_eq!(ignited, vec!["hot.rs".to_string()], "outlier should ignite");
712
713 apply_reinjection_plan(&mut ledger, &PressureAction::ForceCompression);
714 let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
715 assert_eq!(hot.mode, "full", "ignited item keeps its full view");
716 let bg = ledger.entries.iter().find(|e| e.path == "bg0.rs").unwrap();
717 assert_eq!(bg.mode, "map", "non-ignited items are downgraded");
718 }
719
720 #[test]
721 fn overlay_pin_forces_full_mode() {
722 let dir = tempfile::tempdir().expect("tmp dir");
723 let root = dir.path();
724 let mut store = OverlayStore::new();
725 let target = ContextItemId::from_file("src/important.rs");
726 store.add(crate::core::context_overlay::ContextOverlay::new(
727 target,
728 OverlayOp::Pin { verbatim: false },
729 crate::core::context_overlay::OverlayScope::Project,
730 String::new(),
731 crate::core::context_overlay::OverlayAuthor::User,
732 ));
733 store.save_project(root).unwrap();
734
735 let result = pre_dispatch_read(
736 "src/important.rs",
737 "auto",
738 None,
739 Some(root.to_str().unwrap()),
740 None,
741 );
742 assert_eq!(result.overridden_mode, Some("full".to_string()));
743 assert_eq!(result.reason, Some("pinned"));
744 }
745
746 #[test]
747 fn overlay_exclude_forces_signatures_mode() {
748 let dir = tempfile::tempdir().expect("tmp dir");
749 let root = dir.path();
750 let mut store = OverlayStore::new();
751 let target = ContextItemId::from_file("src/noisy.rs");
752 store.add(crate::core::context_overlay::ContextOverlay::new(
753 target,
754 OverlayOp::Exclude {
755 reason: "noise".to_string(),
756 },
757 crate::core::context_overlay::OverlayScope::Project,
758 String::new(),
759 crate::core::context_overlay::OverlayAuthor::User,
760 ));
761 store.save_project(root).unwrap();
762
763 let result = pre_dispatch_read(
764 "src/noisy.rs",
765 "auto",
766 None,
767 Some(root.to_str().unwrap()),
768 None,
769 );
770 assert_eq!(result.overridden_mode, Some("signatures".to_string()));
771 assert_eq!(result.reason, Some("excluded"));
772 }
773
774 #[test]
777 fn pressure_downgrade_suggest_auto_to_map() {
778 let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
779 assert_eq!(result, Some("map".to_string()));
780 }
781
782 #[test]
783 fn pressure_downgrade_suggest_full_to_map() {
784 let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
785 assert_eq!(result, Some("map".to_string()));
786 }
787
788 #[test]
789 fn pressure_downgrade_suggest_does_not_touch_signatures() {
790 let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
791 assert!(result.is_none());
792 }
793
794 #[test]
795 fn pressure_downgrade_suggest_does_not_touch_diff() {
796 let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
797 assert!(result.is_none());
798 }
799
800 #[test]
801 fn pressure_downgrade_force_full_to_map() {
802 let result = pressure_downgrade("full", &PressureAction::ForceCompression);
803 assert_eq!(result, Some("map".to_string()));
804 }
805
806 #[test]
807 fn pressure_downgrade_force_auto_to_signatures() {
808 let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
809 assert_eq!(result, Some("signatures".to_string()));
810 }
811
812 #[test]
813 fn pressure_downgrade_force_map_to_signatures() {
814 let result = pressure_downgrade("map", &PressureAction::ForceCompression);
815 assert_eq!(result, Some("signatures".to_string()));
816 }
817
818 #[test]
819 fn pressure_downgrade_force_does_not_touch_signatures() {
820 let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
821 assert!(result.is_none());
822 }
823
824 #[test]
825 fn pressure_downgrade_force_does_not_touch_lines() {
826 let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
827 assert!(result.is_none());
828 }
829
830 #[test]
831 fn pressure_downgrade_evict_full_to_map() {
832 let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
833 assert_eq!(result, Some("map".to_string()));
834 }
835
836 #[test]
837 fn pressure_downgrade_evict_auto_to_signatures() {
838 let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
839 assert_eq!(result, Some("signatures".to_string()));
840 }
841
842 #[test]
843 fn pressure_downgrade_evict_map_to_signatures() {
844 let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
845 assert_eq!(result, Some("signatures".to_string()));
846 }
847
848 #[test]
849 fn pressure_downgrade_noaction_returns_none() {
850 let result = pressure_downgrade("full", &PressureAction::NoAction);
851 assert!(result.is_none());
852 }
853
854 #[test]
855 fn pressure_downgrade_noaction_auto_returns_none() {
856 let result = pressure_downgrade("auto", &PressureAction::NoAction);
857 assert!(result.is_none());
858 }
859
860 #[test]
864 fn pre_dispatch_does_not_downgrade_full_under_force() {
865 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
866 return;
867 }
868 let result = pre_dispatch_read(
870 "nd_test.rs",
871 "full",
872 None,
873 None,
874 Some(&PressureAction::ForceCompression),
875 );
876 assert!(result.overridden_mode.is_none());
877 assert!(!result.pressure_downgraded);
878 }
879
880 #[test]
881 fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
882 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
883 return;
884 }
885 let result = pre_dispatch_read(
888 "nd_test2.rs",
889 "auto",
890 None,
891 None,
892 Some(&PressureAction::EvictLeastRelevant),
893 );
894 assert!(result.overridden_mode.is_none());
895 assert!(!result.pressure_downgraded);
896 }
897
898 #[test]
901 fn estimate_tokens_diff_mode_is_small() {
902 let tokens = estimate_read_tokens("nonexistent.rs", "diff");
903 assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
904 }
905
906 #[test]
907 fn estimate_tokens_signatures_smaller_than_full() {
908 let sig = estimate_read_tokens("nonexistent.rs", "signatures");
909 let full = estimate_read_tokens("nonexistent.rs", "full");
910 assert!(sig < full, "signatures={sig} should be < full={full}");
911 }
912
913 #[test]
914 fn estimate_tokens_lines_range() {
915 let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
916 assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
917 }
918
919 #[test]
920 fn overlay_set_view_forces_specified_mode() {
921 let dir = tempfile::tempdir().expect("tmp dir");
922 let root = dir.path();
923 let mut store = OverlayStore::new();
924 let target = ContextItemId::from_file("src/big.rs");
925 store.add(crate::core::context_overlay::ContextOverlay::new(
926 target,
927 OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
928 crate::core::context_overlay::OverlayScope::Project,
929 String::new(),
930 crate::core::context_overlay::OverlayAuthor::User,
931 ));
932 store.save_project(root).unwrap();
933
934 let result = pre_dispatch_read(
935 "src/big.rs",
936 "auto",
937 None,
938 Some(root.to_str().unwrap()),
939 None,
940 );
941 assert_eq!(result.overridden_mode, Some("map".to_string()));
942 assert_eq!(result.reason, Some("overlay-set-view"));
943 }
944}