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