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