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 requested_root = Path::new(path);
17    let walk_root = crate::core::walk_filter::explicit_walk_root(requested_root);
18    let root = walk_root.as_path();
19    if root.is_file() {
20        let parent = root
21            .parent()
22            .map_or(path.to_string(), |p| p.display().to_string());
23        return (
24            format!(
25                "ERROR: '{path}' is a file, not a directory. Use path=\"{parent}\" for the containing directory."
26            ),
27            0,
28        );
29    }
30    if !root.is_dir() {
31        return (
32            format!("ERROR: {path} does not exist or is not a directory"),
33            0,
34        );
35    }
36    // Broad-root guard (#356 class): with cwd == $HOME a defaulted `path`
37    // would walk the whole home dir and trip macOS TCC privacy prompts.
38    if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(path) {
39        return (err, 0);
40    }
41
42    let raw_output = generate_raw_tree(root, depth, show_hidden, respect_gitignore);
43    let compact_output = generate_compact_tree(root, depth, show_hidden, respect_gitignore);
44
45    if compact_output.trim().is_empty() {
46        return (format!("{path}/ (empty directory, depth={depth})"), 0);
47    }
48
49    let _mode_guard = crate::core::savings_footer::ModeGuard::new("tree");
50    let raw_tokens = count_tokens(&raw_output);
51    let compact_tokens = count_tokens(&compact_output);
52    let savings = protocol::format_savings(raw_tokens, compact_tokens);
53
54    (format!("{compact_output}\n{savings}"), raw_tokens)
55}
56
57fn generate_compact_tree(
58    root: &Path,
59    max_depth: usize,
60    show_hidden: bool,
61    respect_gitignore: bool,
62) -> String {
63    let mut lines = Vec::new();
64
65    struct Entry {
66        depth: usize,
67        name: String,
68        is_dir: bool,
69        path: std::path::PathBuf,
70    }
71    let mut entries: Vec<Entry> = Vec::new();
72
73    // Vendor dirs (node_modules, …) follow the gitignore toggle: explicitly
74    // disabling gitignore is the escape hatch to look inside them (#400).
75    let walker = WalkBuilder::new(root)
76        .hidden(!show_hidden)
77        .git_ignore(respect_gitignore)
78        .git_global(respect_gitignore)
79        .git_exclude(respect_gitignore)
80        .require_git(false)
81        .max_depth(Some(max_depth))
82        .sort_by_file_name(std::cmp::Ord::cmp)
83        .filter_entry(move |e| {
84            if respect_gitignore {
85                crate::core::walk_filter::keep_entry(e)
86            } else {
87                crate::core::cloud_files::keep_entry(e)
88            }
89        })
90        .build();
91
92    for entry in walker.filter_map(std::result::Result::ok) {
93        if entry.depth() == 0 {
94            continue;
95        }
96        entries.push(Entry {
97            depth: entry.depth(),
98            name: entry.file_name().to_string_lossy().to_string(),
99            is_dir: entry.file_type().is_some_and(|ft| ft.is_dir()),
100            path: entry.path().to_path_buf(),
101        });
102    }
103
104    let mut dir_file_counts: std::collections::HashMap<&std::path::Path, usize> =
105        std::collections::HashMap::new();
106    for e in &entries {
107        if !e.is_dir
108            && let Some(parent) = e.path.parent()
109        {
110            *dir_file_counts.entry(parent).or_default() += 1;
111        }
112    }
113
114    for e in &entries {
115        let indent = "  ".repeat(e.depth.saturating_sub(1));
116        if e.is_dir {
117            let count = dir_file_counts.get(e.path.as_path()).copied().unwrap_or(0);
118            lines.push(format!("{indent}{}/ ({count})", e.name));
119        } else {
120            lines.push(format!("{indent}{}", e.name));
121        }
122    }
123
124    lines.join("\n")
125}
126
127fn generate_raw_tree(
128    root: &Path,
129    depth: usize,
130    show_hidden: bool,
131    respect_gitignore: bool,
132) -> String {
133    let mut lines = Vec::new();
134
135    let walker = WalkBuilder::new(root)
136        .hidden(!show_hidden)
137        .git_ignore(respect_gitignore)
138        .git_global(respect_gitignore)
139        .git_exclude(respect_gitignore)
140        .max_depth(Some(depth))
141        .sort_by_file_name(std::cmp::Ord::cmp)
142        .build();
143
144    for entry in walker.filter_map(std::result::Result::ok) {
145        if entry.depth() == 0 {
146            continue;
147        }
148        let rel = entry
149            .path()
150            .strip_prefix(root)
151            .unwrap_or(entry.path())
152            .to_string_lossy();
153        lines.push(rel.to_string());
154    }
155
156    lines.join("\n")
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    /// Builds a deterministic source-tree fixture so the assertions do not
164    /// depend on the live repository size or platform path separators (the live
165    /// repo coupling previously made this test tip over its token threshold on
166    /// Windows as the codebase grew).
167    fn make_fixture() -> tempfile::TempDir {
168        let dir = tempfile::tempdir().unwrap();
169        let root = dir.path();
170        let files = [
171            "Cargo.toml",
172            "README.md",
173            "src/main.rs",
174            "src/lib.rs",
175            "src/core/mod.rs",
176            "src/core/engine.rs",
177            "src/core/util.rs",
178            "src/tools/mod.rs",
179            "src/tools/reader.rs",
180            "tests/integration.rs",
181            "tests/smoke.rs",
182        ];
183        for rel in files {
184            let p = root.join(rel);
185            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
186            std::fs::write(&p, "// fixture\n").unwrap();
187        }
188        dir
189    }
190
191    #[test]
192    fn tree_savings_are_reasonable() {
193        let dir = make_fixture();
194        let (output, original) = handle(&dir.path().to_string_lossy(), 3, false, true);
195        let compact_tokens = count_tokens(&output);
196
197        eprintln!("=== ctx_tree savings test ===");
198        eprintln!("  original (raw) tokens: {original}");
199        eprintln!("  compact tokens:        {compact_tokens}");
200        eprintln!(
201            "  savings:               {}",
202            original.saturating_sub(compact_tokens)
203        );
204
205        assert!(original > 0, "raw tree should have some tokens");
206        assert!(
207            original < 2000,
208            "raw tree for the fixture should be small, got {original}"
209        );
210        if original > compact_tokens {
211            let ratio = (original - compact_tokens) as f64 / original as f64;
212            eprintln!("  savings ratio:         {:.1}%", ratio * 100.0);
213            assert!(
214                ratio < 0.90,
215                "savings ratio should be < 90% for same-depth comparison, got {:.1}%",
216                ratio * 100.0
217            );
218        }
219    }
220
221    #[test]
222    fn tree_refuses_home_directory_root() {
223        // #356 class: never walk the whole home dir (macOS TCC prompts).
224        let home = dirs::home_dir().expect("home dir in test env");
225        let (output, tokens) = handle(home.to_string_lossy().as_ref(), 2, false, true);
226        assert!(
227            output.starts_with("ERROR:") && output.contains("refusing to scan"),
228            "home root must be refused: {output}"
229        );
230        assert_eq!(tokens, 0);
231    }
232
233    #[test]
234    fn tree_hides_node_modules_by_default_even_without_git() {
235        // #400: vendor dirs are pruned by default; respect_gitignore=false is
236        // the explicit escape hatch to look inside them.
237        let tmp = tempfile::tempdir().expect("tempdir");
238        std::fs::create_dir_all(tmp.path().join("node_modules/react")).expect("mkdir");
239        std::fs::write(tmp.path().join("node_modules/react/index.js"), "x").expect("write");
240        std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
241        std::fs::write(tmp.path().join("src/app.js"), "y").expect("write");
242        let root = tmp.path().to_string_lossy().to_string();
243
244        let (default_out, _) = handle(&root, 4, false, true);
245        assert!(default_out.contains("src"), "src visible: {default_out}");
246        assert!(
247            !default_out.contains("node_modules"),
248            "node_modules must be hidden by default: {default_out}"
249        );
250
251        let (opt_out, _) = handle(&root, 4, false, false);
252        assert!(
253            opt_out.contains("node_modules"),
254            "respect_gitignore=false must reveal vendor dirs: {opt_out}"
255        );
256    }
257
258    #[cfg(windows)]
259    #[test]
260    fn tree_walks_explicit_directory_reparse_root() {
261        use std::os::windows::fs::symlink_dir;
262
263        let tmp = tempfile::tempdir().expect("tempdir");
264        let target = tmp.path().join("target");
265        let link = tmp.path().join("junction");
266        std::fs::create_dir_all(&target).expect("target");
267        std::fs::write(target.join("visible.rs"), "fn visible() {}\n").expect("fixture");
268        if symlink_dir(&target, &link).is_err() {
269            // Windows hosts without Developer Mode cannot create this fixture.
270            return;
271        }
272        let (out, _) = handle(&link.to_string_lossy(), 2, false, true);
273        assert!(
274            out.contains("visible.rs"),
275            "junction root must be traversed: {out}"
276        );
277    }
278}