1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::core::cache::SessionCache;
5use crate::core::context_ledger::PressureAction;
6
7static SOURCE_COUNTS: Mutex<Option<HashMap<&'static str, u64>>> = Mutex::new(None);
10
11pub fn count_source(source: &'static str) {
12 if let Ok(mut guard) = SOURCE_COUNTS.lock() {
13 *guard
14 .get_or_insert_with(HashMap::new)
15 .entry(source)
16 .or_insert(0) += 1;
17 }
18}
19
20pub fn source_counts() -> Vec<(&'static str, u64)> {
22 let Ok(guard) = SOURCE_COUNTS.lock() else {
23 return Vec::new();
24 };
25 let mut items: Vec<(&'static str, u64)> = guard
26 .as_ref()
27 .map(|m| m.iter().map(|(k, v)| (*k, *v)).collect())
28 .unwrap_or_default();
29 items.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
30 items
31}
32
33fn sources_path() -> Option<std::path::PathBuf> {
34 crate::core::data_dir::lean_ctx_data_dir()
35 .ok()
36 .map(|d| d.join("auto_mode_sources.json"))
37}
38
39pub fn flush_sources() {
44 let drained: Vec<(String, u64)> = {
45 let Ok(mut guard) = SOURCE_COUNTS.lock() else {
46 return;
47 };
48 match guard.take() {
49 Some(m) if !m.is_empty() => m.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
50 _ => return,
51 }
52 };
53 let Some(path) = sources_path() else {
54 return;
55 };
56 let mut on_disk: HashMap<String, u64> = std::fs::read_to_string(&path)
57 .ok()
58 .and_then(|s| serde_json::from_str(&s).ok())
59 .unwrap_or_default();
60 for (k, v) in drained {
61 *on_disk.entry(k).or_insert(0) += v;
62 }
63 let Ok(json) = serde_json::to_string_pretty(&on_disk) else {
64 return;
65 };
66 let tmp = path.with_extension("json.tmp");
67 if std::fs::write(&tmp, json).is_ok() {
68 let _ = std::fs::rename(&tmp, &path);
69 }
70}
71
72pub fn persisted_source_counts() -> Vec<(String, u64)> {
75 let Some(path) = sources_path() else {
76 return Vec::new();
77 };
78 let map: HashMap<String, u64> = std::fs::read_to_string(&path)
79 .ok()
80 .and_then(|s| serde_json::from_str(&s).ok())
81 .unwrap_or_default();
82 let mut items: Vec<(String, u64)> = map.into_iter().collect();
83 items.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
84 items
85}
86
87pub struct AutoModeContext<'a> {
88 pub path: &'a str,
89 pub token_count: usize,
90 pub line_count: Option<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
100pub fn resolve_mode_precedence(
103 explicit: Option<String>,
104 configured: Option<String>,
105 learned: Option<String>,
106 default: &str,
107) -> String {
108 explicit
109 .or(configured)
110 .or(learned)
111 .unwrap_or_else(|| default.to_string())
112}
113
114pub fn configured_default_mode() -> Option<String> {
117 crate::core::policy::runtime::active()
118 .and_then(|policy| policy.resolved.default_read_mode.clone())
119 .or_else(|| crate::core::persona::active().read_mode_override())
120 .or_else(|| {
121 let profile = crate::core::profiles::active_profile();
122 let engine = profile.engine_config();
123 let mode = engine.read.default_mode;
124 (mode != "auto").then(|| mode.to_string())
125 })
126}
127
128pub fn resolve(ctx: &AutoModeContext) -> ResolvedMode {
131 if crate::core::edit_quality::take_pending_anchored_escalation(ctx.path) {
136 return resolved("anchored", "anchored_edit_fail_escalation");
137 }
138
139 if crate::core::edit_quality::take_pending_escalation(ctx.path) {
142 return resolved("full", "edit_fail_escalation");
143 }
144
145 let r = resolve_inner(ctx);
146
147 if r.mode != "full" && crate::core::edit_quality::is_risky_mode(ctx.path, &r.mode) {
153 return resolved("full", "edit_quality_penalty");
154 }
155 r
156}
157
158pub const OUTLINE_MIN_LINES: usize = 300;
168
169const UNRANGED_KEEP_SOURCES: &[&str] = &[
174 "cache_hit",
175 "cache_changed",
176 "binary",
177 "instruction_file",
178 "context_kit",
179 "anchored_edit_fail_escalation",
180 "edit_fail_escalation",
181 "edit_quality_penalty",
182];
183
184#[must_use]
188pub fn outline_mode(path: &str) -> &'static str {
189 let lower = path.to_ascii_lowercase();
190 let ext = std::path::Path::new(&lower)
191 .extension()
192 .and_then(|e| e.to_str())
193 .unwrap_or("");
194 if matches!(
195 ext,
196 "md" | "mdx" | "rst" | "json" | "yaml" | "yml" | "toml" | "lock"
197 ) || lower.ends_with("go.sum")
198 {
199 "map"
200 } else {
201 "signatures"
202 }
203}
204
205#[must_use]
210pub fn unranged_view(r: ResolvedMode, path: &str, line_count: usize) -> ResolvedMode {
211 if UNRANGED_KEEP_SOURCES.contains(&r.source) {
212 return r;
213 }
214 if line_count > OUTLINE_MIN_LINES {
215 if matches!(r.mode.as_str(), "signatures" | "map") {
216 return r;
217 }
218 return resolved(outline_mode(path), "large_file_outline");
219 }
220 if r.mode == "full" {
221 return r;
222 }
223 resolved("full", "small_file_whole")
224}
225
226fn resolve_inner(ctx: &AutoModeContext) -> ResolvedMode {
227 if crate::core::instruction_files::is_instruction_file(ctx.path) {
228 return resolved("full", "instruction_file");
229 }
230
231 if crate::core::binary_detect::is_binary_file(ctx.path) {
232 return resolved("full", "binary");
233 }
234
235 if let Some(mode) = crate::kits::active_mode_for_path(ctx.path) {
239 return resolved(&mode, "context_kit");
240 }
241
242 if let Some(cache) = ctx.cache
243 && let Some(cached) = cache.get(ctx.path)
244 {
245 if !file_unchanged(ctx.path, cached) {
246 return resolved("diff", "cache_changed");
247 }
248 if cache.is_full_delivered(ctx.path) {
251 return resolved("full", "cache_hit");
252 }
253 if crate::core::path_mode_memory::should_force_full(ctx.path) {
256 return resolved("full", "path_bounce_memory");
257 }
258 if let Some(prev_mode) = cache.last_mode(ctx.path)
261 && prev_mode != "full"
262 {
263 return resolved(&prev_mode, "cache_hit_compressed");
264 }
265 } else if crate::core::path_mode_memory::should_force_full(ctx.path) {
266 return resolved("full", "path_bounce_memory");
272 }
273
274 if ctx.token_count <= 200 {
275 return resolved("full", "small_file");
276 }
277
278 let ext = std::path::Path::new(ctx.path)
279 .extension()
280 .and_then(|e| e.to_str())
281 .unwrap_or("");
282
283 if ctx.token_count <= 400 && is_code(ext) {
284 return resolved("full", "small_code_file");
285 }
286
287 if is_config_or_data(ext, ctx.path) {
288 if ctx.token_count <= 1000 {
289 return resolved("full", "config_data");
290 }
291 return resolved("map", "config_data_large");
292 }
293
294 if crate::core::diagnostics_store::has_error(ctx.path) {
297 return resolved("full", "active_diagnostic");
298 }
299
300 if task_names_file(ctx.task, ctx.path)
305 && intent_recommended_mode(ctx.task).as_deref() == Some("full")
306 {
307 return resolved("full", "task_suspect_file");
308 }
309
310 if let Some(mode) = intent_recommended_mode(ctx.task) {
311 if mode != "full" {
317 return resolved(&mode, "intent");
318 }
319 }
320
321 let cfg = crate::core::config::Config::load();
335 if cfg.progressive_disclosure_effective() {
336 let lines = ctx
337 .line_count
338 .unwrap_or_else(|| estimate_lines(ctx.token_count));
339 let threshold = cfg.progressive_threshold_lines as usize;
340 let sig_max = cfg.progressive_signatures_max as usize;
341
342 if lines >= sig_max && is_code(ext) {
343 return resolved("map", "progressive_manifest");
344 }
345 if lines >= threshold && is_code(ext) {
346 return resolved("signatures", "progressive_signatures");
347 }
348 }
349
350 let structure_first = cfg.structure_first_effective();
356 let heuristic = heuristic_mode(ext, ctx.token_count, structure_first);
357 let source = if structure_first && heuristic == "map" && ctx.token_count <= 6000 {
358 "structure_first"
359 } else {
360 "heuristic"
361 };
362 resolved(&heuristic, source)
363}
364
365pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
368 match action {
369 PressureAction::SuggestCompression => match requested_mode {
370 "auto" | "full" => Some("map".to_string()),
371 _ => None,
372 },
373 PressureAction::ForceCompression => match requested_mode {
374 "full" => Some("map".to_string()),
375 "auto" | "map" | "cognitive" => Some("signatures".to_string()),
376 _ => None,
377 },
378 PressureAction::EvictLeastRelevant => match requested_mode {
379 "full" => Some("map".to_string()),
380 "auto" | "map" | "cognitive" => Some("signatures".to_string()),
381 "signatures" => Some("reference".to_string()),
382 _ => None,
383 },
384 PressureAction::NoAction => None,
385 }
386}
387
388fn task_names_file(task: Option<&str>, path: &str) -> bool {
395 let Some(task) = task else {
396 return false;
397 };
398 let basename = std::path::Path::new(path)
399 .file_name()
400 .and_then(|n| n.to_str())
401 .unwrap_or("");
402 if basename.len() < 4 || !basename.contains('.') {
403 return false;
404 }
405 task.to_ascii_lowercase()
406 .contains(&basename.to_ascii_lowercase())
407}
408
409fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
410 let task_desc = task?;
411 let classification = crate::core::intent_engine::classify(task_desc);
412 if classification.confidence < 0.4 {
413 return None;
414 }
415 let mode = crate::core::intent_router::read_mode_for_task(classification.task_type);
416 if mode == "auto" {
417 return None;
418 }
419 Some(mode)
420}
421
422fn heuristic_mode(ext: &str, token_count: usize, structure_first: bool) -> String {
423 if token_count > 8000 {
424 if is_code(ext) {
425 return "map".to_string();
426 }
427 return "aggressive".to_string();
428 }
429 if token_count > 2000 && is_prose(ext) {
430 return "aggressive".to_string();
431 }
432 if token_count > 500 && is_prose(ext) {
433 return "map".to_string();
434 }
435 if token_count > 500 && !is_code(ext) && !is_prose(ext) {
436 return "map".to_string();
437 }
438 if token_count > 6000 && is_code(ext) {
440 return "map".to_string();
441 }
442 if structure_first && token_count > 500 && is_code(ext) {
449 return "map".to_string();
450 }
451 if token_count > 500 && is_code(ext) {
456 return "signatures".to_string();
457 }
458 "full".to_string()
459}
460
461fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
470 let Some(stored_mtime) = cached.stored_mtime else {
471 return false;
472 };
473 let Ok(meta) = std::fs::metadata(path) else {
474 return false;
475 };
476 let Ok(current_mtime) = meta.modified() else {
477 return false;
478 };
479 current_mtime == stored_mtime
480}
481
482fn is_code(ext: &str) -> bool {
483 matches!(
484 ext,
485 "rs" | "ts"
486 | "tsx"
487 | "js"
488 | "jsx"
489 | "py"
490 | "go"
491 | "java"
492 | "c"
493 | "cpp"
494 | "cc"
495 | "h"
496 | "hpp"
497 | "rb"
498 | "cs"
499 | "kt"
500 | "swift"
501 | "php"
502 | "zig"
503 | "ex"
504 | "exs"
505 | "scala"
506 | "sc"
507 | "dart"
508 | "toml"
509 | "yaml"
510 | "yml"
511 | "json"
512 | "sh"
513 | "bash"
514 | "svelte"
515 | "vue"
516 | "astro"
517 | "mdx"
518 | "njk"
519 | "hbs"
520 | "ejs"
521 | "erb"
522 | "jinja"
523 | "jinja2"
524 | "twig"
525 | "pug"
526 | "slim"
527 | "haml"
528 | "liquid"
529 )
530}
531
532fn is_prose(ext: &str) -> bool {
533 matches!(
534 ext,
535 "md" | "mdx" | "txt" | "rst" | "adoc" | "org" | "tex" | "html" | "htm"
536 )
537}
538
539fn estimate_lines(token_count: usize) -> usize {
542 token_count / 4
543}
544
545fn is_config_or_data(ext: &str, path: &str) -> bool {
546 if matches!(ext, "xml" | "ini" | "cfg" | "env") {
547 return true;
548 }
549 let name = std::path::Path::new(path)
550 .file_name()
551 .and_then(|n| n.to_str())
552 .unwrap_or("");
553 matches!(
554 name,
555 "Cargo.toml"
556 | "package.json"
557 | "tsconfig.json"
558 | "Makefile"
559 | "Dockerfile"
560 | "docker-compose.yml"
561 | ".gitignore"
562 | ".env"
563 | "pyproject.toml"
564 | "go.mod"
565 | "build.gradle"
566 | "pom.xml"
567 )
568}
569
570fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
571 count_source(source);
572 ResolvedMode {
573 mode: mode.to_string(),
574 source,
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn mode_precedence_is_explicit_then_configured_then_learned_then_default() {
584 assert_eq!(
585 resolve_mode_precedence(
586 Some("raw".to_string()),
587 Some("map".to_string()),
588 Some("signatures".to_string()),
589 "full",
590 ),
591 "raw"
592 );
593 assert_eq!(
594 resolve_mode_precedence(
595 None,
596 Some("map".to_string()),
597 Some("signatures".to_string()),
598 "full",
599 ),
600 "map"
601 );
602 assert_eq!(
603 resolve_mode_precedence(None, None, Some("signatures".to_string()), "full"),
604 "signatures"
605 );
606 assert_eq!(resolve_mode_precedence(None, None, None, "full"), "full");
607 }
608
609 #[test]
610 fn pressure_suggest_full_to_map() {
611 assert_eq!(
612 pressure_downgrade("full", &PressureAction::SuggestCompression),
613 Some("map".to_string())
614 );
615 }
616
617 #[test]
618 fn pressure_suggest_auto_to_map() {
619 assert_eq!(
620 pressure_downgrade("auto", &PressureAction::SuggestCompression),
621 Some("map".to_string())
622 );
623 }
624
625 #[test]
626 fn pressure_suggest_does_not_touch_signatures() {
627 assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
628 }
629
630 #[test]
631 fn pressure_force_full_to_map() {
632 assert_eq!(
633 pressure_downgrade("full", &PressureAction::ForceCompression),
634 Some("map".to_string())
635 );
636 }
637
638 #[test]
639 fn pressure_force_map_to_signatures() {
640 assert_eq!(
641 pressure_downgrade("map", &PressureAction::ForceCompression),
642 Some("signatures".to_string())
643 );
644 }
645
646 #[test]
647 fn pressure_evict_signatures_to_reference() {
648 assert_eq!(
649 pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
650 Some("reference".to_string())
651 );
652 }
653
654 #[test]
655 fn pressure_noaction_returns_none() {
656 assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
657 }
658
659 #[test]
660 fn flush_sources_merges_additively_into_disk_file() {
661 let _lock = crate::core::data_dir::test_env_lock();
662 let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
663 let _ = std::fs::create_dir_all(&dir);
664 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
665 let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
666
667 count_source("test_flush_alpha");
670 count_source("test_flush_alpha");
671 count_source("test_flush_beta");
672 flush_sources();
673
674 count_source("test_flush_alpha");
675 flush_sources();
676
677 let persisted = persisted_source_counts();
678 let get = |k: &str| {
679 persisted
680 .iter()
681 .find(|(s, _)| s == k)
682 .map_or(0, |(_, n)| *n)
683 };
684 assert_eq!(
685 get("test_flush_alpha"),
686 3,
687 "two flushes must merge additively"
688 );
689 assert_eq!(get("test_flush_beta"), 1);
690
691 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
692 let _ = std::fs::remove_dir_all(&dir);
693 }
694
695 #[test]
696 fn small_file_always_full() {
697 let ctx = AutoModeContext {
698 path: "test.rs",
699 token_count: 100,
700 line_count: None,
701 task: None,
702 cache: None,
703 };
704 let result = resolve(&ctx);
705 assert_eq!(result.mode, "full");
706 assert_eq!(result.source, "small_file");
707 }
708
709 #[test]
710 fn config_file_returns_full() {
711 let ctx = AutoModeContext {
712 path: "config.ini",
713 token_count: 500,
714 line_count: None,
715 task: None,
716 cache: None,
717 };
718 let result = resolve(&ctx);
719 assert_eq!(result.mode, "full");
720 assert_eq!(result.source, "config_data");
721 }
722
723 #[test]
724 fn cached_compressed_only_file_reuses_cached_mode() {
725 let dir = tempfile::tempdir().unwrap();
729 let file = dir.path().join("large.rs");
730 let body = "fn placeholder() { let _ = 1; }\n".repeat(900);
731 std::fs::write(&file, &body).unwrap();
732 let path = file.to_str().unwrap();
733
734 let mut cache = SessionCache::new();
735 cache.store(path, &body);
736 cache.get_mut(path).unwrap().last_mode = "map".to_string();
737
738 let ctx = AutoModeContext {
739 path,
740 token_count: 7000,
741 line_count: None,
742 task: None,
743 cache: Some(&cache),
744 };
745 let result = resolve(&ctx);
746 assert!(
747 result.mode == "map" || result.mode == "cognitive",
748 "expected map or cognitive, got: {}",
749 result.mode
750 );
751 assert_eq!(result.source, "cache_hit_compressed");
752 }
753
754 #[test]
755 fn cached_full_delivered_file_short_circuits_to_stub() {
756 let dir = tempfile::tempdir().unwrap();
759 let file = dir.path().join("medium.rs");
760 let body = "fn placeholder() { let _ = 1; }\n".repeat(400);
761 std::fs::write(&file, &body).unwrap();
762 let path = file.to_str().unwrap();
763
764 let mut cache = SessionCache::new();
765 cache.store(path, &body);
766 cache.mark_full_delivered(path);
767
768 let ctx = AutoModeContext {
769 path,
770 token_count: 3000,
771 line_count: None,
772 task: None,
773 cache: Some(&cache),
774 };
775 let result = resolve(&ctx);
776 assert_eq!(result.mode, "full");
777 assert_eq!(result.source, "cache_hit");
778 }
779
780 #[test]
781 fn learned_path_force_full_applies() {
782 let _iso = crate::core::data_dir::isolated_data_dir();
786 let dir = tempfile::tempdir().unwrap();
787 let file = dir.path().join("learned-bouncy.rs");
788 let body = "fn placeholder() { let _ = 1; }\n".repeat(900);
789 std::fs::write(&file, &body).unwrap();
790 let path = file.to_str().unwrap();
791 let norm = crate::core::pathutil::normalize_tool_path(path);
792 crate::core::path_mode_memory::record_bounce(&norm);
793 crate::core::path_mode_memory::record_bounce(&norm);
794
795 let cold = resolve(&AutoModeContext {
796 path,
797 token_count: 7000,
798 line_count: None,
799 task: None,
800 cache: None,
801 });
802 assert_eq!(
803 (cold.mode.as_str(), cold.source),
804 ("full", "path_bounce_memory")
805 );
806
807 let mut cache = SessionCache::new();
808 cache.store(path, &body);
809 cache.get_mut(path).unwrap().last_mode = "map".to_string();
810 let warm = resolve(&AutoModeContext {
811 path,
812 token_count: 7000,
813 line_count: None,
814 task: None,
815 cache: Some(&cache),
816 });
817 assert_eq!(
818 (warm.mode.as_str(), warm.source),
819 ("full", "path_bounce_memory"),
820 "learned force-full outranks reusing the bouncing compressed view"
821 );
822 }
823
824 #[test]
825 fn intent_explore_returns_map() {
826 let ctx = AutoModeContext {
827 path: "large.rs",
828 token_count: 5000,
829 line_count: None,
830 task: Some("how does the cache work?"),
831 cache: None,
832 };
833 let result = resolve(&ctx);
834 assert!(
835 result.mode == "map" || result.mode == "cognitive",
836 "expected map or cognitive, got: {}",
837 result.mode
838 );
839 assert_eq!(result.source, "intent");
840 }
841
842 #[test]
843 fn task_names_file_matches_explicit_filename() {
844 assert!(task_names_file(
845 Some("fix the version sort in versioncmp.c"),
846 "src/versioncmp.c"
847 ));
848 assert!(task_names_file(
849 Some("why does graph.ts loop?"),
850 "web/src/graph.ts"
851 ));
852 }
853
854 #[test]
855 fn task_names_file_ignores_bare_stems_and_trivia() {
856 assert!(!task_names_file(
858 Some("improve the parser"),
859 "src/parser.rs"
860 ));
861 assert!(!task_names_file(None, "src/parser.rs"));
862 assert!(!task_names_file(Some("touch a.c"), "a.c"));
864 assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
865 }
866
867 #[test]
868 fn task_suspect_file_overrides_intent() {
869 let _lock = crate::core::data_dir::test_env_lock();
870 let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
871 let _ = std::fs::create_dir_all(&dir);
872 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
873
874 let ctx = AutoModeContext {
878 path: "large.rs",
879 token_count: 5000,
880 line_count: None,
881 task: Some("how does large.rs build the cache?"),
882 cache: None,
883 };
884 let result = resolve(&ctx);
885 assert_eq!(result.mode, "full");
886 assert_eq!(result.source, "task_suspect_file");
887
888 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
889 let _ = std::fs::remove_dir_all(&dir);
890 }
891
892 #[test]
893 fn intent_full_skipped_for_non_suspect_files() {
894 let _iso = crate::core::data_dir::isolated_data_dir();
900 let ctx = AutoModeContext {
901 path: "unrelated_module.rs",
902 token_count: 5000,
903 line_count: None,
904 task: Some("fix the bug in parser.rs"),
905 cache: None,
906 };
907 let result = resolve(&ctx);
908 assert_ne!(
909 result.mode, "full",
910 "non-suspect file must not get full mode from a coding task intent"
911 );
912 assert_ne!(
913 result.source, "intent",
914 "general intent must not force full on non-suspect files"
915 );
916 }
917
918 #[test]
919 fn heuristic_medium_code_uses_signatures_by_default() {
920 assert_eq!(heuristic_mode("rs", 1500, false), "signatures");
921 assert_eq!(heuristic_mode("ts", 1000, false), "signatures");
922 }
923
924 #[test]
925 fn heuristic_structure_first_maps_medium_code() {
926 assert_eq!(heuristic_mode("rs", 1500, true), "map");
928 assert_eq!(heuristic_mode("c", 800, true), "map");
929 }
930
931 #[test]
932 fn heuristic_structure_first_keeps_tiny_and_prose_full() {
933 assert_eq!(heuristic_mode("rs", 400, true), "full");
935 assert_eq!(heuristic_mode("md", 4000, true), "aggressive");
937 assert_eq!(heuristic_mode("md", 1500, true), "map");
938 assert_eq!(heuristic_mode("txt", 3000, true), "aggressive");
939 assert_eq!(heuristic_mode("txt", 1000, true), "map");
940 }
941
942 #[test]
943 fn code_and_data_extensions_cover_progressive_formats() {
944 for ext in [
945 "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "rb", "swift",
946 "kt", "scala", "toml", "yaml", "yml", "json", "sh", "bash", "svelte", "vue", "astro",
947 "mdx", "njk", "hbs", "ejs", "erb", "jinja", "jinja2", "twig", "pug", "slim", "haml",
948 "liquid",
949 ] {
950 assert!(
951 is_code(ext),
952 "{ext} should participate in structure-first modes"
953 );
954 }
955 assert!(!is_code("md"));
957 }
958
959 #[test]
960 fn heuristic_large_code_maps_regardless() {
961 assert_eq!(heuristic_mode("rs", 9000, false), "map");
962 assert_eq!(heuristic_mode("rs", 9000, true), "map");
963 }
964
965 #[test]
971 fn structure_first_resolve_bugfix_cold_read() {
972 let _lock = crate::core::data_dir::test_env_lock();
973 let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
974 let _ = std::fs::create_dir_all(&dir);
975 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
976 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
977 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
978
979 let suspect = AutoModeContext {
980 path: "src/versioncmp.c",
981 token_count: 1500,
982 line_count: None,
983 task: None,
984 cache: None,
985 };
986 let result = resolve(&suspect);
987 assert!(
988 result.mode == "map" || result.mode == "cognitive",
989 "expected map or cognitive, got: {}",
990 result.mode
991 );
992 assert!(
993 result.source == "structure_first" || result.source.starts_with("science_"),
994 "source: {}",
995 result.source
996 );
997
998 let tiny = AutoModeContext {
999 path: "src/util.c",
1000 token_count: 120,
1001 line_count: None,
1002 task: None,
1003 cache: None,
1004 };
1005 assert_eq!(resolve(&tiny).mode, "full");
1006
1007 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1008 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1009 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1010 let _ = std::fs::remove_dir_all(&dir);
1011 }
1012
1013 #[test]
1014 fn structure_first_off_uses_signatures_for_medium_code() {
1015 let _lock = crate::core::data_dir::test_env_lock();
1016 let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
1017 let _ = std::fs::create_dir_all(&dir);
1018 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1019 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
1020 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1021
1022 let ctx = AutoModeContext {
1023 path: "src/versioncmp.c",
1024 token_count: 1500,
1025 line_count: None,
1026 task: None,
1027 cache: None,
1028 };
1029 let result = resolve(&ctx);
1030 assert!(
1031 result.mode == "signatures" || result.mode == "cognitive",
1032 "sf=off → signatures or cognitive, got: {}",
1033 result.mode
1034 );
1035 assert!(
1036 result.source == "heuristic" || result.source.starts_with("science_"),
1037 "source: {}",
1038 result.source
1039 );
1040
1041 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1042 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1043 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1044 let _ = std::fs::remove_dir_all(&dir);
1045 }
1046
1047 #[test]
1052 fn auto_resolution_is_deterministic() {
1053 let _lock = crate::core::data_dir::test_env_lock();
1054 let dir = std::env::temp_dir().join(format!("lctx-amr-det-{}", std::process::id()));
1055 let _ = std::fs::create_dir_all(&dir);
1056 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1057 crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "1");
1058 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1059 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1060
1061 let ctx = AutoModeContext {
1062 path: "src/widget.rs",
1063 token_count: 1500,
1064 line_count: None,
1065 task: None,
1066 cache: None,
1067 };
1068 let a = resolve(&ctx);
1069 let b = resolve(&ctx);
1070 assert_eq!((a.mode.as_str(), a.source), ("signatures", "heuristic"));
1073 assert_eq!((a.mode, a.source), (b.mode, b.source));
1074
1075 crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
1076 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1077 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1078 let _ = std::fs::remove_dir_all(&dir);
1079 }
1080
1081 #[test]
1082 fn progressive_small_file_stays_full() {
1083 let _lock = crate::core::data_dir::test_env_lock();
1084 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1085
1086 let ctx = AutoModeContext {
1087 path: "small.rs",
1088 token_count: 200,
1089 line_count: Some(50),
1090 task: None,
1091 cache: None,
1092 };
1093 let result = resolve(&ctx);
1094 assert_eq!(result.mode, "full", "50 lines → full");
1095
1096 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1097 }
1098
1099 #[test]
1100 fn progressive_medium_file_gets_signatures() {
1101 let _lock = crate::core::data_dir::test_env_lock();
1102 let dir = std::env::temp_dir().join(format!("lctx-pd-sig-{}", std::process::id()));
1103 let _ = std::fs::create_dir_all(&dir);
1104 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1105 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1106
1107 let ctx = AutoModeContext {
1108 path: "medium.rs",
1109 token_count: 800,
1110 line_count: Some(200),
1111 task: None,
1112 cache: None,
1113 };
1114 let result = resolve(&ctx);
1115 assert!(
1116 result.mode == "signatures" || result.mode == "cognitive",
1117 "200 lines → signatures or cognitive, got: {}",
1118 result.mode
1119 );
1120 assert!(
1121 result.source == "progressive_signatures" || result.source.starts_with("science_"),
1122 "source: {}",
1123 result.source
1124 );
1125
1126 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1127 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1128 let _ = std::fs::remove_dir_all(&dir);
1129 }
1130
1131 #[test]
1132 fn progressive_large_file_gets_map() {
1133 let _lock = crate::core::data_dir::test_env_lock();
1134 let dir = std::env::temp_dir().join(format!("lctx-pd-map-{}", std::process::id()));
1135 let _ = std::fs::create_dir_all(&dir);
1136 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1137 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1138
1139 let ctx = AutoModeContext {
1140 path: "large.rs",
1141 token_count: 3200,
1142 line_count: Some(800),
1143 task: None,
1144 cache: None,
1145 };
1146 let result = resolve(&ctx);
1147 assert!(
1148 result.mode == "map" || result.mode == "cognitive",
1149 "800 lines → map or cognitive, got: {}",
1150 result.mode
1151 );
1152 assert!(
1153 result.source == "progressive_manifest" || result.source.starts_with("science_"),
1154 "source: {}",
1155 result.source
1156 );
1157
1158 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1159 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1160 let _ = std::fs::remove_dir_all(&dir);
1161 }
1162
1163 #[test]
1164 fn progressive_disabled_skips_tiering() {
1165 let _lock = crate::core::data_dir::test_env_lock();
1166 let dir = std::env::temp_dir().join(format!("lctx-pd-off-{}", std::process::id()));
1167 let _ = std::fs::create_dir_all(&dir);
1168 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1169 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1170 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
1171
1172 let ctx = AutoModeContext {
1173 path: "medium.rs",
1174 token_count: 800,
1175 line_count: Some(200),
1176 task: None,
1177 cache: None,
1178 };
1179 let result = resolve(&ctx);
1180 assert!(
1181 result.mode == "signatures" || result.mode == "cognitive",
1182 "progressive off → signatures or cognitive, got: {}",
1183 result.mode
1184 );
1185
1186 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1187 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1188 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1189 let _ = std::fs::remove_dir_all(&dir);
1190 }
1191
1192 #[test]
1196 fn unranged_view_outlines_large_files_and_keeps_small_ones_whole() {
1197 let r = |mode: &str, source: &'static str| ResolvedMode {
1198 mode: mode.to_string(),
1199 source,
1200 };
1201 let big = OUTLINE_MIN_LINES + 1;
1202 for (mode, source, path, lines, expected) in [
1203 ("full", "heuristic", "a.rs", big, "signatures"),
1204 ("aggressive", "heuristic", "notes.txt", big, "signatures"),
1205 (
1206 "lines:46-228",
1207 "cache_hit_compressed",
1208 "a.py",
1209 big,
1210 "signatures",
1211 ),
1212 ("full", "path_bounce_memory", "a.go", big, "signatures"),
1213 ("full", "config_data", "data.json", big, "map"),
1214 ("aggressive", "heuristic", "README.md", big, "map"),
1215 ("map", "heuristic", "a.rs", big, "map"),
1216 ("signatures", "heuristic", "a.rs", OUTLINE_MIN_LINES, "full"),
1217 ("lines:1-40", "cache_hit_compressed", "a.rs", 120, "full"),
1218 ("full", "small_file", "a.rs", 10, "full"),
1219 ("full", "cache_hit", "a.rs", big, "full"),
1220 ("diff", "cache_changed", "a.rs", big, "diff"),
1221 ("full", "instruction_file", "AGENTS.md", big, "full"),
1222 ("full", "edit_fail_escalation", "a.rs", big, "full"),
1223 (
1224 "anchored",
1225 "anchored_edit_fail_escalation",
1226 "a.rs",
1227 big,
1228 "anchored",
1229 ),
1230 ("map", "context_kit", "a.rs", 10, "map"),
1231 ] {
1232 assert_eq!(
1233 unranged_view(r(mode, source), path, lines).mode,
1234 expected,
1235 "{mode} ({source}) for {path} with {lines} lines"
1236 );
1237 }
1238 }
1239
1240 #[test]
1241 fn estimate_lines_approximation() {
1242 assert_eq!(estimate_lines(400), 100);
1243 assert_eq!(estimate_lines(2000), 500);
1244 assert_eq!(estimate_lines(0), 0);
1245 }
1246}