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
16pub 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 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 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
144fn 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 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 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 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 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}