1use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4
5use ignore::WalkBuilder;
6
7use lean_ctx::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(
18 path: &str,
19 depth: usize,
20 show_hidden: bool,
21 respect_gitignore: bool,
22) -> (String, usize) {
23 let requested_root = Path::new(path);
24 let walk_root = lean_ctx::core::walk_filter::explicit_walk_root(requested_root);
25 let root = walk_root.as_path();
26
27 if root.is_file() {
28 let parent = root
29 .parent()
30 .map_or(path.to_string(), |parent| parent.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!(
41 "ERROR: {path} does not exist or is not a directory{}",
42 lean_ctx::core::path_suggest::hint(root)
43 ),
44 0,
45 );
46 }
47
48 if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(path) {
49 return (err, 0);
50 }
51
52 let (entries, raw_tokens) = match collect_entries(root, depth, show_hidden, respect_gitignore) {
53 Ok(entries) => entries,
54 Err(error) => return (format!("ERROR: {error}"), 0),
55 };
56 let body = render_compact_tree(root, &entries);
57 if body.trim().is_empty() {
58 return (format!("{path}/ (empty directory, depth={depth})"), 0);
59 }
60
61 (body, raw_tokens)
62}
63
64fn collect_entries(
65 root: &Path,
66 max_depth: usize,
67 show_hidden: bool,
68 respect_gitignore: bool,
69) -> Result<(Vec<Entry>, usize), &'static str> {
70 let mut entries = Vec::new();
71 let mut raw_tokens = 0;
72 let newline_tokens = count_tokens("\n");
73 let protected = lean_ctx::core::policy::runtime::active().is_some();
74 let allow_secret_paths = lean_ctx::core::roles::active_role().io.allow_secret_paths;
75
76 let walker = WalkBuilder::new(root)
77 .hidden(!show_hidden)
78 .git_ignore(respect_gitignore)
79 .git_global(respect_gitignore)
80 .git_exclude(respect_gitignore)
81 .require_git(false)
82 .max_depth(Some(max_depth))
83 .sort_by_file_name(std::cmp::Ord::cmp)
84 .filter_entry(move |entry| {
85 if respect_gitignore {
86 lean_ctx::core::walk_filter::keep_entry(entry)
87 } else {
88 lean_ctx::core::cloud_files::keep_entry(entry)
89 }
90 })
91 .build();
92
93 for (visited, entry) in walker.filter_map(std::result::Result::ok).enumerate() {
94 if protected && visited >= 20_000 {
95 return Err("protected directory walk limit exceeded");
96 }
97 if entry.depth() == 0 {
98 continue;
99 }
100
101 let depth = entry.depth();
102 let admitted = if protected {
103 if !allow_secret_paths
104 && lean_ctx::core::io_boundary::is_secret_like(entry.path()).is_some()
105 {
106 continue;
107 }
108 match crate::server::policy_guard::protect_result(
109 "ctx_tree",
110 &entry.path().to_string_lossy(),
111 ) {
112 Ok(path) => path,
113 Err(_) => continue,
114 }
115 } else {
116 entry.path().to_string_lossy().into_owned()
117 };
118 let name = Path::new(&admitted)
119 .file_name()
120 .unwrap_or_default()
121 .to_string_lossy()
122 .into_owned();
123 let is_dir = entry
124 .file_type()
125 .is_some_and(|file_type| file_type.is_dir());
126 let path = entry.into_path();
127 let relative_path = Path::new(&admitted)
128 .strip_prefix(root)
129 .unwrap_or(Path::new(&admitted))
130 .to_string_lossy();
131
132 if !entries.is_empty() {
133 raw_tokens += newline_tokens;
134 }
135 raw_tokens += count_tokens(&relative_path);
136
137 entries.push(Entry {
138 depth,
139 name,
140 is_dir,
141 path,
142 });
143 }
144
145 Ok((entries, raw_tokens))
146}
147
148fn render_compact_tree(root: &Path, entries: &[Entry]) -> String {
149 let mut lines = Vec::new();
150
151 let mut dir_file_counts: HashMap<&Path, usize> = HashMap::new();
152 for entry in entries {
153 if !entry.is_dir
154 && let Some(parent) = entry.path.parent()
155 {
156 *dir_file_counts.entry(parent).or_default() += 1;
157 }
158 }
159
160 let (hive_summaries, hive_skipped) = detect_hive_partitions(entries, &dir_file_counts);
161 if let Some(summary) = hive_summaries.get(root) {
162 let root_name = root.file_name().map_or_else(
163 || root.display().to_string(),
164 |name| name.to_string_lossy().to_string(),
165 );
166 lines.push(format!("{root_name}/ ({summary})"));
167 }
168
169 for entry in entries {
170 if hive_skipped.contains(&entry.path) {
171 continue;
172 }
173 let indent = " ".repeat(entry.depth.saturating_sub(1));
174 if entry.is_dir {
175 if let Some(summary) = hive_summaries.get(&entry.path) {
176 lines.push(format!("{indent}{}/ ({summary})", entry.name));
177 } else {
178 let count = dir_file_counts
179 .get(entry.path.as_path())
180 .copied()
181 .unwrap_or(0);
182 lines.push(format!("{indent}{}/ ({count})", entry.name));
183 }
184 } else {
185 lines.push(format!("{indent}{}", entry.name));
186 }
187 }
188
189 lines.join("\n")
190}
191
192fn detect_hive_partitions(
194 entries: &[Entry],
195 dir_file_counts: &HashMap<&Path, usize>,
196) -> (HashMap<PathBuf, String>, HashSet<PathBuf>) {
197 let mut children_by_parent: HashMap<&Path, Vec<&Entry>> = HashMap::new();
198 for entry in entries.iter().filter(|entry| entry.is_dir) {
199 if let Some(parent) = entry.path.parent() {
200 children_by_parent.entry(parent).or_default().push(entry);
201 }
202 }
203
204 let mut summaries = HashMap::new();
205 let mut skipped = HashSet::new();
206 for (parent, children) in children_by_parent {
207 let Some(key) = children
208 .first()
209 .and_then(|entry| hive_partition_key(&entry.name))
210 else {
211 continue;
212 };
213 if children.len() < 3
214 || children
215 .iter()
216 .any(|entry| hive_partition_key(&entry.name) != Some(key))
217 {
218 continue;
219 }
220
221 let file_count = children
222 .iter()
223 .map(|entry| {
224 dir_file_counts
225 .get(entry.path.as_path())
226 .copied()
227 .unwrap_or(0)
228 })
229 .sum::<usize>();
230 summaries.insert(
231 parent.to_path_buf(),
232 format!(
233 "hive: {key}=* — {} partitions, {file_count} files",
234 children.len()
235 ),
236 );
237 for entry in entries {
238 if children
239 .iter()
240 .any(|child| entry.path.starts_with(&child.path))
241 {
242 skipped.insert(entry.path.clone());
243 }
244 }
245 }
246
247 (summaries, skipped)
248}
249
250fn hive_partition_key(name: &str) -> Option<&str> {
251 let (key, value) = name.split_once('=')?;
252 if value.is_empty() {
253 return None;
254 }
255
256 let mut chars = key.chars();
257 let first = chars.next()?;
258 if !(first.is_ascii_alphabetic() || first == '_')
259 || !chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
260 {
261 return None;
262 }
263 Some(key)
264}
265
266#[cfg(test)]
267mod tests {
268 use super::{collect_entries, count_tokens, handle, render_compact_tree};
269
270 fn make_fixture() -> tempfile::TempDir {
275 let dir = tempfile::tempdir().unwrap();
276 let root = dir.path();
277 let files = [
278 "Cargo.toml",
279 "README.md",
280 "src/main.rs",
281 "src/lib.rs",
282 "src/core/mod.rs",
283 "src/core/engine.rs",
284 "src/core/util.rs",
285 "src/tools/mod.rs",
286 "src/tools/reader.rs",
287 "tests/integration.rs",
288 "tests/smoke.rs",
289 ];
290 for rel in files {
291 let p = root.join(rel);
292 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
293 std::fs::write(&p, "// fixture\n").unwrap();
294 }
295 dir
296 }
297
298 #[test]
299 fn tree_derives_body_and_raw_tokens_from_one_entry_set() {
300 let dir = make_fixture();
301 let root = dir.path();
302 let (entries, expected_raw_tokens) = collect_entries(root, 3, false, true).unwrap();
303
304 let (body, raw_tokens) = handle(&root.to_string_lossy(), 3, false, true);
305
306 assert_eq!(body, render_compact_tree(root, &entries));
307 assert_eq!(raw_tokens, expected_raw_tokens);
308 }
309
310 #[test]
311 fn tree_savings_are_reasonable() {
312 let dir = make_fixture();
313 let (output, original) = handle(&dir.path().to_string_lossy(), 3, false, true);
314 let compact_tokens = count_tokens(&output);
315
316 eprintln!("=== ctx_tree savings test ===");
317 eprintln!(" original (raw) tokens: {original}");
318 eprintln!(" compact tokens: {compact_tokens}");
319 eprintln!(
320 " savings: {}",
321 original.saturating_sub(compact_tokens)
322 );
323
324 assert!(original > 0, "raw tree should have some tokens");
325 assert!(
326 original < 2000,
327 "raw tree for the fixture should be small, got {original}"
328 );
329 if original > compact_tokens {
330 let ratio = (original - compact_tokens) as f64 / original as f64;
331 eprintln!(" savings ratio: {:.1}%", ratio * 100.0);
332 assert!(
333 ratio < 0.90,
334 "savings ratio should be < 90% for same-depth comparison, got {:.1}%",
335 ratio * 100.0
336 );
337 }
338 }
339
340 #[test]
341 fn tree_refuses_home_directory_root() {
342 let _env = lean_ctx::core::data_dir::test_env_lock();
345 let home = dirs::home_dir().expect("home dir in test env");
347 let (output, tokens) = handle(home.to_string_lossy().as_ref(), 2, false, true);
348 assert!(
349 output.starts_with("ERROR:") && output.contains("refusing to scan"),
350 "home root must be refused: {output}"
351 );
352 assert_eq!(tokens, 0);
353 }
354
355 #[test]
356 fn tree_hides_node_modules_by_default_even_without_git() {
357 let tmp = tempfile::tempdir().expect("tempdir");
360 std::fs::create_dir_all(tmp.path().join("node_modules/react")).expect("mkdir");
361 std::fs::write(tmp.path().join("node_modules/react/index.js"), "x").expect("write");
362 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
363 std::fs::write(tmp.path().join("src/app.js"), "y").expect("write");
364 let root = tmp.path().to_string_lossy().to_string();
365
366 let (default_out, _) = handle(&root, 4, false, true);
367 assert!(default_out.contains("src"), "src visible: {default_out}");
368 assert!(
369 !default_out.contains("node_modules"),
370 "node_modules must be hidden by default: {default_out}"
371 );
372
373 let (opt_out, _) = handle(&root, 4, false, false);
374 assert!(
375 opt_out.contains("node_modules"),
376 "respect_gitignore=false must reveal vendor dirs: {opt_out}"
377 );
378 }
379
380 #[test]
381 fn hive_partition_detection() {
382 let dir = tempfile::tempdir().expect("tempdir");
383 let root = dir.path();
384 for year in 2020..=2024 {
385 let partition = root.join(format!("year={year}"));
386 std::fs::create_dir_all(&partition).expect("mkdir");
387 std::fs::write(partition.join("data.parquet"), "fake").expect("write");
388 }
389
390 let (output, _) = handle(&root.display().to_string(), 3, false, false);
391 assert!(output.contains("hive:"), "expected Hive summary: {output}");
392 assert!(
393 output.contains("5 partitions"),
394 "expected partition count: {output}"
395 );
396 assert!(output.contains("5 files"), "expected file count: {output}");
397 assert!(
398 !output.contains("year=2020"),
399 "partition was not collapsed: {output}"
400 );
401 }
402
403 #[cfg(windows)]
404 #[test]
405 fn tree_walks_explicit_directory_reparse_root() {
406 use std::os::windows::fs::symlink_dir;
407
408 let tmp = tempfile::tempdir().expect("tempdir");
409 let target = tmp.path().join("target");
410 let link = tmp.path().join("junction");
411 std::fs::create_dir_all(&target).expect("target");
412 std::fs::write(target.join("visible.rs"), "fn visible() {}\n").expect("fixture");
413 if symlink_dir(&target, &link).is_err() {
414 return;
416 }
417 let (out, _) = handle(&link.to_string_lossy(), 2, false, true);
418 assert!(
419 out.contains("visible.rs"),
420 "junction root must be traversed: {out}"
421 );
422 }
423}