1use 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 requested_root = Path::new(path);
17 let walk_root = crate::core::walk_filter::explicit_walk_root(requested_root);
18 let root = walk_root.as_path();
19 if root.is_file() {
20 let parent = root
21 .parent()
22 .map_or(path.to_string(), |p| p.display().to_string());
23 return (
24 format!(
25 "ERROR: '{path}' is a file, not a directory. Use path=\"{parent}\" for the containing directory."
26 ),
27 0,
28 );
29 }
30 if !root.is_dir() {
31 return (
32 format!("ERROR: {path} does not exist or is not a directory"),
33 0,
34 );
35 }
36 if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(path) {
39 return (err, 0);
40 }
41
42 let raw_output = generate_raw_tree(root, depth, show_hidden, respect_gitignore);
43 let compact_output = generate_compact_tree(root, depth, show_hidden, respect_gitignore);
44
45 if compact_output.trim().is_empty() {
46 return (format!("{path}/ (empty directory, depth={depth})"), 0);
47 }
48
49 let _mode_guard = crate::core::savings_footer::ModeGuard::new("tree");
50 let raw_tokens = count_tokens(&raw_output);
51 let compact_tokens = count_tokens(&compact_output);
52 let savings = protocol::format_savings(raw_tokens, compact_tokens);
53
54 (format!("{compact_output}\n{savings}"), raw_tokens)
55}
56
57fn generate_compact_tree(
58 root: &Path,
59 max_depth: usize,
60 show_hidden: bool,
61 respect_gitignore: bool,
62) -> String {
63 let mut lines = Vec::new();
64
65 struct Entry {
66 depth: usize,
67 name: String,
68 is_dir: bool,
69 path: std::path::PathBuf,
70 }
71 let mut entries: Vec<Entry> = Vec::new();
72
73 let walker = WalkBuilder::new(root)
76 .hidden(!show_hidden)
77 .git_ignore(respect_gitignore)
78 .git_global(respect_gitignore)
79 .git_exclude(respect_gitignore)
80 .require_git(false)
81 .max_depth(Some(max_depth))
82 .sort_by_file_name(std::cmp::Ord::cmp)
83 .filter_entry(move |e| {
84 if respect_gitignore {
85 crate::core::walk_filter::keep_entry(e)
86 } else {
87 crate::core::cloud_files::keep_entry(e)
88 }
89 })
90 .build();
91
92 for entry in walker.filter_map(std::result::Result::ok) {
93 if entry.depth() == 0 {
94 continue;
95 }
96 entries.push(Entry {
97 depth: entry.depth(),
98 name: entry.file_name().to_string_lossy().to_string(),
99 is_dir: entry.file_type().is_some_and(|ft| ft.is_dir()),
100 path: entry.path().to_path_buf(),
101 });
102 }
103
104 let mut dir_file_counts: std::collections::HashMap<&std::path::Path, usize> =
105 std::collections::HashMap::new();
106 for e in &entries {
107 if !e.is_dir
108 && let Some(parent) = e.path.parent()
109 {
110 *dir_file_counts.entry(parent).or_default() += 1;
111 }
112 }
113
114 for e in &entries {
115 let indent = " ".repeat(e.depth.saturating_sub(1));
116 if e.is_dir {
117 let count = dir_file_counts.get(e.path.as_path()).copied().unwrap_or(0);
118 lines.push(format!("{indent}{}/ ({count})", e.name));
119 } else {
120 lines.push(format!("{indent}{}", e.name));
121 }
122 }
123
124 lines.join("\n")
125}
126
127fn generate_raw_tree(
128 root: &Path,
129 depth: usize,
130 show_hidden: bool,
131 respect_gitignore: bool,
132) -> String {
133 let mut lines = Vec::new();
134
135 let walker = WalkBuilder::new(root)
136 .hidden(!show_hidden)
137 .git_ignore(respect_gitignore)
138 .git_global(respect_gitignore)
139 .git_exclude(respect_gitignore)
140 .max_depth(Some(depth))
141 .sort_by_file_name(std::cmp::Ord::cmp)
142 .build();
143
144 for entry in walker.filter_map(std::result::Result::ok) {
145 if entry.depth() == 0 {
146 continue;
147 }
148 let rel = entry
149 .path()
150 .strip_prefix(root)
151 .unwrap_or(entry.path())
152 .to_string_lossy();
153 lines.push(rel.to_string());
154 }
155
156 lines.join("\n")
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 fn make_fixture() -> tempfile::TempDir {
168 let dir = tempfile::tempdir().unwrap();
169 let root = dir.path();
170 let files = [
171 "Cargo.toml",
172 "README.md",
173 "src/main.rs",
174 "src/lib.rs",
175 "src/core/mod.rs",
176 "src/core/engine.rs",
177 "src/core/util.rs",
178 "src/tools/mod.rs",
179 "src/tools/reader.rs",
180 "tests/integration.rs",
181 "tests/smoke.rs",
182 ];
183 for rel in files {
184 let p = root.join(rel);
185 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
186 std::fs::write(&p, "// fixture\n").unwrap();
187 }
188 dir
189 }
190
191 #[test]
192 fn tree_savings_are_reasonable() {
193 let dir = make_fixture();
194 let (output, original) = handle(&dir.path().to_string_lossy(), 3, false, true);
195 let compact_tokens = count_tokens(&output);
196
197 eprintln!("=== ctx_tree savings test ===");
198 eprintln!(" original (raw) tokens: {original}");
199 eprintln!(" compact tokens: {compact_tokens}");
200 eprintln!(
201 " savings: {}",
202 original.saturating_sub(compact_tokens)
203 );
204
205 assert!(original > 0, "raw tree should have some tokens");
206 assert!(
207 original < 2000,
208 "raw tree for the fixture should be small, got {original}"
209 );
210 if original > compact_tokens {
211 let ratio = (original - compact_tokens) as f64 / original as f64;
212 eprintln!(" savings ratio: {:.1}%", ratio * 100.0);
213 assert!(
214 ratio < 0.90,
215 "savings ratio should be < 90% for same-depth comparison, got {:.1}%",
216 ratio * 100.0
217 );
218 }
219 }
220
221 #[test]
222 fn tree_refuses_home_directory_root() {
223 let home = dirs::home_dir().expect("home dir in test env");
225 let (output, tokens) = handle(home.to_string_lossy().as_ref(), 2, false, true);
226 assert!(
227 output.starts_with("ERROR:") && output.contains("refusing to scan"),
228 "home root must be refused: {output}"
229 );
230 assert_eq!(tokens, 0);
231 }
232
233 #[test]
234 fn tree_hides_node_modules_by_default_even_without_git() {
235 let tmp = tempfile::tempdir().expect("tempdir");
238 std::fs::create_dir_all(tmp.path().join("node_modules/react")).expect("mkdir");
239 std::fs::write(tmp.path().join("node_modules/react/index.js"), "x").expect("write");
240 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
241 std::fs::write(tmp.path().join("src/app.js"), "y").expect("write");
242 let root = tmp.path().to_string_lossy().to_string();
243
244 let (default_out, _) = handle(&root, 4, false, true);
245 assert!(default_out.contains("src"), "src visible: {default_out}");
246 assert!(
247 !default_out.contains("node_modules"),
248 "node_modules must be hidden by default: {default_out}"
249 );
250
251 let (opt_out, _) = handle(&root, 4, false, false);
252 assert!(
253 opt_out.contains("node_modules"),
254 "respect_gitignore=false must reveal vendor dirs: {opt_out}"
255 );
256 }
257
258 #[cfg(windows)]
259 #[test]
260 fn tree_walks_explicit_directory_reparse_root() {
261 use std::os::windows::fs::symlink_dir;
262
263 let tmp = tempfile::tempdir().expect("tempdir");
264 let target = tmp.path().join("target");
265 let link = tmp.path().join("junction");
266 std::fs::create_dir_all(&target).expect("target");
267 std::fs::write(target.join("visible.rs"), "fn visible() {}\n").expect("fixture");
268 if symlink_dir(&target, &link).is_err() {
269 return;
271 }
272 let (out, _) = handle(&link.to_string_lossy(), 2, false, true);
273 assert!(
274 out.contains("visible.rs"),
275 "junction root must be traversed: {out}"
276 );
277 }
278}