Skip to main content

lean_ctx/server/
context_gate.rs

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