Skip to main content

lean_ctx/tools/
ctx_tree.rs

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