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