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            // #715: emit targets the evict resolver can actually find —
375            // root-relative paths (or the full path), never display-shortened
376            // forms that used to produce "Evicted 0/N".
377            let names: Vec<_> = candidates
378                .iter()
379                .take(3)
380                .map(|p| eviction_target_display(p, project_root))
381                .collect();
382            return PostDispatchResult {
383                eviction_hint: Some(format!(
384                    "Context pressure {:.0}%. Evict: ctx_ledger(action=\"evict\", targets=\"{}\")",
385                    pressure.utilization * 100.0,
386                    names.join(", ")
387                )),
388                elicitation_hint: elicitation,
389                resource_changed,
390                // Under pressure we evict rather than prefetch — no warmup hint.
391                prefetch_hint: None,
392            };
393        }
394    }
395
396    // #9 FEP prefetch: with budget to spare, suggest the files most likely needed
397    // next (co-access graph), so the agent can warm them before the surprise of a
398    // miss. Deterministic; runs in the background post-dispatch, never in output.
399    let prefetch_hint =
400        project_root.and_then(|root| crate::core::fep_prefetch::prefetch_hint(root, path, ledger));
401
402    PostDispatchResult {
403        eviction_hint: None,
404        elicitation_hint: elicitation,
405        resource_changed,
406        prefetch_hint,
407    }
408}
409
410/// #715: a resolvable evict target for hint output — project-root-relative
411/// when the candidate lives under the root, otherwise the full canonical
412/// path. Both forms round-trip through `ContextLedger::resolve_entry`.
413fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
414    if let Some(root) = project_root.filter(|r| !r.is_empty()) {
415        let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
416        if let Some(rel) = path.strip_prefix(&root_prefix)
417            && !rel.is_empty()
418        {
419            return rel.to_string();
420        }
421    }
422    path.to_string()
423}
424
425fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
426    if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
427    {
428        return;
429    }
430    for entry in &mut ledger.entries {
431        // #6: ignited / user-pinned items stay broadcast — never downgraded.
432        if entry.state == Some(ContextState::Pinned) {
433            continue;
434        }
435        if entry.mode == "full" {
436            entry.mode = "map".to_string();
437        }
438    }
439}
440
441fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
442    crate::core::graph_provider::open_best_effort(project_root)
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn eviction_target_display_emits_resolvable_targets() {
451        // #715: root-relative when under the root, full path otherwise —
452        // never a display-shortened form the resolver cannot find.
453        assert_eq!(
454            eviction_target_display("/w/proj/src/a.rs", Some("/w/proj")),
455            "src/a.rs"
456        );
457        assert_eq!(
458            eviction_target_display("/other/b.rs", Some("/w/proj")),
459            "/other/b.rs"
460        );
461        assert_eq!(eviction_target_display("/x/c.rs", None), "/x/c.rs");
462    }
463
464    #[test]
465    fn pre_dispatch_passthrough_for_full() {
466        let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
467        assert!(result.overridden_mode.is_none());
468    }
469
470    #[test]
471    fn pre_dispatch_passthrough_for_diff() {
472        let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
473        assert!(result.overridden_mode.is_none());
474    }
475
476    #[test]
477    fn pre_dispatch_no_override_without_signals() {
478        let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
479        assert!(result.overridden_mode.is_none());
480    }
481
482    #[test]
483    fn pre_dispatch_bounce_prevention_forces_full() {
484        {
485            let mut bt = crate::core::bounce_tracker::global().lock().unwrap();
486            bt.set_seq(1);
487            bt.record_read("src/bouncy.yml", "map", 30, 400);
488            bt.set_seq(2);
489            bt.record_read("src/bouncy.yml", "full", 400, 400);
490            bt.set_seq(3);
491            bt.record_read("a2.yml", "map", 30, 400);
492            bt.set_seq(4);
493            bt.record_read("a2.yml", "full", 400, 400);
494            bt.set_seq(5);
495            bt.record_read("a3.yml", "map", 30, 400);
496            bt.set_seq(6);
497            bt.record_read("a3.yml", "full", 400, 400);
498        }
499        let result = pre_dispatch_read("new.yml", "auto", None, None, None);
500        assert_eq!(result.overridden_mode, Some("full".to_string()));
501        assert_eq!(result.reason, Some("bounce-prevention"));
502    }
503
504    #[test]
505    fn pressure_does_not_downgrade_explicit_full() {
506        let result = pre_dispatch_read(
507            "c.rs",
508            "full",
509            None,
510            None,
511            Some(&PressureAction::ForceCompression),
512        );
513        assert!(
514            result.overridden_mode.is_none(),
515            "explicit mode=full must never be downgraded by pressure"
516        );
517        assert!(!result.pressure_downgraded);
518    }
519
520    #[test]
521    fn pressure_does_not_downgrade_when_enforce_off() {
522        // Default profile has degradation.enforce = false, so pressure
523        // should NOT downgrade any mode.
524        let result = pre_dispatch_read(
525            "c.rs",
526            "map",
527            None,
528            None,
529            Some(&PressureAction::EvictLeastRelevant),
530        );
531        assert!(
532            result.overridden_mode.is_none(),
533            "pressure must not downgrade when degradation.enforce is off"
534        );
535        assert!(!result.pressure_downgraded);
536    }
537
538    #[test]
539    fn no_pressure_downgrade_when_low() {
540        let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
541        assert!(result.overridden_mode.is_none());
542        assert!(!result.pressure_downgraded);
543    }
544
545    #[test]
546    fn suggest_compression_does_not_downgrade_when_enforce_off() {
547        // Default profile has degradation.enforce = false
548        let result = pre_dispatch_read(
549            "c.rs",
550            "auto",
551            None,
552            None,
553            Some(&PressureAction::SuggestCompression),
554        );
555        assert!(
556            result.overridden_mode.is_none(),
557            "suggest_compression must not downgrade when enforce is off"
558        );
559        assert!(!result.pressure_downgraded);
560    }
561
562    #[test]
563    fn suggest_compression_does_not_touch_explicit_full() {
564        let result = pre_dispatch_read(
565            "c.rs",
566            "full",
567            None,
568            None,
569            Some(&PressureAction::SuggestCompression),
570        );
571        assert!(result.overridden_mode.is_none());
572        assert!(!result.pressure_downgraded);
573    }
574
575    #[test]
576    fn post_dispatch_reinjection_downgrades_entries() {
577        let mut ledger = ContextLedger::with_window_size(1000);
578        ledger.record("a.rs", "full", 400, 400);
579        ledger.record("b.rs", "full", 400, 400);
580        let overlay = OverlayStore::new();
581        let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
582        assert!(result.resource_changed);
583        let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
584        assert_eq!(a_entry.mode, "map");
585    }
586
587    #[test]
588    fn ignited_item_resists_reinjection_downgrade() {
589        // #6: a high-salience outlier ignites (pins) and keeps its full view,
590        // while the rest are downgraded to map by pressure reinjection.
591        let mut ledger = ContextLedger::with_window_size(1000);
592        for i in 0..5 {
593            ledger.record(&format!("bg{i}.rs"), "full", 250, 250);
594        }
595        ledger.record("hot.rs", "full", 250, 250);
596        // Set the salience distribution explicitly (record recomputes Phi, so we
597        // overwrite afterwards) to make ignition deterministic in the test.
598        for e in &mut ledger.entries {
599            e.phi = Some(if e.path == "hot.rs" { 0.97 } else { 0.1 });
600        }
601        let ignited = ledger.ignite_high_salience();
602        assert_eq!(ignited, vec!["hot.rs".to_string()], "outlier should ignite");
603
604        apply_reinjection_plan(&mut ledger, &PressureAction::ForceCompression);
605        let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
606        assert_eq!(hot.mode, "full", "ignited item keeps its full view");
607        let bg = ledger.entries.iter().find(|e| e.path == "bg0.rs").unwrap();
608        assert_eq!(bg.mode, "map", "non-ignited items are downgraded");
609    }
610
611    #[test]
612    fn overlay_pin_forces_full_mode() {
613        let dir = tempfile::tempdir().expect("tmp dir");
614        let root = dir.path();
615        let mut store = OverlayStore::new();
616        let target = ContextItemId::from_file("src/important.rs");
617        store.add(crate::core::context_overlay::ContextOverlay::new(
618            target,
619            OverlayOp::Pin { verbatim: false },
620            crate::core::context_overlay::OverlayScope::Project,
621            String::new(),
622            crate::core::context_overlay::OverlayAuthor::User,
623        ));
624        store.save_project(root).unwrap();
625
626        let result = pre_dispatch_read(
627            "src/important.rs",
628            "auto",
629            None,
630            Some(root.to_str().unwrap()),
631            None,
632        );
633        assert_eq!(result.overridden_mode, Some("full".to_string()));
634        assert_eq!(result.reason, Some("pinned"));
635    }
636
637    #[test]
638    fn overlay_exclude_forces_signatures_mode() {
639        let dir = tempfile::tempdir().expect("tmp dir");
640        let root = dir.path();
641        let mut store = OverlayStore::new();
642        let target = ContextItemId::from_file("src/noisy.rs");
643        store.add(crate::core::context_overlay::ContextOverlay::new(
644            target,
645            OverlayOp::Exclude {
646                reason: "noise".to_string(),
647            },
648            crate::core::context_overlay::OverlayScope::Project,
649            String::new(),
650            crate::core::context_overlay::OverlayAuthor::User,
651        ));
652        store.save_project(root).unwrap();
653
654        let result = pre_dispatch_read(
655            "src/noisy.rs",
656            "auto",
657            None,
658            Some(root.to_str().unwrap()),
659            None,
660        );
661        assert_eq!(result.overridden_mode, Some("signatures".to_string()));
662        assert_eq!(result.reason, Some("excluded"));
663    }
664
665    // --- pressure_downgrade unit tests (pure function) ---
666
667    #[test]
668    fn pressure_downgrade_suggest_auto_to_map() {
669        let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
670        assert_eq!(result, Some("map".to_string()));
671    }
672
673    #[test]
674    fn pressure_downgrade_suggest_full_to_map() {
675        let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
676        assert_eq!(result, Some("map".to_string()));
677    }
678
679    #[test]
680    fn pressure_downgrade_suggest_does_not_touch_signatures() {
681        let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
682        assert!(result.is_none());
683    }
684
685    #[test]
686    fn pressure_downgrade_suggest_does_not_touch_diff() {
687        let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
688        assert!(result.is_none());
689    }
690
691    #[test]
692    fn pressure_downgrade_force_full_to_map() {
693        let result = pressure_downgrade("full", &PressureAction::ForceCompression);
694        assert_eq!(result, Some("map".to_string()));
695    }
696
697    #[test]
698    fn pressure_downgrade_force_auto_to_signatures() {
699        let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
700        assert_eq!(result, Some("signatures".to_string()));
701    }
702
703    #[test]
704    fn pressure_downgrade_force_map_to_signatures() {
705        let result = pressure_downgrade("map", &PressureAction::ForceCompression);
706        assert_eq!(result, Some("signatures".to_string()));
707    }
708
709    #[test]
710    fn pressure_downgrade_force_does_not_touch_signatures() {
711        let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
712        assert!(result.is_none());
713    }
714
715    #[test]
716    fn pressure_downgrade_force_does_not_touch_lines() {
717        let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
718        assert!(result.is_none());
719    }
720
721    #[test]
722    fn pressure_downgrade_evict_full_to_map() {
723        let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
724        assert_eq!(result, Some("map".to_string()));
725    }
726
727    #[test]
728    fn pressure_downgrade_evict_auto_to_signatures() {
729        let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
730        assert_eq!(result, Some("signatures".to_string()));
731    }
732
733    #[test]
734    fn pressure_downgrade_evict_map_to_signatures() {
735        let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
736        assert_eq!(result, Some("signatures".to_string()));
737    }
738
739    #[test]
740    fn pressure_downgrade_noaction_returns_none() {
741        let result = pressure_downgrade("full", &PressureAction::NoAction);
742        assert!(result.is_none());
743    }
744
745    #[test]
746    fn pressure_downgrade_noaction_auto_returns_none() {
747        let result = pressure_downgrade("auto", &PressureAction::NoAction);
748        assert!(result.is_none());
749    }
750
751    // --- pre_dispatch_inner: no_degrade integration ---
752    // When LCTX_NO_DEGRADE is NOT set (test default), pressure downgrade is active.
753
754    #[test]
755    fn pre_dispatch_does_not_downgrade_full_under_force() {
756        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
757            return;
758        }
759        // Explicit mode=full is protected: pressure cannot downgrade it
760        let result = pre_dispatch_read(
761            "nd_test.rs",
762            "full",
763            None,
764            None,
765            Some(&PressureAction::ForceCompression),
766        );
767        assert!(result.overridden_mode.is_none());
768        assert!(!result.pressure_downgraded);
769    }
770
771    #[test]
772    fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
773        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
774            return;
775        }
776        // Default profile has degradation.enforce = false, so pressure
777        // should not downgrade even non-full modes
778        let result = pre_dispatch_read(
779            "nd_test2.rs",
780            "auto",
781            None,
782            None,
783            Some(&PressureAction::EvictLeastRelevant),
784        );
785        assert!(result.overridden_mode.is_none());
786        assert!(!result.pressure_downgraded);
787    }
788
789    // --- estimate_read_tokens unit tests ---
790
791    #[test]
792    fn estimate_tokens_diff_mode_is_small() {
793        let tokens = estimate_read_tokens("nonexistent.rs", "diff");
794        assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
795    }
796
797    #[test]
798    fn estimate_tokens_signatures_smaller_than_full() {
799        let sig = estimate_read_tokens("nonexistent.rs", "signatures");
800        let full = estimate_read_tokens("nonexistent.rs", "full");
801        assert!(sig < full, "signatures={sig} should be < full={full}");
802    }
803
804    #[test]
805    fn estimate_tokens_lines_range() {
806        let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
807        assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
808    }
809
810    #[test]
811    fn overlay_set_view_forces_specified_mode() {
812        let dir = tempfile::tempdir().expect("tmp dir");
813        let root = dir.path();
814        let mut store = OverlayStore::new();
815        let target = ContextItemId::from_file("src/big.rs");
816        store.add(crate::core::context_overlay::ContextOverlay::new(
817            target,
818            OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
819            crate::core::context_overlay::OverlayScope::Project,
820            String::new(),
821            crate::core::context_overlay::OverlayAuthor::User,
822        ));
823        store.save_project(root).unwrap();
824
825        let result = pre_dispatch_read(
826            "src/big.rs",
827            "auto",
828            None,
829            Some(root.to_str().unwrap()),
830            None,
831        );
832        assert_eq!(result.overridden_mode, Some("map".to_string()));
833        assert_eq!(result.reason, Some("overlay-set-view"));
834    }
835}