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 {
431 if let (Some(task_str), Some(root)) = (task, project_root) {
432 let kernel =
433 crate::core::context_kernel::orchestrator::ContextKernel::for_project(root);
434 let ctx = crate::core::context_kernel::types::RetrievalContext {
435 query: task_str.to_owned(),
436 task: Some(task_str.to_owned()),
437 project_root: root.to_owned(),
438 budget: crate::core::context_field::TokenBudget {
439 total: original_tokens,
440 used: 0,
441 },
442 max_candidates: 10,
443 };
444 let plan = kernel.plan(&ctx);
445 let receipt = kernel.record_receipt(
446 &plan,
447 sent_tokens,
448 crate::core::context_kernel::types::ReceiptOutcome::Accepted,
449 );
450 let logger =
451 crate::core::context_kernel::shadow::ShadowLogger::default_for_project(root);
452 logger.log_receipt(&receipt);
453 }
454 }
455
456 PostDispatchResult {
457 eviction_hint: None,
458 elicitation_hint: elicitation,
459 resource_changed,
460 prefetch_hint,
461 }
462}
463
464fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
468 if let Some(root) = project_root.filter(|r| !r.is_empty()) {
469 let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
470 if let Some(rel) = path.strip_prefix(&root_prefix)
471 && !rel.is_empty()
472 {
473 return rel.to_string();
474 }
475 }
476 path.to_string()
477}
478
479fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
480 if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
481 {
482 return;
483 }
484 for entry in &mut ledger.entries {
485 if entry.state == Some(ContextState::Pinned) {
487 continue;
488 }
489 if entry.mode == "full" {
490 entry.mode = "map".to_string();
491 }
492 }
493}
494
495fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
496 crate::core::graph_provider::open_best_effort(project_root)
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn eviction_target_display_emits_resolvable_targets() {
505 assert_eq!(
508 eviction_target_display("/w/proj/src/a.rs", Some("/w/proj")),
509 "src/a.rs"
510 );
511 assert_eq!(
512 eviction_target_display("/other/b.rs", Some("/w/proj")),
513 "/other/b.rs"
514 );
515 assert_eq!(eviction_target_display("/x/c.rs", None), "/x/c.rs");
516 }
517
518 #[test]
519 fn pre_dispatch_passthrough_for_full() {
520 let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
521 assert!(result.overridden_mode.is_none());
522 }
523
524 #[test]
525 fn pre_dispatch_passthrough_for_diff() {
526 let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
527 assert!(result.overridden_mode.is_none());
528 }
529
530 #[test]
531 fn pre_dispatch_passthrough_for_anchored_window() {
532 {
536 let mut bt = crate::core::bounce_tracker::global()
537 .lock()
538 .unwrap_or_else(std::sync::PoisonError::into_inner);
539 bt.set_seq(101);
540 bt.record_read("anchored-bouncy.yml", "map", 30, 400);
541 bt.set_seq(102);
542 bt.record_read("anchored-bouncy.yml", "full", 400, 400);
543 bt.set_seq(103);
544 bt.record_read("a2.yml", "map", 30, 400);
545 bt.set_seq(104);
546 bt.record_read("a2.yml", "full", 400, 400);
547 bt.set_seq(105);
548 bt.record_read("a3.yml", "map", 30, 400);
549 bt.set_seq(106);
550 bt.record_read("a3.yml", "full", 400, 400);
551 }
552 let result = pre_dispatch_read("anchored-new.yml", "anchored:10-40", None, None, None);
553 assert!(
554 result.overridden_mode.is_none(),
555 "anchored:N-M must not be overridden by bounce-prevention"
556 );
557 let bare = pre_dispatch_read("anchored-new.yml", "anchored", None, None, None);
558 assert!(
559 bare.overridden_mode.is_none(),
560 "bare anchored mode must not be overridden by bounce-prevention"
561 );
562 }
563
564 #[test]
565 fn pre_dispatch_passthrough_for_lines_multi_select() {
566 {
573 let mut bt = crate::core::bounce_tracker::global()
574 .lock()
575 .unwrap_or_else(std::sync::PoisonError::into_inner);
576 bt.set_seq(201);
577 bt.record_edit("multi-select-971.rs");
578 bt.set_seq(202);
579 }
580
581 let forced = pre_dispatch_read("multi-select-971.rs", "map", None, None, None);
584 assert_eq!(
585 forced.overridden_mode.as_deref(),
586 Some("full"),
587 "precondition: a recent edit must force a non-pinned mode to full"
588 );
589
590 let multi = pre_dispatch_read(
591 "multi-select-971.rs",
592 "lines:620-622,1214-1218",
593 None,
594 None,
595 None,
596 );
597 assert!(
598 multi.overridden_mode.is_none(),
599 "lines:A-B,C-D must not be overridden by bounce-prevention (#971)"
600 );
601
602 let single = pre_dispatch_read("multi-select-971.rs", "lines:620-622", None, None, None);
604 assert!(
605 single.overridden_mode.is_none(),
606 "lines:A-B must not be overridden by bounce-prevention"
607 );
608 }
609
610 #[test]
611 fn pre_dispatch_no_override_without_signals() {
612 let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
613 assert!(result.overridden_mode.is_none());
614 }
615
616 #[test]
617 fn pre_dispatch_bounce_prevention_forces_full() {
618 {
619 let mut bt = crate::core::bounce_tracker::global()
620 .lock()
621 .unwrap_or_else(std::sync::PoisonError::into_inner);
622 bt.set_seq(1);
623 bt.record_read("src/bouncy.yml", "map", 30, 400);
624 bt.set_seq(2);
625 bt.record_read("src/bouncy.yml", "full", 400, 400);
626 bt.set_seq(3);
627 bt.record_read("a2.yml", "map", 30, 400);
628 bt.set_seq(4);
629 bt.record_read("a2.yml", "full", 400, 400);
630 bt.set_seq(5);
631 bt.record_read("a3.yml", "map", 30, 400);
632 bt.set_seq(6);
633 bt.record_read("a3.yml", "full", 400, 400);
634 }
635 let result = pre_dispatch_read("new.yml", "auto", None, None, None);
636 assert_eq!(result.overridden_mode, Some("full".to_string()));
637 assert_eq!(result.reason, Some("bounce-prevention"));
638 }
639
640 #[test]
641 fn pressure_does_not_downgrade_explicit_full() {
642 let result = pre_dispatch_read(
643 "c.rs",
644 "full",
645 None,
646 None,
647 Some(&PressureAction::ForceCompression),
648 );
649 assert!(
650 result.overridden_mode.is_none(),
651 "explicit mode=full must never be downgraded by pressure"
652 );
653 assert!(!result.pressure_downgraded);
654 }
655
656 #[test]
657 fn pressure_does_not_downgrade_when_enforce_off() {
658 let result = pre_dispatch_read(
661 "c.rs",
662 "map",
663 None,
664 None,
665 Some(&PressureAction::EvictLeastRelevant),
666 );
667 assert!(
668 result.overridden_mode.is_none(),
669 "pressure must not downgrade when degradation.enforce is off"
670 );
671 assert!(!result.pressure_downgraded);
672 }
673
674 #[test]
675 fn no_pressure_downgrade_when_low() {
676 let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
677 assert!(result.overridden_mode.is_none());
678 assert!(!result.pressure_downgraded);
679 }
680
681 #[test]
682 fn suggest_compression_does_not_downgrade_when_enforce_off() {
683 let result = pre_dispatch_read(
685 "c.rs",
686 "auto",
687 None,
688 None,
689 Some(&PressureAction::SuggestCompression),
690 );
691 assert!(
692 result.overridden_mode.is_none(),
693 "suggest_compression must not downgrade when enforce is off"
694 );
695 assert!(!result.pressure_downgraded);
696 }
697
698 #[test]
699 fn suggest_compression_does_not_touch_explicit_full() {
700 let result = pre_dispatch_read(
701 "c.rs",
702 "full",
703 None,
704 None,
705 Some(&PressureAction::SuggestCompression),
706 );
707 assert!(result.overridden_mode.is_none());
708 assert!(!result.pressure_downgraded);
709 }
710
711 #[test]
712 fn post_dispatch_reinjection_downgrades_entries() {
713 let mut ledger = ContextLedger::with_window_size(1000);
714 ledger.record("a.rs", "full", 400, 400);
715 ledger.record("b.rs", "full", 400, 400);
716 let overlay = OverlayStore::new();
717 let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
718 assert!(result.resource_changed);
719 let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
720 assert_eq!(a_entry.mode, "map");
721 }
722
723 #[test]
724 fn ignited_item_resists_reinjection_downgrade() {
725 let mut ledger = ContextLedger::with_window_size(1000);
728 for i in 0..5 {
729 ledger.record(&format!("bg{i}.rs"), "full", 250, 250);
730 }
731 ledger.record("hot.rs", "full", 250, 250);
732 for e in &mut ledger.entries {
735 e.phi = Some(if e.path == "hot.rs" { 0.97 } else { 0.1 });
736 }
737 let ignited = ledger.ignite_high_salience();
738 assert_eq!(ignited, vec!["hot.rs".to_string()], "outlier should ignite");
739
740 apply_reinjection_plan(&mut ledger, &PressureAction::ForceCompression);
741 let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
742 assert_eq!(hot.mode, "full", "ignited item keeps its full view");
743 let bg = ledger.entries.iter().find(|e| e.path == "bg0.rs").unwrap();
744 assert_eq!(bg.mode, "map", "non-ignited items are downgraded");
745 }
746
747 #[test]
748 fn overlay_pin_forces_full_mode() {
749 let dir = tempfile::tempdir().expect("tmp dir");
750 let root = dir.path();
751 let mut store = OverlayStore::new();
752 let target = ContextItemId::from_file("src/important.rs");
753 store.add(crate::core::context_overlay::ContextOverlay::new(
754 target,
755 OverlayOp::Pin { verbatim: false },
756 crate::core::context_overlay::OverlayScope::Project,
757 String::new(),
758 crate::core::context_overlay::OverlayAuthor::User,
759 ));
760 store.save_project(root).unwrap();
761
762 let result = pre_dispatch_read(
763 "src/important.rs",
764 "auto",
765 None,
766 Some(root.to_str().unwrap()),
767 None,
768 );
769 assert_eq!(result.overridden_mode, Some("full".to_string()));
770 assert_eq!(result.reason, Some("pinned"));
771 }
772
773 #[test]
774 fn overlay_exclude_forces_signatures_mode() {
775 let dir = tempfile::tempdir().expect("tmp dir");
776 let root = dir.path();
777 let mut store = OverlayStore::new();
778 let target = ContextItemId::from_file("src/noisy.rs");
779 store.add(crate::core::context_overlay::ContextOverlay::new(
780 target,
781 OverlayOp::Exclude {
782 reason: "noise".to_string(),
783 },
784 crate::core::context_overlay::OverlayScope::Project,
785 String::new(),
786 crate::core::context_overlay::OverlayAuthor::User,
787 ));
788 store.save_project(root).unwrap();
789
790 let result = pre_dispatch_read(
791 "src/noisy.rs",
792 "auto",
793 None,
794 Some(root.to_str().unwrap()),
795 None,
796 );
797 assert_eq!(result.overridden_mode, Some("signatures".to_string()));
798 assert_eq!(result.reason, Some("excluded"));
799 }
800
801 #[test]
804 fn pressure_downgrade_suggest_auto_to_map() {
805 let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
806 assert_eq!(result, Some("map".to_string()));
807 }
808
809 #[test]
810 fn pressure_downgrade_suggest_full_to_map() {
811 let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
812 assert_eq!(result, Some("map".to_string()));
813 }
814
815 #[test]
816 fn pressure_downgrade_suggest_does_not_touch_signatures() {
817 let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
818 assert!(result.is_none());
819 }
820
821 #[test]
822 fn pressure_downgrade_suggest_does_not_touch_diff() {
823 let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
824 assert!(result.is_none());
825 }
826
827 #[test]
828 fn pressure_downgrade_force_full_to_map() {
829 let result = pressure_downgrade("full", &PressureAction::ForceCompression);
830 assert_eq!(result, Some("map".to_string()));
831 }
832
833 #[test]
834 fn pressure_downgrade_force_auto_to_signatures() {
835 let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
836 assert_eq!(result, Some("signatures".to_string()));
837 }
838
839 #[test]
840 fn pressure_downgrade_force_map_to_signatures() {
841 let result = pressure_downgrade("map", &PressureAction::ForceCompression);
842 assert_eq!(result, Some("signatures".to_string()));
843 }
844
845 #[test]
846 fn pressure_downgrade_force_does_not_touch_signatures() {
847 let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
848 assert!(result.is_none());
849 }
850
851 #[test]
852 fn pressure_downgrade_force_does_not_touch_lines() {
853 let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
854 assert!(result.is_none());
855 }
856
857 #[test]
858 fn pressure_downgrade_evict_full_to_map() {
859 let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
860 assert_eq!(result, Some("map".to_string()));
861 }
862
863 #[test]
864 fn pressure_downgrade_evict_auto_to_signatures() {
865 let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
866 assert_eq!(result, Some("signatures".to_string()));
867 }
868
869 #[test]
870 fn pressure_downgrade_evict_map_to_signatures() {
871 let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
872 assert_eq!(result, Some("signatures".to_string()));
873 }
874
875 #[test]
876 fn pressure_downgrade_noaction_returns_none() {
877 let result = pressure_downgrade("full", &PressureAction::NoAction);
878 assert!(result.is_none());
879 }
880
881 #[test]
882 fn pressure_downgrade_noaction_auto_returns_none() {
883 let result = pressure_downgrade("auto", &PressureAction::NoAction);
884 assert!(result.is_none());
885 }
886
887 #[test]
891 fn pre_dispatch_does_not_downgrade_full_under_force() {
892 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
893 return;
894 }
895 let result = pre_dispatch_read(
897 "nd_test.rs",
898 "full",
899 None,
900 None,
901 Some(&PressureAction::ForceCompression),
902 );
903 assert!(result.overridden_mode.is_none());
904 assert!(!result.pressure_downgraded);
905 }
906
907 #[test]
908 fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
909 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
910 return;
911 }
912 let result = pre_dispatch_read(
915 "nd_test2.rs",
916 "auto",
917 None,
918 None,
919 Some(&PressureAction::EvictLeastRelevant),
920 );
921 assert!(result.overridden_mode.is_none());
922 assert!(!result.pressure_downgraded);
923 }
924
925 #[test]
928 fn estimate_tokens_diff_mode_is_small() {
929 let tokens = estimate_read_tokens("nonexistent.rs", "diff");
930 assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
931 }
932
933 #[test]
934 fn estimate_tokens_signatures_smaller_than_full() {
935 let sig = estimate_read_tokens("nonexistent.rs", "signatures");
936 let full = estimate_read_tokens("nonexistent.rs", "full");
937 assert!(sig < full, "signatures={sig} should be < full={full}");
938 }
939
940 #[test]
941 fn estimate_tokens_lines_range() {
942 let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
943 assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
944 }
945
946 #[test]
947 fn overlay_set_view_forces_specified_mode() {
948 let dir = tempfile::tempdir().expect("tmp dir");
949 let root = dir.path();
950 let mut store = OverlayStore::new();
951 let target = ContextItemId::from_file("src/big.rs");
952 store.add(crate::core::context_overlay::ContextOverlay::new(
953 target,
954 OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
955 crate::core::context_overlay::OverlayScope::Project,
956 String::new(),
957 crate::core::context_overlay::OverlayAuthor::User,
958 ));
959 store.save_project(root).unwrap();
960
961 let result = pre_dispatch_read(
962 "src/big.rs",
963 "auto",
964 None,
965 Some(root.to_str().unwrap()),
966 None,
967 );
968 assert_eq!(result.overridden_mode, Some("map".to_string()));
969 assert_eq!(result.reason, Some("overlay-set-view"));
970 }
971}