Skip to main content

lean_ctx/tools/
ctx_tree.rs

1use std::path::Path;
2
3use ignore::WalkBuilder;
4
5use crate::core::protocol;
6use crate::core::tokens::count_tokens;
7
8/// Generates a compact directory tree listing with file counts.
9/// When `respect_gitignore` is true, entries matching .gitignore patterns are excluded.
10pub fn handle(
11    path: &str,
12    depth: usize,
13    show_hidden: bool,
14    respect_gitignore: bool,
15) -> (String, usize) {
16    let root = Path::new(path);
17    if root.is_file() {
18        let parent = root
19            .parent()
20            .map_or(path.to_string(), |p| p.display().to_string());
21        return (
22            format!(
23                "ERROR: '{path}' is a file, not a directory. Use path=\"{parent}\" for the containing directory."
24            ),
25            0,
26        );
27    }
28    if !root.is_dir() {
29        return (
30            format!("ERROR: {path} does not exist or is not a directory"),
31            0,
32        );
33    }
34
35    let raw_output = generate_raw_tree(root, depth, show_hidden, respect_gitignore);
36    let compact_output = generate_compact_tree(root, depth, show_hidden, respect_gitignore);
37
38    if compact_output.trim().is_empty() {
39        return (format!("{path}/ (empty directory, depth={depth})"), 0);
40    }
41
42    let raw_tokens = count_tokens(&raw_output);
43    let compact_tokens = count_tokens(&compact_output);
44    let savings = protocol::format_savings(raw_tokens, compact_tokens);
45
46    (format!("{compact_output}\n{savings}"), raw_tokens)
47}
48
49fn generate_compact_tree(
50    root: &Path,
51    max_depth: usize,
52    show_hidden: bool,
53    respect_gitignore: bool,
54) -> String {
55    let mut lines = Vec::new();
56
57    struct Entry {
58        depth: usize,
59        name: String,
60        is_dir: bool,
61        path: std::path::PathBuf,
62    }
63    let mut entries: Vec<Entry> = Vec::new();
64
65    let walker = WalkBuilder::new(root)
66        .hidden(!show_hidden)
67        .git_ignore(respect_gitignore)
68        .git_global(respect_gitignore)
69        .git_exclude(respect_gitignore)
70        .max_depth(Some(max_depth))
71        .sort_by_file_name(std::cmp::Ord::cmp)
72        .build();
73
74    for entry in walker.filter_map(std::result::Result::ok) {
75        if entry.depth() == 0 {
76            continue;
77        }
78        entries.push(Entry {
79            depth: entry.depth(),
80            name: entry.file_name().to_string_lossy().to_string(),
81            is_dir: entry.file_type().is_some_and(|ft| ft.is_dir()),
82            path: entry.path().to_path_buf(),
83        });
84    }
85
86    let mut dir_file_counts: std::collections::HashMap<&std::path::Path, usize> =
87        std::collections::HashMap::new();
88    for e in &entries {
89        if !e.is_dir {
90            if let Some(parent) = e.path.parent() {
91                *dir_file_counts.entry(parent).or_default() += 1;
92            }
93        }
94    }
95
96    for e in &entries {
97        let indent = "  ".repeat(e.depth.saturating_sub(1));
98        if e.is_dir {
99            let count = dir_file_counts.get(e.path.as_path()).copied().unwrap_or(0);
100            lines.push(format!("{indent}{}/ ({count})", e.name));
101        } else {
102            lines.push(format!("{indent}{}", e.name));
103        }
104    }
105
106    lines.join("\n")
107}
108
109fn generate_raw_tree(
110    root: &Path,
111    depth: usize,
112    show_hidden: bool,
113    respect_gitignore: bool,
114) -> String {
115    let mut lines = Vec::new();
116
117    let walker = WalkBuilder::new(root)
118        .hidden(!show_hidden)
119        .git_ignore(respect_gitignore)
120        .git_global(respect_gitignore)
121        .git_exclude(respect_gitignore)
122        .max_depth(Some(depth))
123        .sort_by_file_name(std::cmp::Ord::cmp)
124        .build();
125
126    for entry in walker.filter_map(std::result::Result::ok) {
127        if entry.depth() == 0 {
128            continue;
129        }
130        let rel = entry
131            .path()
132            .strip_prefix(root)
133            .unwrap_or(entry.path())
134            .to_string_lossy();
135        lines.push(rel.to_string());
136    }
137
138    lines.join("\n")
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    /// Builds a deterministic source-tree fixture so the assertions do not
146    /// depend on the live repository size or platform path separators (the live
147    /// repo coupling previously made this test tip over its token threshold on
148    /// Windows as the codebase grew).
149    fn make_fixture() -> tempfile::TempDir {
150        let dir = tempfile::tempdir().unwrap();
151        let root = dir.path();
152        let files = [
153            "Cargo.toml",
154            "README.md",
155            "src/main.rs",
156            "src/lib.rs",
157            "src/core/mod.rs",
158            "src/core/engine.rs",
159            "src/core/util.rs",
160            "src/tools/mod.rs",
161            "src/tools/reader.rs",
162            "tests/integration.rs",
163            "tests/smoke.rs",
164        ];
165        for rel in files {
166            let p = root.join(rel);
167            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
168            std::fs::write(&p, "// fixture\n").unwrap();
169        }
170        dir
171    }
172
173    #[test]
174    fn tree_savings_are_reasonable() {
175        let dir = make_fixture();
176        let (output, original) = handle(&dir.path().to_string_lossy(), 3, false, true);
177        let compact_tokens = count_tokens(&output);
178
179        eprintln!("=== ctx_tree savings test ===");
180        eprintln!("  original (raw) tokens: {original}");
181        eprintln!("  compact tokens:        {compact_tokens}");
182        eprintln!(
183            "  savings:               {}",
184            original.saturating_sub(compact_tokens)
185        );
186
187        assert!(original > 0, "raw tree should have some tokens");
188        assert!(
189            original < 2000,
190            "raw tree for the fixture should be small, got {original}"
191        );
192        if original > compact_tokens {
193            let ratio = (original - compact_tokens) as f64 / original as f64;
194            eprintln!("  savings ratio:         {:.1}%", ratio * 100.0);
195            assert!(
196                ratio < 0.90,
197                "savings ratio should be < 90% for same-depth comparison, got {:.1}%",
198                ratio * 100.0
199            );
200        }
201    }
202}