Skip to main content

lean_ctx/core/
auto_mode_resolver.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::core::cache::SessionCache;
5use crate::core::context_ledger::PressureAction;
6use crate::core::mode_predictor::{FileSignature, ModePredictor};
7
8/// Per-process counters of which signal decided each auto-mode resolution.
9/// Surfaced by `ctx_metrics` so the learning loops are observable (#496).
10static SOURCE_COUNTS: Mutex<Option<HashMap<&'static str, u64>>> = Mutex::new(None);
11
12fn count_source(source: &'static str) {
13    if let Ok(mut guard) = SOURCE_COUNTS.lock() {
14        *guard
15            .get_or_insert_with(HashMap::new)
16            .entry(source)
17            .or_insert(0) += 1;
18    }
19}
20
21/// Snapshot of auto-mode decision sources, sorted by count descending.
22pub fn source_counts() -> Vec<(&'static str, u64)> {
23    let Ok(guard) = SOURCE_COUNTS.lock() else {
24        return Vec::new();
25    };
26    let mut items: Vec<(&'static str, u64)> = guard
27        .as_ref()
28        .map(|m| m.iter().map(|(k, v)| (*k, *v)).collect())
29        .unwrap_or_default();
30    items.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
31    items
32}
33
34fn sources_path() -> Option<std::path::PathBuf> {
35    crate::core::data_dir::lean_ctx_data_dir()
36        .ok()
37        .map(|d| d.join("auto_mode_sources.json"))
38}
39
40/// Persist the in-process counters by *adding* them into the cumulative
41/// on-disk file, then reset the process counters. The counters live in the
42/// MCP/CLI process — the dashboard is a separate process and can only see
43/// them through this file (#505).
44pub fn flush_sources() {
45    let drained: Vec<(String, u64)> = {
46        let Ok(mut guard) = SOURCE_COUNTS.lock() else {
47            return;
48        };
49        match guard.take() {
50            Some(m) if !m.is_empty() => m.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
51            _ => return,
52        }
53    };
54    let Some(path) = sources_path() else {
55        return;
56    };
57    let mut on_disk: HashMap<String, u64> = std::fs::read_to_string(&path)
58        .ok()
59        .and_then(|s| serde_json::from_str(&s).ok())
60        .unwrap_or_default();
61    for (k, v) in drained {
62        *on_disk.entry(k).or_insert(0) += v;
63    }
64    let Ok(json) = serde_json::to_string_pretty(&on_disk) else {
65        return;
66    };
67    let tmp = path.with_extension("json.tmp");
68    if std::fs::write(&tmp, json).is_ok() {
69        let _ = std::fs::rename(&tmp, &path);
70    }
71}
72
73/// Cumulative auto-mode decision sources from disk (all processes, all time),
74/// sorted by count descending. Used by the dashboard's Live Signals panel.
75pub fn persisted_source_counts() -> Vec<(String, u64)> {
76    let Some(path) = sources_path() else {
77        return Vec::new();
78    };
79    let map: HashMap<String, u64> = std::fs::read_to_string(&path)
80        .ok()
81        .and_then(|s| serde_json::from_str(&s).ok())
82        .unwrap_or_default();
83    let mut items: Vec<(String, u64)> = map.into_iter().collect();
84    items.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
85    items
86}
87
88pub struct AutoModeContext<'a> {
89    pub path: &'a str,
90    pub token_count: usize,
91    pub task: Option<&'a str>,
92    pub cache: Option<&'a SessionCache>,
93}
94
95pub struct ResolvedMode {
96    pub mode: String,
97    pub source: &'static str,
98}
99
100/// Single entry point for auto-mode resolution.
101/// Merges Pipeline A (select_mode_with_task) and Pipeline B (resolve_auto_mode).
102pub fn resolve(ctx: &AutoModeContext) -> ResolvedMode {
103    // Quality loop (#494), signal 1: an edit on this file just failed after a
104    // compressed read — the agent needs the real body now, one-shot.
105    if crate::core::edit_quality::take_pending_escalation(ctx.path) {
106        return resolved("full", "edit_fail_escalation");
107    }
108
109    let r = resolve_inner(ctx);
110
111    // Quality loop (#494), signal 2: this mode keeps producing edit failures
112    // for this file type — compression here is a proven net loss, use full.
113    if r.mode != "full" && crate::core::edit_quality::is_risky_mode(ctx.path, &r.mode) {
114        return resolved("full", "edit_quality_penalty");
115    }
116    r
117}
118
119fn resolve_inner(ctx: &AutoModeContext) -> ResolvedMode {
120    if crate::tools::ctx_read::is_instruction_file(ctx.path) {
121        return resolved("full", "instruction_file");
122    }
123
124    if crate::core::binary_detect::is_binary_file(ctx.path) {
125        return resolved("full", "binary");
126    }
127
128    if let Some(cache) = ctx.cache {
129        if let Some(cached) = cache.get(ctx.path) {
130            if file_unchanged(ctx.path, cached) {
131                return resolved("full", "cache_hit");
132            }
133            return resolved("diff", "cache_changed");
134        }
135    }
136
137    if ctx.token_count <= 200 {
138        return resolved("full", "small_file");
139    }
140
141    let ext = std::path::Path::new(ctx.path)
142        .extension()
143        .and_then(|e| e.to_str())
144        .unwrap_or("");
145
146    if is_config_or_data(ext, ctx.path) {
147        return resolved("full", "config_data");
148    }
149
150    if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
151        if bt.should_force_full(ctx.path) {
152            return resolved("full", "bounce_tracker");
153        }
154    }
155
156    // Per-path long-term memory (#496): a file that historically bounced in
157    // the majority of its reads will bounce again — compression is a proven
158    // net loss for it, across process restarts.
159    if crate::core::path_mode_memory::should_force_full(ctx.path) {
160        return resolved("full", "path_bounce_memory");
161    }
162
163    // Active compiler error (#499): the agent reads this file to fix the
164    // build — compressed modes would hide the error region.
165    if crate::core::diagnostics_store::has_error(ctx.path) {
166        return resolved("full", "active_diagnostic");
167    }
168
169    // Suspect file (#361 capability): the task explicitly names this file
170    // (e.g. "fix the version sort in versioncmp.c"), so the agent is about to
171    // inspect it for the defect. Keep the full body it needs to localize and
172    // edit, ahead of any task-type intent default that might compress it.
173    if task_names_file(ctx.task, ctx.path) {
174        return resolved("full", "task_suspect_file");
175    }
176
177    if let Some(mode) = intent_recommended_mode(ctx.task) {
178        return resolved(&mode, "intent");
179    }
180
181    let sig = FileSignature::from_path(ctx.path, ctx.token_count);
182    let predictor = ModePredictor::new();
183    let mut predicted = predictor
184        .predict_best_mode(&sig)
185        .unwrap_or_else(|| "full".to_string());
186    if predicted == "auto" {
187        predicted = "full".to_string();
188    }
189
190    if predicted != "full" {
191        if let Some(bandit_override) = bandit_explore(ctx.path, ctx.token_count) {
192            predicted = bandit_override;
193        }
194    }
195
196    // Heatmap signal (#496): a frequently-read file where compression barely
197    // saves anything will likely trigger a follow-up read — step one mode more
198    // conservative. avg_compression_ratio is the historical fraction saved.
199    if predicted != "full" {
200        if let Some((access_count, avg_ratio)) = crate::core::heatmap::entry_stats(ctx.path) {
201            if access_count >= 5 && avg_ratio < 0.30 {
202                let conservative = match predicted.as_str() {
203                    "signatures" | "aggressive" | "entropy" => "map".to_string(),
204                    "map" if ctx.token_count <= 6000 => "full".to_string(),
205                    other => other.to_string(),
206                };
207                if conservative != predicted {
208                    return resolved(&conservative, "heatmap_conservative");
209                }
210            }
211        }
212    }
213
214    let policy = crate::core::adaptive_mode_policy::AdaptiveModePolicyStore::load();
215    let chosen = policy.choose_auto_mode(ctx.task, &predicted);
216
217    if ctx.token_count > 2000 {
218        if (predicted == "map" || predicted == "signatures")
219            && chosen != "map"
220            && chosen != "signatures"
221        {
222            return resolved(&predicted, "predictor_guard");
223        }
224        if chosen == "full" && predicted != "full" {
225            return resolved(&predicted, "predictor_override");
226        }
227    }
228
229    if chosen != predicted {
230        return resolved(&chosen, "adaptive_policy");
231    }
232
233    if predicted != "full" {
234        return resolved(&predicted, "predictor");
235    }
236
237    // Cold-read fallback. Every read that reaches here missed the session cache
238    // (a warm hit returns `full`/`diff` above), so on a phase-isolated harness
239    // there is no warm re-read to amortize a `full` cold read. `structure_first`
240    // lets such a host opt into a lower `map` floor for medium code files; all
241    // capability guards (diagnostic / edit-fail / bounce / intent) already ran
242    // above, and the anti-inflation guarantee keeps `map` break-even at worst.
243    let structure_first = crate::core::config::Config::load().structure_first_effective();
244    let heuristic = heuristic_mode(ext, ctx.token_count, structure_first);
245    let source = if structure_first && heuristic == "map" && ctx.token_count <= 6000 {
246        "structure_first"
247    } else {
248        "heuristic"
249    };
250    resolved(&heuristic, source)
251}
252
253/// Unified pressure downgrade table.
254/// Used by both context_gate and intent_router pressure paths.
255pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
256    match action {
257        PressureAction::SuggestCompression => match requested_mode {
258            "auto" | "full" => Some("map".to_string()),
259            _ => None,
260        },
261        PressureAction::ForceCompression => match requested_mode {
262            "full" => Some("map".to_string()),
263            "auto" | "map" => Some("signatures".to_string()),
264            _ => None,
265        },
266        PressureAction::EvictLeastRelevant => match requested_mode {
267            "full" => Some("map".to_string()),
268            "auto" | "map" => Some("signatures".to_string()),
269            "signatures" => Some("reference".to_string()),
270            _ => None,
271        },
272        PressureAction::NoAction => None,
273    }
274}
275
276/// True when the task text explicitly names this file (basename match). A real
277/// filename mention ("versioncmp.c") is a strong suspect signal for a bug-fix;
278/// requiring an extension-bearing, non-trivial basename keeps it precise — the
279/// bare stem in "improve the parser" must not match `parser.rs`. A rare false
280/// positive only costs a little compression on a file the user literally named,
281/// so the failure mode is capability-safe.
282fn task_names_file(task: Option<&str>, path: &str) -> bool {
283    let Some(task) = task else {
284        return false;
285    };
286    let basename = std::path::Path::new(path)
287        .file_name()
288        .and_then(|n| n.to_str())
289        .unwrap_or("");
290    if basename.len() < 4 || !basename.contains('.') {
291        return false;
292    }
293    task.to_ascii_lowercase()
294        .contains(&basename.to_ascii_lowercase())
295}
296
297fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
298    let task_desc = task?;
299    let classification = crate::core::intent_engine::classify(task_desc);
300    if classification.confidence < 0.4 {
301        return None;
302    }
303    let route = crate::core::intent_engine::route_intent(task_desc, &classification);
304    let mode =
305        crate::core::intent_router::read_mode_for_tier(route.model_tier, classification.task_type);
306    if mode == "auto" {
307        return None;
308    }
309    Some(mode)
310}
311
312fn bandit_explore(file_path: &str, token_count: usize) -> Option<String> {
313    let project_root =
314        crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)?;
315    let ext = std::path::Path::new(file_path)
316        .extension()
317        .and_then(|e| e.to_str())
318        .unwrap_or("");
319    let bucket = match token_count {
320        0..=2000 => "sm",
321        2001..=10000 => "md",
322        10001..=50000 => "lg",
323        _ => "xl",
324    };
325    let bandit_key = format!("{ext}_{bucket}");
326    let mut store = crate::core::bandit::BanditStore::load(&project_root);
327    let bandit = store.get_or_create(&bandit_key);
328    let arm = bandit.select_arm();
329    if arm.budget_ratio < 0.25 && token_count > 2000 {
330        Some("aggressive".to_string())
331    } else {
332        None
333    }
334}
335
336fn heuristic_mode(ext: &str, token_count: usize, structure_first: bool) -> String {
337    if token_count > 8000 {
338        if is_code(ext) {
339            return "map".to_string();
340        }
341        return "aggressive".to_string();
342    }
343    // Raised from 3000 → 6000: at 3-6k tokens, returning only signatures forces
344    // the agent into a follow-up full/lines read for the body it actually
345    // needs. Keeping `full` here trades a few hundred tokens per call for
346    // fewer round-trips — the right call per the total-task-token principle.
347    if token_count > 6000 && is_code(ext) {
348        return "map".to_string();
349    }
350    // Structure-first cold-read floor (#361): on a phase-isolated harness a cold
351    // `full` read never amortizes, so medium code files default to `map`
352    // (deps + exports + key signatures) — cheaper and a better localization
353    // surface. `map` keeps far more than `signatures` (no empty bodies), so the
354    // follow-up-read risk that justifies the 6000 floor above is much lower; the
355    // 500-token floor stays above the trivial files where `full` is already best.
356    if structure_first && token_count > 500 && is_code(ext) {
357        return "map".to_string();
358    }
359    "full".to_string()
360}
361
362/// Fast O(1) staleness check: if the file's mtime still matches what was
363/// stored when the cache entry was created, the content is unchanged — no need
364/// to read the file or compute any hash. Falls back to "changed" when metadata
365/// is unavailable (e.g. file deleted) or when the cache entry predates mtime
366/// tracking (legacy entries with `stored_mtime = None`).
367///
368/// mtime comparison is sufficient for correctness on all major filesystems:
369/// every `write(2)` / `truncate(2)` updates mtime (POSIX guarantee).
370fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
371    let Some(stored_mtime) = cached.stored_mtime else {
372        return false;
373    };
374    let Ok(meta) = std::fs::metadata(path) else {
375        return false;
376    };
377    let Ok(current_mtime) = meta.modified() else {
378        return false;
379    };
380    current_mtime == stored_mtime
381}
382
383fn is_code(ext: &str) -> bool {
384    matches!(
385        ext,
386        "rs" | "ts"
387            | "tsx"
388            | "js"
389            | "jsx"
390            | "py"
391            | "go"
392            | "java"
393            | "c"
394            | "cpp"
395            | "cc"
396            | "h"
397            | "hpp"
398            | "rb"
399            | "cs"
400            | "kt"
401            | "swift"
402            | "php"
403            | "zig"
404            | "ex"
405            | "exs"
406            | "scala"
407            | "sc"
408            | "dart"
409            | "sh"
410            | "bash"
411            | "svelte"
412            | "vue"
413    )
414}
415
416fn is_config_or_data(ext: &str, path: &str) -> bool {
417    if matches!(ext, "xml" | "ini" | "cfg" | "env") {
418        return true;
419    }
420    let name = std::path::Path::new(path)
421        .file_name()
422        .and_then(|n| n.to_str())
423        .unwrap_or("");
424    matches!(
425        name,
426        "Cargo.toml"
427            | "package.json"
428            | "tsconfig.json"
429            | "Makefile"
430            | "Dockerfile"
431            | "docker-compose.yml"
432            | ".gitignore"
433            | ".env"
434            | "pyproject.toml"
435            | "go.mod"
436            | "build.gradle"
437            | "pom.xml"
438    )
439}
440
441fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
442    count_source(source);
443    ResolvedMode {
444        mode: mode.to_string(),
445        source,
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn pressure_suggest_full_to_map() {
455        assert_eq!(
456            pressure_downgrade("full", &PressureAction::SuggestCompression),
457            Some("map".to_string())
458        );
459    }
460
461    #[test]
462    fn pressure_suggest_auto_to_map() {
463        assert_eq!(
464            pressure_downgrade("auto", &PressureAction::SuggestCompression),
465            Some("map".to_string())
466        );
467    }
468
469    #[test]
470    fn pressure_suggest_does_not_touch_signatures() {
471        assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
472    }
473
474    #[test]
475    fn pressure_force_full_to_map() {
476        assert_eq!(
477            pressure_downgrade("full", &PressureAction::ForceCompression),
478            Some("map".to_string())
479        );
480    }
481
482    #[test]
483    fn pressure_force_map_to_signatures() {
484        assert_eq!(
485            pressure_downgrade("map", &PressureAction::ForceCompression),
486            Some("signatures".to_string())
487        );
488    }
489
490    #[test]
491    fn pressure_evict_signatures_to_reference() {
492        assert_eq!(
493            pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
494            Some("reference".to_string())
495        );
496    }
497
498    #[test]
499    fn pressure_noaction_returns_none() {
500        assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
501    }
502
503    #[test]
504    fn flush_sources_merges_additively_into_disk_file() {
505        let _lock = crate::core::data_dir::test_env_lock();
506        let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
507        let _ = std::fs::create_dir_all(&dir);
508        std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
509        let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
510
511        // Unique test-only keys: parallel resolve() tests count real sources
512        // into the same process-global map, so shared keys would be flaky.
513        count_source("test_flush_alpha");
514        count_source("test_flush_alpha");
515        count_source("test_flush_beta");
516        flush_sources();
517
518        count_source("test_flush_alpha");
519        flush_sources();
520
521        let persisted = persisted_source_counts();
522        let get = |k: &str| {
523            persisted
524                .iter()
525                .find(|(s, _)| s == k)
526                .map_or(0, |(_, n)| *n)
527        };
528        assert_eq!(
529            get("test_flush_alpha"),
530            3,
531            "two flushes must merge additively"
532        );
533        assert_eq!(get("test_flush_beta"), 1);
534
535        std::env::remove_var("LEAN_CTX_DATA_DIR");
536        let _ = std::fs::remove_dir_all(&dir);
537    }
538
539    #[test]
540    fn small_file_always_full() {
541        let ctx = AutoModeContext {
542            path: "test.rs",
543            token_count: 100,
544            task: None,
545            cache: None,
546        };
547        let result = resolve(&ctx);
548        assert_eq!(result.mode, "full");
549        assert_eq!(result.source, "small_file");
550    }
551
552    #[test]
553    fn config_file_returns_full() {
554        let ctx = AutoModeContext {
555            path: "config.ini",
556            token_count: 500,
557            task: None,
558            cache: None,
559        };
560        let result = resolve(&ctx);
561        assert_eq!(result.mode, "full");
562        assert_eq!(result.source, "config_data");
563    }
564
565    #[test]
566    fn intent_explore_returns_map() {
567        let ctx = AutoModeContext {
568            path: "large.rs",
569            token_count: 5000,
570            task: Some("how does the cache work?"),
571            cache: None,
572        };
573        let result = resolve(&ctx);
574        assert_eq!(result.mode, "map");
575        assert_eq!(result.source, "intent");
576    }
577
578    #[test]
579    fn task_names_file_matches_explicit_filename() {
580        assert!(task_names_file(
581            Some("fix the version sort in versioncmp.c"),
582            "src/versioncmp.c"
583        ));
584        assert!(task_names_file(
585            Some("why does graph.ts loop?"),
586            "web/src/graph.ts"
587        ));
588    }
589
590    #[test]
591    fn task_names_file_ignores_bare_stems_and_trivia() {
592        // A bare stem mention must not match the file.
593        assert!(!task_names_file(
594            Some("improve the parser"),
595            "src/parser.rs"
596        ));
597        assert!(!task_names_file(None, "src/parser.rs"));
598        // Trivial / extension-less basenames are excluded.
599        assert!(!task_names_file(Some("touch a.c"), "a.c"));
600        assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
601    }
602
603    #[test]
604    fn task_suspect_file_overrides_intent() {
605        let _lock = crate::core::data_dir::test_env_lock();
606        let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
607        let _ = std::fs::create_dir_all(&dir);
608        std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
609
610        // An explore-style task that would otherwise map (cf.
611        // intent_explore_returns_map) — but it names the file, so the suspect
612        // guard keeps the full body for localization.
613        let ctx = AutoModeContext {
614            path: "large.rs",
615            token_count: 5000,
616            task: Some("how does large.rs build the cache?"),
617            cache: None,
618        };
619        let result = resolve(&ctx);
620        assert_eq!(result.mode, "full");
621        assert_eq!(result.source, "task_suspect_file");
622
623        std::env::remove_var("LEAN_CTX_DATA_DIR");
624        let _ = std::fs::remove_dir_all(&dir);
625    }
626
627    #[test]
628    fn heuristic_full_for_medium_code_by_default() {
629        // Default (structure_first off): medium code stays full so the agent
630        // gets the body in one round-trip on a warm, re-readable session.
631        assert_eq!(heuristic_mode("rs", 1500, false), "full");
632        assert_eq!(heuristic_mode("ts", 1000, false), "full");
633    }
634
635    #[test]
636    fn heuristic_structure_first_maps_medium_code() {
637        // Structure-first: medium code becomes `map` on a cold read.
638        assert_eq!(heuristic_mode("rs", 1500, true), "map");
639        assert_eq!(heuristic_mode("c", 800, true), "map");
640    }
641
642    #[test]
643    fn heuristic_structure_first_keeps_tiny_and_prose_full() {
644        // Below the 500-token floor `full` is already best.
645        assert_eq!(heuristic_mode("rs", 400, true), "full");
646        // Non-code (prose / data) is never structure-first mapped.
647        assert_eq!(heuristic_mode("md", 4000, true), "full");
648        assert_eq!(heuristic_mode("txt", 1000, true), "full");
649    }
650
651    #[test]
652    fn heuristic_large_code_maps_regardless() {
653        assert_eq!(heuristic_mode("rs", 9000, false), "map");
654        assert_eq!(heuristic_mode("rs", 9000, true), "map");
655    }
656
657    /// Bug-fix read pattern: while localizing a planted defect the agent reads
658    /// many medium source files cold. With structure_first the resolver returns
659    /// `map` (cheap, localization-friendly) instead of an un-amortized `full`,
660    /// while every capability guard still takes precedence because it runs
661    /// before this fallback (here: the small-file guard keeps a tiny file full).
662    #[test]
663    fn structure_first_resolve_bugfix_cold_read() {
664        let _lock = crate::core::data_dir::test_env_lock();
665        let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
666        let _ = std::fs::create_dir_all(&dir);
667        std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
668        std::env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
669
670        let suspect = AutoModeContext {
671            path: "src/versioncmp.c",
672            token_count: 1500,
673            task: None,
674            cache: None,
675        };
676        let result = resolve(&suspect);
677        assert_eq!(result.mode, "map");
678        assert_eq!(result.source, "structure_first");
679
680        let tiny = AutoModeContext {
681            path: "src/util.c",
682            token_count: 120,
683            task: None,
684            cache: None,
685        };
686        assert_eq!(resolve(&tiny).mode, "full");
687
688        std::env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
689        std::env::remove_var("LEAN_CTX_DATA_DIR");
690        let _ = std::fs::remove_dir_all(&dir);
691    }
692
693    #[test]
694    fn structure_first_off_keeps_medium_code_full() {
695        let _lock = crate::core::data_dir::test_env_lock();
696        let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
697        let _ = std::fs::create_dir_all(&dir);
698        std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
699        std::env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
700
701        let ctx = AutoModeContext {
702            path: "src/versioncmp.c",
703            token_count: 1500,
704            task: None,
705            cache: None,
706        };
707        let result = resolve(&ctx);
708        assert_eq!(result.mode, "full");
709        assert_eq!(result.source, "heuristic");
710
711        std::env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
712        std::env::remove_var("LEAN_CTX_DATA_DIR");
713        let _ = std::fs::remove_dir_all(&dir);
714    }
715}