Skip to main content

lean_ctx_server/tools/
ctx_smart_read.rs

1// SPDX-License-Identifier: Apache-2.0
2use lean_ctx::core::auto_mode_resolver::{self, AutoModeContext};
3use lean_ctx::core::cache::SessionCache;
4use lean_ctx::core::tokens::count_tokens;
5
6pub fn select_mode(cache: &SessionCache, path: &str) -> String {
7    select_mode_with_task(cache, path, None)
8}
9
10/// Delegates to the unified `auto_mode_resolver::resolve()`.
11pub fn select_mode_with_task(cache: &SessionCache, path: &str, task: Option<&str>) -> String {
12    if let Ok(meta) = std::fs::metadata(path) {
13        let cap = lean_ctx::core::limits::max_read_bytes() as u64;
14        if meta.len() > cap {
15            return "full".to_string();
16        }
17    }
18
19    // Avoid a redundant disk read: ctx_read::handle re-reads the file anyway.
20    // For files already in the session cache (the common agent-loop case of
21    // re-reading a file), reuse the stored token count instead of reading from
22    // disk a second time just to pick a mode.
23    let token_count = cache
24        .get(path)
25        .filter(|e| !lean_ctx::core::cache::is_cache_entry_stale(path, e.stored_mtime))
26        .map(|e| e.original_tokens)
27        .filter(|t| *t > 0)
28        // Counted on what the read path itself returns for this request (G01):
29        // inside a request scope that is the admitted text, and a source that
30        // is withheld counts as 0 — the mode is never picked from raw bytes
31        // the read that follows would not deliver.
32        .unwrap_or_else(|| {
33            crate::tools::ctx_read::read_file_lossy(path).map_or(0, |c| count_tokens(&c))
34        });
35
36    let ctx = AutoModeContext {
37        path,
38        token_count,
39        line_count: None,
40        task,
41        cache: Some(cache),
42    };
43    auto_mode_resolver::resolve(&ctx).mode
44}
45
46/// The view for a `ctx_read` that names no range: the resolver's choice under
47/// [`auto_mode_resolver::unranged_view`] — an outline above
48/// [`auto_mode_resolver::OUTLINE_MIN_LINES`] lines, the whole file below.
49/// Counts come from the fresh cache entry or, like the resolver's own count,
50/// from what the read path would deliver (G01).
51pub fn select_unranged_mode(cache: &SessionCache, path: &str, task: Option<&str>) -> String {
52    // Over the read cap the handler refuses the read; do not load it here.
53    if std::fs::metadata(path)
54        .is_ok_and(|meta| meta.len() > lean_ctx::core::limits::max_read_bytes() as u64)
55    {
56        return "full".to_string();
57    }
58    let (token_count, line_count) = cache
59        .get(path)
60        .filter(|e| !lean_ctx::core::cache::is_cache_entry_stale(path, e.stored_mtime))
61        .filter(|e| e.original_tokens > 0)
62        .map(|e| (e.original_tokens, e.line_count))
63        .unwrap_or_else(|| {
64            crate::tools::ctx_read::read_file_lossy(path)
65                .map_or((0, 0), |c| (count_tokens(&c), c.lines().count()))
66        });
67    let ctx = AutoModeContext {
68        path,
69        token_count,
70        line_count: Some(line_count),
71        task,
72        cache: Some(cache),
73    };
74    auto_mode_resolver::unranged_view(auto_mode_resolver::resolve(&ctx), path, line_count).mode
75}
76
77pub fn is_code_ext(ext: &str) -> bool {
78    matches!(
79        ext,
80        "rs" | "ts"
81            | "tsx"
82            | "js"
83            | "jsx"
84            | "py"
85            | "go"
86            | "java"
87            | "c"
88            | "cpp"
89            | "cc"
90            | "h"
91            | "hpp"
92            | "rb"
93            | "cs"
94            | "kt"
95            | "swift"
96            | "php"
97            | "zig"
98            | "ex"
99            | "exs"
100            | "scala"
101            | "sc"
102            | "dart"
103            | "sh"
104            | "bash"
105            | "svelte"
106            | "vue"
107    )
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn test_code_detection() {
116        assert!(is_code_ext("rs"));
117        assert!(is_code_ext("py"));
118        assert!(is_code_ext("tsx"));
119        assert!(!is_code_ext("json"));
120    }
121
122    /// G01: the mode was picked from the token count of the raw file
123    /// (`fs::read_to_string`), also for a source the request may not read.
124    /// Inside a request the count now comes from the read path: a withheld
125    /// source counts as nothing, exactly like a file that does not exist.
126    #[test]
127    fn a_withheld_source_does_not_steer_the_mode() {
128        let _data = lean_ctx::core::data_dir::isolated_data_dir();
129        let policy = lean_ctx::core::policy::load(
130            "name = \"ctx-smart-read-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
131             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
132        )
133        .unwrap();
134        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy));
135        let project = tempfile::tempdir().unwrap();
136        let root = project.path().to_string_lossy().into_owned();
137        let body = "pub fn generated_handler(input: &str) -> usize { input.len() }\n".repeat(4000);
138        let blocked = project.path().join("blocked.rs");
139        let open = project.path().join("open.rs");
140        std::fs::write(&blocked, format!("CONFIDENTIAL\n{body}")).unwrap();
141        std::fs::write(&open, &body).unwrap();
142        let missing = project.path().join("missing.rs");
143        let cache = SessionCache::new();
144        let mode = |path: &std::path::Path| {
145            crate::tools::admitted_sources::as_agent_acquisition(&root, || {
146                select_mode(&cache, &path.to_string_lossy())
147            })
148        };
149        assert_eq!(mode(&blocked), mode(&missing));
150        // Control: a readable source of the same size is still measured.
151        let counted = crate::tools::admitted_sources::as_agent_acquisition(&root, || {
152            crate::tools::ctx_read::read_file_lossy(&open.to_string_lossy())
153                .map_or(0, |text| count_tokens(&text))
154        });
155        assert!(counted > 10_000, "{counted}");
156    }
157}