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