lean_ctx/tools/
ctx_tree.rs1use std::path::Path;
2
3use ignore::WalkBuilder;
4
5use crate::core::protocol;
6use crate::core::tokens::count_tokens;
7
8pub 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 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 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 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 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 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}