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/// #843: precise, pinned reads (`diff`, `lines:N-M`, `anchored`/`anchored:N-M`)
6/// must pass through every mode-override path untouched — bounce-prevention,
7/// pressure-downgrade, and the graph/knowledge heuristics below must never
8/// silently reinterpret one of these to e.g. `full`, which would discard the
9/// exact window/anchors/delta the caller asked for. Classifies off the typed
10/// `ReadMode` (single source of truth, see `tools::ctx_read::mode`) rather than
11/// a hand-maintained string-prefix list, so a future precise mode has to be
12/// added to `ReadMode::is_precise_pinned_read` explicitly instead of silently
13/// falling through an allowlist. An unparseable mode is conservatively treated
14/// as *not* pinned, matching prior behaviour for anything outside this set.
15fn is_precise_pinned_mode(requested_mode: &str) -> bool {
16    requested_mode
17        .parse::<crate::tools::ctx_read::ReadMode>()
18        .is_ok_and(|m| m.is_precise_pinned_read())
19}
20
21#[derive(Debug, Clone)]
22pub struct PreDispatchResult {
23    pub overridden_mode: Option<String>,
24    pub reason: Option<&'static str>,
25    pub pressure_downgraded: bool,
26    pub budget_blocked: bool,
27    pub budget_warning: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct PostDispatchResult {
32    pub eviction_hint: Option<String>,
33    pub elicitation_hint: Option<String>,
34    pub resource_changed: bool,
35    /// FEP prefetch suggestion (#9): files likely needed next, from the co-access
36    /// graph. A warmup hint only — never an automatic read.
37    pub prefetch_hint: Option<String>,
38}
39
40pub fn pre_dispatch_read(
41    path: &str,
42    requested_mode: &str,
43    task: Option<&str>,
44    project_root: Option<&str>,
45    pressure: Option<&PressureAction>,
46) -> PreDispatchResult {
47    pre_dispatch_read_for_agent(path, requested_mode, task, project_root, pressure, None)
48}
49
50pub fn pre_dispatch_read_for_agent(
51    path: &str,
52    requested_mode: &str,
53    task: Option<&str>,
54    project_root: Option<&str>,
55    pressure: Option<&PressureAction>,
56    agent_id: Option<&str>,
57) -> PreDispatchResult {
58    let no_change = PreDispatchResult {
59        overridden_mode: None,
60        reason: None,
61        pressure_downgraded: false,
62        budget_blocked: false,
63        budget_warning: None,
64    };
65
66    if let Some(aid) = agent_id {
67        let estimated_tokens = estimate_read_tokens(path, requested_mode);
68        match crate::core::agent_budget::check_budget(aid, estimated_tokens) {
69            crate::core::agent_budget::BudgetCheckResult::Exceeded { limit, consumed } => {
70                return PreDispatchResult {
71                    overridden_mode: None,
72                    reason: Some("agent-budget-exceeded"),
73                    pressure_downgraded: false,
74                    budget_blocked: true,
75                    budget_warning: Some(format!(
76                        "Agent budget exceeded: {consumed}/{limit} tokens consumed. Reset via ctx_session or set a higher limit."
77                    )),
78                };
79            }
80            crate::core::agent_budget::BudgetCheckResult::Warning {
81                remaining,
82                percent_used,
83            } => {
84                let warning = format!(
85                    "[BUDGET WARNING] Agent '{aid}' at {:.0}% budget ({remaining} tokens remaining)",
86                    percent_used * 100.0
87                );
88                let mut result = no_change.clone();
89                result.budget_warning = Some(warning);
90                if is_precise_pinned_mode(requested_mode) {
91                    return result;
92                }
93                let rest = pre_dispatch_inner(path, requested_mode, task, project_root, pressure);
94                return PreDispatchResult {
95                    budget_warning: result.budget_warning,
96                    ..rest
97                };
98            }
99            crate::core::agent_budget::BudgetCheckResult::Allowed { .. } => {}
100        }
101    }
102
103    pre_dispatch_inner(path, requested_mode, task, project_root, pressure)
104}
105
106fn pre_dispatch_inner(
107    path: &str,
108    requested_mode: &str,
109    task: Option<&str>,
110    project_root: Option<&str>,
111    pressure: Option<&PressureAction>,
112) -> PreDispatchResult {
113    let no_change = PreDispatchResult {
114        overridden_mode: None,
115        reason: None,
116        pressure_downgraded: false,
117        budget_blocked: false,
118        budget_warning: None,
119    };
120
121    if is_precise_pinned_mode(requested_mode) {
122        return no_change;
123    }
124
125    if let Some(root) = project_root {
126        let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
127        if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
128            return result;
129        }
130    }
131
132    // Explicit mode=full must not be downgraded by pressure or other heuristics.
133    // Only overlays (user-explicit) above can override it.
134    if requested_mode == "full" {
135        return no_change;
136    }
137
138    if let Some(action) = pressure {
139        let no_degrade = crate::core::config::Config::load().no_degrade_effective();
140        let profile = crate::core::profiles::active_profile();
141        if !no_degrade
142            && profile.degradation.enforce_effective()
143            && let Some(downgraded) = pressure_downgrade(requested_mode, action)
144        {
145            return PreDispatchResult {
146                overridden_mode: Some(downgraded),
147                reason: Some("pressure-auto-downgrade"),
148                pressure_downgraded: true,
149                budget_blocked: false,
150                budget_warning: None,
151            };
152        }
153    }
154
155    if let Ok(bt) = crate::core::bounce_tracker::global().lock()
156        && bt.should_force_full(path)
157    {
158        return PreDispatchResult {
159            overridden_mode: Some("full".to_string()),
160            reason: Some("bounce-prevention"),
161            pressure_downgraded: false,
162            budget_blocked: false,
163            budget_warning: None,
164        };
165    }
166
167    if let Some(task_str) = task {
168        let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
169        let norm = crate::core::pathutil::normalize_tool_path(path);
170        let is_target = intent
171            .targets
172            .iter()
173            .any(|t| norm.ends_with(t) || norm.contains(t));
174        if is_target {
175            return PreDispatchResult {
176                overridden_mode: Some("full".to_string()),
177                reason: Some("intent-target"),
178                pressure_downgraded: false,
179                budget_blocked: false,
180                budget_warning: None,
181            };
182        }
183    }
184
185    if let Some(root) = project_root
186        && let Some(open) = try_load_graph(root)
187    {
188        let gp = &open.provider;
189        let related = gp.related(path, 1);
190        if let Some(task_str) = task {
191            let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
192            for target in &intent.targets {
193                let target_related = gp.related(target, 1);
194                let norm = crate::core::pathutil::normalize_tool_path(path);
195                if target_related
196                    .iter()
197                    .any(|r| r.contains(&norm) || norm.contains(r))
198                {
199                    return PreDispatchResult {
200                        overridden_mode: Some("map".to_string()),
201                        reason: Some("graph-direct-import"),
202                        pressure_downgraded: false,
203                        budget_blocked: false,
204                        budget_warning: None,
205                    };
206                }
207            }
208        }
209        if !related.is_empty() && requested_mode == "auto" {
210            let reverse_deps = gp.dependents(path);
211            if reverse_deps.len() > 3 {
212                return PreDispatchResult {
213                    overridden_mode: Some("map".to_string()),
214                    reason: Some("graph-hub-file"),
215                    pressure_downgraded: false,
216                    budget_blocked: false,
217                    budget_warning: None,
218                };
219            }
220        }
221    }
222
223    if let Some(root) = project_root
224        && let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root)
225    {
226        let norm = crate::core::pathutil::normalize_tool_path(path);
227        let mentions = knowledge
228            .facts
229            .iter()
230            .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
231            .count();
232        if mentions >= 3 {
233            return PreDispatchResult {
234                overridden_mode: Some("map".to_string()),
235                reason: Some("knowledge-high-relevance"),
236                pressure_downgraded: false,
237                budget_blocked: false,
238                budget_warning: None,
239            };
240        }
241    }
242
243    no_change
244}
245
246fn estimate_read_tokens(path: &str, mode: &str) -> usize {
247    let file_size = std::fs::metadata(path).map_or(4000, |m| m.len() as usize);
248    let char_estimate = file_size;
249    let full_tokens = char_estimate / 4;
250    match mode {
251        "signatures" => full_tokens / 5,
252        "map" => full_tokens / 3,
253        "aggressive" | "entropy" => full_tokens / 4,
254        "diff" => full_tokens / 10,
255        _ if mode.starts_with("lines:") => {
256            if let Some(range) = mode.strip_prefix("lines:") {
257                let parts: Vec<&str> = range.split('-').collect();
258                if parts.len() == 2 {
259                    let start = parts[0].parse::<usize>().unwrap_or(1);
260                    let end = parts[1].parse::<usize>().unwrap_or(start + 100);
261                    (end.saturating_sub(start) + 1) * 10
262                } else {
263                    full_tokens / 10
264                }
265            } else {
266                full_tokens / 10
267            }
268        }
269        _ => full_tokens,
270    }
271}
272
273fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
274    crate::core::auto_mode_resolver::pressure_downgrade(requested_mode, action)
275}
276
277fn check_overlay_mode_override(
278    path: &str,
279    requested_mode: &str,
280    overlay: &OverlayStore,
281) -> Option<PreDispatchResult> {
282    let item_id = ContextItemId::from_file(path);
283    let overlays = overlay.for_item(&item_id);
284
285    for ov in overlays.iter().rev() {
286        match &ov.operation {
287            OverlayOp::SetView(view) => {
288                let mode_str = view.as_str();
289                if mode_str != requested_mode {
290                    return Some(PreDispatchResult {
291                        overridden_mode: Some(mode_str.to_string()),
292                        reason: Some("overlay-set-view"),
293                        pressure_downgraded: false,
294                        budget_blocked: false,
295                        budget_warning: None,
296                    });
297                }
298            }
299            OverlayOp::Pin { .. } if requested_mode != "full" => {
300                return Some(PreDispatchResult {
301                    overridden_mode: Some("full".to_string()),
302                    reason: Some("pinned"),
303                    pressure_downgraded: false,
304                    budget_blocked: false,
305                    budget_warning: None,
306                });
307            }
308            OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
309                return Some(PreDispatchResult {
310                    overridden_mode: Some("signatures".to_string()),
311                    reason: Some("excluded"),
312                    pressure_downgraded: false,
313                    budget_blocked: false,
314                    budget_warning: None,
315                });
316            }
317            _ => {}
318        }
319    }
320    None
321}
322
323pub fn post_dispatch_record(
324    path: &str,
325    mode: &str,
326    original_tokens: usize,
327    sent_tokens: usize,
328    ledger: &mut ContextLedger,
329    overlay: &OverlayStore,
330) -> PostDispatchResult {
331    post_dispatch_record_with_task(
332        path,
333        mode,
334        original_tokens,
335        sent_tokens,
336        ledger,
337        overlay,
338        None,
339        None,
340    )
341}
342
343pub fn post_dispatch_record_with_task(
344    path: &str,
345    mode: &str,
346    original_tokens: usize,
347    sent_tokens: usize,
348    ledger: &mut ContextLedger,
349    overlay: &OverlayStore,
350    task: Option<&str>,
351    project_root: Option<&str>,
352) -> PostDispatchResult {
353    let prev_count = ledger.entries.len();
354    let prev_pressure = ledger.pressure().recommendation;
355
356    ledger.record_with_task(path, mode, original_tokens, sent_tokens, task);
357
358    let item_id = ContextItemId::from_file(path);
359    let state = overlay.apply_to_state(&item_id, ContextState::Included);
360
361    if state == ContextState::Excluded {
362        return PostDispatchResult {
363            eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
364            elicitation_hint: None,
365            resource_changed: true,
366            prefetch_hint: None,
367        };
368    }
369
370    let elicitation =
371        super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
372            .map(|s| s.format_fallback_hint());
373
374    let pressure = ledger.pressure();
375
376    // #6 Global-Workspace ignition: salience outliers are broadcast (pinned) into
377    // the working set BEFORE reinjection, so an ignited item keeps its view while
378    // the rest are downgraded under pressure. Deterministic z-score threshold.
379    let ignited = ledger.ignite_high_salience();
380
381    apply_reinjection_plan(ledger, &pressure.recommendation);
382
383    let new_entry = ledger.entries.len() != prev_count;
384    let pressure_shifted = pressure.recommendation != prev_pressure;
385    let resource_changed = new_entry || pressure_shifted || !ignited.is_empty();
386
387    if pressure.utilization > 0.9 {
388        let candidates = ledger.eviction_candidates_by_phi(3);
389        if !candidates.is_empty() {
390            // #715: emit targets the evict resolver can actually find —
391            // root-relative paths (or the full path), never display-shortened
392            // forms that used to produce "Evicted 0/N".
393            let names: Vec<_> = candidates
394                .iter()
395                .take(3)
396                .map(|p| eviction_target_display(p, project_root))
397                .collect();
398            return PostDispatchResult {
399                eviction_hint: Some(format!(
400                    "Context pressure {:.0}%. Evict: ctx_ledger(action=\"evict\", targets=\"{}\")",
401                    pressure.utilization * 100.0,
402                    names.join(", ")
403                )),
404                elicitation_hint: elicitation,
405                resource_changed,
406                // Under pressure we evict rather than prefetch — no warmup hint.
407                prefetch_hint: None,
408            };
409        }
410    }
411
412    // #9 FEP prefetch: with budget to spare, suggest the files most likely needed
413    // next (co-access graph), so the agent can warm them before the surprise of a
414    // miss. Deterministic; runs in the background post-dispatch, never in output.
415    let prefetch_hint =
416        project_root.and_then(|root| crate::core::fep_prefetch::prefetch_hint(root, path, ledger));
417
418    PostDispatchResult {
419        eviction_hint: None,
420        elicitation_hint: elicitation,
421        resource_changed,
422        prefetch_hint,
423    }
424}
425
426/// #715: a resolvable evict target for hint output — project-root-relative
427/// when the candidate lives under the root, otherwise the full canonical
428/// path. Both forms round-trip through `ContextLedger::resolve_entry`.
429fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
430    if let Some(root) = project_root.filter(|r| !r.is_empty()) {
431        let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
432        if let Some(rel) = path.strip_prefix(&root_prefix)
433            && !rel.is_empty()
434        {
435            return rel.to_string();
436        }
437    }
438    path.to_string()
439}
440
441fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
442    if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
443    {
444        return;
445    }
446    for entry in &mut ledger.entries {
447        // #6: ignited / user-pinned items stay broadcast — never downgraded.
448        if entry.state == Some(ContextState::Pinned) {
449            continue;
450        }
451        if entry.mode == "full" {
452            entry.mode = "map".to_string();
453        }
454    }
455}
456
457fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
458    crate::core::graph_provider::open_best_effort(project_root)
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn eviction_target_display_emits_resolvable_targets() {
467        // #715: root-relative when under the root, full path otherwise —
468        // never a display-shortened form the resolver cannot find.
469        assert_eq!(
470            eviction_target_display("/w/proj/src/a.rs", Some("/w/proj")),
471            "src/a.rs"
472        );
473        assert_eq!(
474            eviction_target_display("/other/b.rs", Some("/w/proj")),
475            "/other/b.rs"
476        );
477        assert_eq!(eviction_target_display("/x/c.rs", None), "/x/c.rs");
478    }
479
480    #[test]
481    fn pre_dispatch_passthrough_for_full() {
482        let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
483        assert!(result.overridden_mode.is_none());
484    }
485
486    #[test]
487    fn pre_dispatch_passthrough_for_diff() {
488        let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
489        assert!(result.overridden_mode.is_none());
490    }
491
492    #[test]
493    fn pre_dispatch_passthrough_for_anchored_window() {
494        // #843: a windowed anchored:N-M read must keep its hash anchors —
495        // bounce-prevention, pressure-downgrade, etc. must not clobber it to
496        // "full" and silently drop the window.
497        {
498            let mut bt = crate::core::bounce_tracker::global()
499                .lock()
500                .unwrap_or_else(std::sync::PoisonError::into_inner);
501            bt.set_seq(101);
502            bt.record_read("anchored-bouncy.yml", "map", 30, 400);
503            bt.set_seq(102);
504            bt.record_read("anchored-bouncy.yml", "full", 400, 400);
505            bt.set_seq(103);
506            bt.record_read("a2.yml", "map", 30, 400);
507            bt.set_seq(104);
508            bt.record_read("a2.yml", "full", 400, 400);
509            bt.set_seq(105);
510            bt.record_read("a3.yml", "map", 30, 400);
511            bt.set_seq(106);
512            bt.record_read("a3.yml", "full", 400, 400);
513        }
514        let result = pre_dispatch_read("anchored-new.yml", "anchored:10-40", None, None, None);
515        assert!(
516            result.overridden_mode.is_none(),
517            "anchored:N-M must not be overridden by bounce-prevention"
518        );
519        let bare = pre_dispatch_read("anchored-new.yml", "anchored", None, None, None);
520        assert!(
521            bare.overridden_mode.is_none(),
522            "bare anchored mode must not be overridden by bounce-prevention"
523        );
524    }
525
526    #[test]
527    fn pre_dispatch_no_override_without_signals() {
528        let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
529        assert!(result.overridden_mode.is_none());
530    }
531
532    #[test]
533    fn pre_dispatch_bounce_prevention_forces_full() {
534        {
535            let mut bt = crate::core::bounce_tracker::global()
536                .lock()
537                .unwrap_or_else(std::sync::PoisonError::into_inner);
538            bt.set_seq(1);
539            bt.record_read("src/bouncy.yml", "map", 30, 400);
540            bt.set_seq(2);
541            bt.record_read("src/bouncy.yml", "full", 400, 400);
542            bt.set_seq(3);
543            bt.record_read("a2.yml", "map", 30, 400);
544            bt.set_seq(4);
545            bt.record_read("a2.yml", "full", 400, 400);
546            bt.set_seq(5);
547            bt.record_read("a3.yml", "map", 30, 400);
548            bt.set_seq(6);
549            bt.record_read("a3.yml", "full", 400, 400);
550        }
551        let result = pre_dispatch_read("new.yml", "auto", None, None, None);
552        assert_eq!(result.overridden_mode, Some("full".to_string()));
553        assert_eq!(result.reason, Some("bounce-prevention"));
554    }
555
556    #[test]
557    fn pressure_does_not_downgrade_explicit_full() {
558        let result = pre_dispatch_read(
559            "c.rs",
560            "full",
561            None,
562            None,
563            Some(&PressureAction::ForceCompression),
564        );
565        assert!(
566            result.overridden_mode.is_none(),
567            "explicit mode=full must never be downgraded by pressure"
568        );
569        assert!(!result.pressure_downgraded);
570    }
571
572    #[test]
573    fn pressure_does_not_downgrade_when_enforce_off() {
574        // Default profile has degradation.enforce = false, so pressure
575        // should NOT downgrade any mode.
576        let result = pre_dispatch_read(
577            "c.rs",
578            "map",
579            None,
580            None,
581            Some(&PressureAction::EvictLeastRelevant),
582        );
583        assert!(
584            result.overridden_mode.is_none(),
585            "pressure must not downgrade when degradation.enforce is off"
586        );
587        assert!(!result.pressure_downgraded);
588    }
589
590    #[test]
591    fn no_pressure_downgrade_when_low() {
592        let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
593        assert!(result.overridden_mode.is_none());
594        assert!(!result.pressure_downgraded);
595    }
596
597    #[test]
598    fn suggest_compression_does_not_downgrade_when_enforce_off() {
599        // Default profile has degradation.enforce = false
600        let result = pre_dispatch_read(
601            "c.rs",
602            "auto",
603            None,
604            None,
605            Some(&PressureAction::SuggestCompression),
606        );
607        assert!(
608            result.overridden_mode.is_none(),
609            "suggest_compression must not downgrade when enforce is off"
610        );
611        assert!(!result.pressure_downgraded);
612    }
613
614    #[test]
615    fn suggest_compression_does_not_touch_explicit_full() {
616        let result = pre_dispatch_read(
617            "c.rs",
618            "full",
619            None,
620            None,
621            Some(&PressureAction::SuggestCompression),
622        );
623        assert!(result.overridden_mode.is_none());
624        assert!(!result.pressure_downgraded);
625    }
626
627    #[test]
628    fn post_dispatch_reinjection_downgrades_entries() {
629        let mut ledger = ContextLedger::with_window_size(1000);
630        ledger.record("a.rs", "full", 400, 400);
631        ledger.record("b.rs", "full", 400, 400);
632        let overlay = OverlayStore::new();
633        let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
634        assert!(result.resource_changed);
635        let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
636        assert_eq!(a_entry.mode, "map");
637    }
638
639    #[test]
640    fn ignited_item_resists_reinjection_downgrade() {
641        // #6: a high-salience outlier ignites (pins) and keeps its full view,
642        // while the rest are downgraded to map by pressure reinjection.
643        let mut ledger = ContextLedger::with_window_size(1000);
644        for i in 0..5 {
645            ledger.record(&format!("bg{i}.rs"), "full", 250, 250);
646        }
647        ledger.record("hot.rs", "full", 250, 250);
648        // Set the salience distribution explicitly (record recomputes Phi, so we
649        // overwrite afterwards) to make ignition deterministic in the test.
650        for e in &mut ledger.entries {
651            e.phi = Some(if e.path == "hot.rs" { 0.97 } else { 0.1 });
652        }
653        let ignited = ledger.ignite_high_salience();
654        assert_eq!(ignited, vec!["hot.rs".to_string()], "outlier should ignite");
655
656        apply_reinjection_plan(&mut ledger, &PressureAction::ForceCompression);
657        let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
658        assert_eq!(hot.mode, "full", "ignited item keeps its full view");
659        let bg = ledger.entries.iter().find(|e| e.path == "bg0.rs").unwrap();
660        assert_eq!(bg.mode, "map", "non-ignited items are downgraded");
661    }
662
663    #[test]
664    fn overlay_pin_forces_full_mode() {
665        let dir = tempfile::tempdir().expect("tmp dir");
666        let root = dir.path();
667        let mut store = OverlayStore::new();
668        let target = ContextItemId::from_file("src/important.rs");
669        store.add(crate::core::context_overlay::ContextOverlay::new(
670            target,
671            OverlayOp::Pin { verbatim: false },
672            crate::core::context_overlay::OverlayScope::Project,
673            String::new(),
674            crate::core::context_overlay::OverlayAuthor::User,
675        ));
676        store.save_project(root).unwrap();
677
678        let result = pre_dispatch_read(
679            "src/important.rs",
680            "auto",
681            None,
682            Some(root.to_str().unwrap()),
683            None,
684        );
685        assert_eq!(result.overridden_mode, Some("full".to_string()));
686        assert_eq!(result.reason, Some("pinned"));
687    }
688
689    #[test]
690    fn overlay_exclude_forces_signatures_mode() {
691        let dir = tempfile::tempdir().expect("tmp dir");
692        let root = dir.path();
693        let mut store = OverlayStore::new();
694        let target = ContextItemId::from_file("src/noisy.rs");
695        store.add(crate::core::context_overlay::ContextOverlay::new(
696            target,
697            OverlayOp::Exclude {
698                reason: "noise".to_string(),
699            },
700            crate::core::context_overlay::OverlayScope::Project,
701            String::new(),
702            crate::core::context_overlay::OverlayAuthor::User,
703        ));
704        store.save_project(root).unwrap();
705
706        let result = pre_dispatch_read(
707            "src/noisy.rs",
708            "auto",
709            None,
710            Some(root.to_str().unwrap()),
711            None,
712        );
713        assert_eq!(result.overridden_mode, Some("signatures".to_string()));
714        assert_eq!(result.reason, Some("excluded"));
715    }
716
717    // --- pressure_downgrade unit tests (pure function) ---
718
719    #[test]
720    fn pressure_downgrade_suggest_auto_to_map() {
721        let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
722        assert_eq!(result, Some("map".to_string()));
723    }
724
725    #[test]
726    fn pressure_downgrade_suggest_full_to_map() {
727        let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
728        assert_eq!(result, Some("map".to_string()));
729    }
730
731    #[test]
732    fn pressure_downgrade_suggest_does_not_touch_signatures() {
733        let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
734        assert!(result.is_none());
735    }
736
737    #[test]
738    fn pressure_downgrade_suggest_does_not_touch_diff() {
739        let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
740        assert!(result.is_none());
741    }
742
743    #[test]
744    fn pressure_downgrade_force_full_to_map() {
745        let result = pressure_downgrade("full", &PressureAction::ForceCompression);
746        assert_eq!(result, Some("map".to_string()));
747    }
748
749    #[test]
750    fn pressure_downgrade_force_auto_to_signatures() {
751        let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
752        assert_eq!(result, Some("signatures".to_string()));
753    }
754
755    #[test]
756    fn pressure_downgrade_force_map_to_signatures() {
757        let result = pressure_downgrade("map", &PressureAction::ForceCompression);
758        assert_eq!(result, Some("signatures".to_string()));
759    }
760
761    #[test]
762    fn pressure_downgrade_force_does_not_touch_signatures() {
763        let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
764        assert!(result.is_none());
765    }
766
767    #[test]
768    fn pressure_downgrade_force_does_not_touch_lines() {
769        let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
770        assert!(result.is_none());
771    }
772
773    #[test]
774    fn pressure_downgrade_evict_full_to_map() {
775        let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
776        assert_eq!(result, Some("map".to_string()));
777    }
778
779    #[test]
780    fn pressure_downgrade_evict_auto_to_signatures() {
781        let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
782        assert_eq!(result, Some("signatures".to_string()));
783    }
784
785    #[test]
786    fn pressure_downgrade_evict_map_to_signatures() {
787        let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
788        assert_eq!(result, Some("signatures".to_string()));
789    }
790
791    #[test]
792    fn pressure_downgrade_noaction_returns_none() {
793        let result = pressure_downgrade("full", &PressureAction::NoAction);
794        assert!(result.is_none());
795    }
796
797    #[test]
798    fn pressure_downgrade_noaction_auto_returns_none() {
799        let result = pressure_downgrade("auto", &PressureAction::NoAction);
800        assert!(result.is_none());
801    }
802
803    // --- pre_dispatch_inner: no_degrade integration ---
804    // When LCTX_NO_DEGRADE is NOT set (test default), pressure downgrade is active.
805
806    #[test]
807    fn pre_dispatch_does_not_downgrade_full_under_force() {
808        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
809            return;
810        }
811        // Explicit mode=full is protected: pressure cannot downgrade it
812        let result = pre_dispatch_read(
813            "nd_test.rs",
814            "full",
815            None,
816            None,
817            Some(&PressureAction::ForceCompression),
818        );
819        assert!(result.overridden_mode.is_none());
820        assert!(!result.pressure_downgraded);
821    }
822
823    #[test]
824    fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
825        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
826            return;
827        }
828        // Default profile has degradation.enforce = false, so pressure
829        // should not downgrade even non-full modes
830        let result = pre_dispatch_read(
831            "nd_test2.rs",
832            "auto",
833            None,
834            None,
835            Some(&PressureAction::EvictLeastRelevant),
836        );
837        assert!(result.overridden_mode.is_none());
838        assert!(!result.pressure_downgraded);
839    }
840
841    // --- estimate_read_tokens unit tests ---
842
843    #[test]
844    fn estimate_tokens_diff_mode_is_small() {
845        let tokens = estimate_read_tokens("nonexistent.rs", "diff");
846        assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
847    }
848
849    #[test]
850    fn estimate_tokens_signatures_smaller_than_full() {
851        let sig = estimate_read_tokens("nonexistent.rs", "signatures");
852        let full = estimate_read_tokens("nonexistent.rs", "full");
853        assert!(sig < full, "signatures={sig} should be < full={full}");
854    }
855
856    #[test]
857    fn estimate_tokens_lines_range() {
858        let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
859        assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
860    }
861
862    #[test]
863    fn overlay_set_view_forces_specified_mode() {
864        let dir = tempfile::tempdir().expect("tmp dir");
865        let root = dir.path();
866        let mut store = OverlayStore::new();
867        let target = ContextItemId::from_file("src/big.rs");
868        store.add(crate::core::context_overlay::ContextOverlay::new(
869            target,
870            OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
871            crate::core::context_overlay::OverlayScope::Project,
872            String::new(),
873            crate::core::context_overlay::OverlayAuthor::User,
874        ));
875        store.save_project(root).unwrap();
876
877        let result = pre_dispatch_read(
878            "src/big.rs",
879            "auto",
880            None,
881            Some(root.to_str().unwrap()),
882            None,
883        );
884        assert_eq!(result.overridden_mode, Some("map".to_string()));
885        assert_eq!(result.reason, Some("overlay-set-view"));
886    }
887}