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    /// FEP prefetch suggestion (#9): files likely needed next, from the co-access
20    /// graph. A warmup hint only — never an automatic read.
21    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    // Explicit mode=full must not be downgraded by pressure or other heuristics.
117    // Only overlays (user-explicit) above can override it.
118    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    // #6 Global-Workspace ignition: salience outliers are broadcast (pinned) into
361    // the working set BEFORE reinjection, so an ignited item keeps its view while
362    // the rest are downgraded under pressure. Deterministic z-score threshold.
363    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                // Under pressure we evict rather than prefetch — no warmup hint.
388                prefetch_hint: None,
389            };
390        }
391    }
392
393    // #9 FEP prefetch: with budget to spare, suggest the files most likely needed
394    // next (co-access graph), so the agent can warm them before the surprise of a
395    // miss. Deterministic; runs in the background post-dispatch, never in output.
396    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        // #6: ignited / user-pinned items stay broadcast — never downgraded.
414        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        // Default profile has degradation.enforce = false, so pressure
490        // should NOT downgrade any mode.
491        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        // Default profile has degradation.enforce = false
515        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        // #6: a high-salience outlier ignites (pins) and keeps its full view,
557        // while the rest are downgraded to map by pressure reinjection.
558        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        // Set the salience distribution explicitly (record recomputes Phi, so we
564        // overwrite afterwards) to make ignition deterministic in the test.
565        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    // --- pressure_downgrade unit tests (pure function) ---
633
634    #[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    // --- pre_dispatch_inner: no_degrade integration ---
719    // When LCTX_NO_DEGRADE is NOT set (test default), pressure downgrade is active.
720
721    #[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        // Explicit mode=full is protected: pressure cannot downgrade it
727        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        // Default profile has degradation.enforce = false, so pressure
744        // should not downgrade even non-full modes
745        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    // --- estimate_read_tokens unit tests ---
757
758    #[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}