Skip to main content

lean_ctx_server/tools/
ctx_tree.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4
5use ignore::WalkBuilder;
6
7use lean_ctx::core::tokens::count_tokens;
8
9struct Entry {
10    depth: usize,
11    name: String,
12    is_dir: bool,
13    path: PathBuf,
14}
15
16/// Generates a compact directory tree body with the raw tree token count.
17pub fn handle(
18    path: &str,
19    depth: usize,
20    show_hidden: bool,
21    respect_gitignore: bool,
22) -> (String, usize) {
23    let requested_root = Path::new(path);
24    let walk_root = lean_ctx::core::walk_filter::explicit_walk_root(requested_root);
25    let root = walk_root.as_path();
26
27    if root.is_file() {
28        let parent = root
29            .parent()
30            .map_or(path.to_string(), |parent| parent.display().to_string());
31        return (
32            format!(
33                "ERROR: '{path}' is a file, not a directory. Use path=\"{parent}\" for the containing directory."
34            ),
35            0,
36        );
37    }
38    if !root.is_dir() {
39        return (
40            format!(
41                "ERROR: {path} does not exist or is not a directory{}",
42                lean_ctx::core::path_suggest::hint(root)
43            ),
44            0,
45        );
46    }
47
48    if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(path) {
49        return (err, 0);
50    }
51
52    let (entries, raw_tokens) = match collect_entries(root, depth, show_hidden, respect_gitignore) {
53        Ok(entries) => entries,
54        Err(error) => return (format!("ERROR: {error}"), 0),
55    };
56    let body = render_compact_tree(root, &entries);
57    if body.trim().is_empty() {
58        return (format!("{path}/ (empty directory, depth={depth})"), 0);
59    }
60
61    (body, raw_tokens)
62}
63
64fn collect_entries(
65    root: &Path,
66    max_depth: usize,
67    show_hidden: bool,
68    respect_gitignore: bool,
69) -> Result<(Vec<Entry>, usize), &'static str> {
70    let mut entries = Vec::new();
71    let mut raw_tokens = 0;
72    let newline_tokens = count_tokens("\n");
73    let protected = lean_ctx::core::policy::runtime::active().is_some();
74    let allow_secret_paths = lean_ctx::core::roles::active_role().io.allow_secret_paths;
75
76    let walker = WalkBuilder::new(root)
77        .hidden(!show_hidden)
78        .git_ignore(respect_gitignore)
79        .git_global(respect_gitignore)
80        .git_exclude(respect_gitignore)
81        .require_git(false)
82        .max_depth(Some(max_depth))
83        .sort_by_file_name(std::cmp::Ord::cmp)
84        .filter_entry(move |entry| {
85            if respect_gitignore {
86                lean_ctx::core::walk_filter::keep_entry(entry)
87            } else {
88                lean_ctx::core::cloud_files::keep_entry(entry)
89            }
90        })
91        .build();
92
93    for (visited, entry) in walker.filter_map(std::result::Result::ok).enumerate() {
94        if protected && visited >= 20_000 {
95            return Err("protected directory walk limit exceeded");
96        }
97        if entry.depth() == 0 {
98            continue;
99        }
100
101        let depth = entry.depth();
102        let admitted = if protected {
103            if !allow_secret_paths
104                && lean_ctx::core::io_boundary::is_secret_like(entry.path()).is_some()
105            {
106                continue;
107            }
108            match crate::server::policy_guard::protect_result(
109                "ctx_tree",
110                &entry.path().to_string_lossy(),
111            ) {
112                Ok(path) => path,
113                Err(_) => continue,
114            }
115        } else {
116            entry.path().to_string_lossy().into_owned()
117        };
118        let name = Path::new(&admitted)
119            .file_name()
120            .unwrap_or_default()
121            .to_string_lossy()
122            .into_owned();
123        let is_dir = entry
124            .file_type()
125            .is_some_and(|file_type| file_type.is_dir());
126        let path = entry.into_path();
127        let relative_path = Path::new(&admitted)
128            .strip_prefix(root)
129            .unwrap_or(Path::new(&admitted))
130            .to_string_lossy();
131
132        if !entries.is_empty() {
133            raw_tokens += newline_tokens;
134        }
135        raw_tokens += count_tokens(&relative_path);
136
137        entries.push(Entry {
138            depth,
139            name,
140            is_dir,
141            path,
142        });
143    }
144
145    Ok((entries, raw_tokens))
146}
147
148fn render_compact_tree(root: &Path, entries: &[Entry]) -> String {
149    let mut lines = Vec::new();
150
151    let mut dir_file_counts: HashMap<&Path, usize> = HashMap::new();
152    for entry in entries {
153        if !entry.is_dir
154            && let Some(parent) = entry.path.parent()
155        {
156            *dir_file_counts.entry(parent).or_default() += 1;
157        }
158    }
159
160    let (hive_summaries, hive_skipped) = detect_hive_partitions(entries, &dir_file_counts);
161    if let Some(summary) = hive_summaries.get(root) {
162        let root_name = root.file_name().map_or_else(
163            || root.display().to_string(),
164            |name| name.to_string_lossy().to_string(),
165        );
166        lines.push(format!("{root_name}/ ({summary})"));
167    }
168
169    for entry in entries {
170        if hive_skipped.contains(&entry.path) {
171            continue;
172        }
173        let indent = "  ".repeat(entry.depth.saturating_sub(1));
174        if entry.is_dir {
175            if let Some(summary) = hive_summaries.get(&entry.path) {
176                lines.push(format!("{indent}{}/ ({summary})", entry.name));
177            } else {
178                let count = dir_file_counts
179                    .get(entry.path.as_path())
180                    .copied()
181                    .unwrap_or(0);
182                lines.push(format!("{indent}{}/ ({count})", entry.name));
183            }
184        } else {
185            lines.push(format!("{indent}{}", entry.name));
186        }
187    }
188
189    lines.join("\n")
190}
191
192/// Detects Hive-partitioned directories and returns summaries plus paths to omit.
193fn detect_hive_partitions(
194    entries: &[Entry],
195    dir_file_counts: &HashMap<&Path, usize>,
196) -> (HashMap<PathBuf, String>, HashSet<PathBuf>) {
197    let mut children_by_parent: HashMap<&Path, Vec<&Entry>> = HashMap::new();
198    for entry in entries.iter().filter(|entry| entry.is_dir) {
199        if let Some(parent) = entry.path.parent() {
200            children_by_parent.entry(parent).or_default().push(entry);
201        }
202    }
203
204    let mut summaries = HashMap::new();
205    let mut skipped = HashSet::new();
206    for (parent, children) in children_by_parent {
207        let Some(key) = children
208            .first()
209            .and_then(|entry| hive_partition_key(&entry.name))
210        else {
211            continue;
212        };
213        if children.len() < 3
214            || children
215                .iter()
216                .any(|entry| hive_partition_key(&entry.name) != Some(key))
217        {
218            continue;
219        }
220
221        let file_count = children
222            .iter()
223            .map(|entry| {
224                dir_file_counts
225                    .get(entry.path.as_path())
226                    .copied()
227                    .unwrap_or(0)
228            })
229            .sum::<usize>();
230        summaries.insert(
231            parent.to_path_buf(),
232            format!(
233                "hive: {key}=* — {} partitions, {file_count} files",
234                children.len()
235            ),
236        );
237        for entry in entries {
238            if children
239                .iter()
240                .any(|child| entry.path.starts_with(&child.path))
241            {
242                skipped.insert(entry.path.clone());
243            }
244        }
245    }
246
247    (summaries, skipped)
248}
249
250fn hive_partition_key(name: &str) -> Option<&str> {
251    let (key, value) = name.split_once('=')?;
252    if value.is_empty() {
253        return None;
254    }
255
256    let mut chars = key.chars();
257    let first = chars.next()?;
258    if !(first.is_ascii_alphabetic() || first == '_')
259        || !chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
260    {
261        return None;
262    }
263    Some(key)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::{collect_entries, count_tokens, handle, render_compact_tree};
269
270    /// Builds a deterministic source-tree fixture so the assertions do not
271    /// depend on the live repository size or platform path separators (the live
272    /// repo coupling previously made this test tip over its token threshold on
273    /// Windows as the codebase grew).
274    fn make_fixture() -> tempfile::TempDir {
275        let dir = tempfile::tempdir().unwrap();
276        let root = dir.path();
277        let files = [
278            "Cargo.toml",
279            "README.md",
280            "src/main.rs",
281            "src/lib.rs",
282            "src/core/mod.rs",
283            "src/core/engine.rs",
284            "src/core/util.rs",
285            "src/tools/mod.rs",
286            "src/tools/reader.rs",
287            "tests/integration.rs",
288            "tests/smoke.rs",
289        ];
290        for rel in files {
291            let p = root.join(rel);
292            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
293            std::fs::write(&p, "// fixture\n").unwrap();
294        }
295        dir
296    }
297
298    #[test]
299    fn tree_derives_body_and_raw_tokens_from_one_entry_set() {
300        let dir = make_fixture();
301        let root = dir.path();
302        let (entries, expected_raw_tokens) = collect_entries(root, 3, false, true).unwrap();
303
304        let (body, raw_tokens) = handle(&root.to_string_lossy(), 3, false, true);
305
306        assert_eq!(body, render_compact_tree(root, &entries));
307        assert_eq!(raw_tokens, expected_raw_tokens);
308    }
309
310    #[test]
311    fn tree_savings_are_reasonable() {
312        let dir = make_fixture();
313        let (output, original) = handle(&dir.path().to_string_lossy(), 3, false, true);
314        let compact_tokens = count_tokens(&output);
315
316        eprintln!("=== ctx_tree savings test ===");
317        eprintln!("  original (raw) tokens: {original}");
318        eprintln!("  compact tokens:        {compact_tokens}");
319        eprintln!(
320            "  savings:               {}",
321            original.saturating_sub(compact_tokens)
322        );
323
324        assert!(original > 0, "raw tree should have some tokens");
325        assert!(
326            original < 2000,
327            "raw tree for the fixture should be small, got {original}"
328        );
329        if original > compact_tokens {
330            let ratio = (original - compact_tokens) as f64 / original as f64;
331            eprintln!("  savings ratio:         {:.1}%", ratio * 100.0);
332            assert!(
333                ratio < 0.90,
334                "savings ratio should be < 90% for same-depth comparison, got {:.1}%",
335                ratio * 100.0
336            );
337        }
338    }
339
340    #[test]
341    fn tree_refuses_home_directory_root() {
342        // HOME is read here and again inside the check: another test may move
343        // it in between (macOS release prebuild, 3.12.1).
344        let _env = lean_ctx::core::data_dir::test_env_lock();
345        // #356 class: never walk the whole home dir (macOS TCC prompts).
346        let home = dirs::home_dir().expect("home dir in test env");
347        let (output, tokens) = handle(home.to_string_lossy().as_ref(), 2, false, true);
348        assert!(
349            output.starts_with("ERROR:") && output.contains("refusing to scan"),
350            "home root must be refused: {output}"
351        );
352        assert_eq!(tokens, 0);
353    }
354
355    #[test]
356    fn tree_hides_node_modules_by_default_even_without_git() {
357        // #400: vendor dirs are pruned by default; respect_gitignore=false is
358        // the explicit escape hatch to look inside them.
359        let tmp = tempfile::tempdir().expect("tempdir");
360        std::fs::create_dir_all(tmp.path().join("node_modules/react")).expect("mkdir");
361        std::fs::write(tmp.path().join("node_modules/react/index.js"), "x").expect("write");
362        std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
363        std::fs::write(tmp.path().join("src/app.js"), "y").expect("write");
364        let root = tmp.path().to_string_lossy().to_string();
365
366        let (default_out, _) = handle(&root, 4, false, true);
367        assert!(default_out.contains("src"), "src visible: {default_out}");
368        assert!(
369            !default_out.contains("node_modules"),
370            "node_modules must be hidden by default: {default_out}"
371        );
372
373        let (opt_out, _) = handle(&root, 4, false, false);
374        assert!(
375            opt_out.contains("node_modules"),
376            "respect_gitignore=false must reveal vendor dirs: {opt_out}"
377        );
378    }
379
380    #[test]
381    fn hive_partition_detection() {
382        let dir = tempfile::tempdir().expect("tempdir");
383        let root = dir.path();
384        for year in 2020..=2024 {
385            let partition = root.join(format!("year={year}"));
386            std::fs::create_dir_all(&partition).expect("mkdir");
387            std::fs::write(partition.join("data.parquet"), "fake").expect("write");
388        }
389
390        let (output, _) = handle(&root.display().to_string(), 3, false, false);
391        assert!(output.contains("hive:"), "expected Hive summary: {output}");
392        assert!(
393            output.contains("5 partitions"),
394            "expected partition count: {output}"
395        );
396        assert!(output.contains("5 files"), "expected file count: {output}");
397        assert!(
398            !output.contains("year=2020"),
399            "partition was not collapsed: {output}"
400        );
401    }
402
403    #[cfg(windows)]
404    #[test]
405    fn tree_walks_explicit_directory_reparse_root() {
406        use std::os::windows::fs::symlink_dir;
407
408        let tmp = tempfile::tempdir().expect("tempdir");
409        let target = tmp.path().join("target");
410        let link = tmp.path().join("junction");
411        std::fs::create_dir_all(&target).expect("target");
412        std::fs::write(target.join("visible.rs"), "fn visible() {}\n").expect("fixture");
413        if symlink_dir(&target, &link).is_err() {
414            // Windows hosts without Developer Mode cannot create this fixture.
415            return;
416        }
417        let (out, _) = handle(&link.to_string_lossy(), 2, false, true);
418        assert!(
419            out.contains("visible.rs"),
420            "junction root must be traversed: {out}"
421        );
422    }
423}