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(path: &str, depth: usize, show_hidden: bool) -> (String, usize) {
10 let root = Path::new(path);
11 if root.is_file() {
12 let parent = root
13 .parent()
14 .map_or(path.to_string(), |p| p.display().to_string());
15 return (
16 format!(
17 "ERROR: '{path}' is a file, not a directory. Use path=\"{parent}\" for the containing directory."
18 ),
19 0,
20 );
21 }
22 if !root.is_dir() {
23 return (
24 format!("ERROR: {path} does not exist or is not a directory"),
25 0,
26 );
27 }
28
29 let raw_output = generate_raw_tree(root, depth, show_hidden);
30 let compact_output = generate_compact_tree(root, depth, show_hidden);
31
32 if compact_output.trim().is_empty() {
33 return (format!("{path}/ (empty directory, depth={depth})"), 0);
34 }
35
36 let raw_tokens = count_tokens(&raw_output);
37 let compact_tokens = count_tokens(&compact_output);
38 let savings = protocol::format_savings(raw_tokens, compact_tokens);
39
40 (format!("{compact_output}\n{savings}"), raw_tokens)
41}
42
43fn generate_compact_tree(root: &Path, max_depth: usize, show_hidden: bool) -> String {
44 let mut lines = Vec::new();
45 let mut entries: Vec<(usize, String, bool, usize)> = Vec::new();
46
47 let walker = WalkBuilder::new(root)
48 .hidden(!show_hidden)
49 .git_ignore(true)
50 .git_global(true)
51 .git_exclude(true)
52 .max_depth(Some(max_depth))
53 .sort_by_file_name(std::cmp::Ord::cmp)
54 .build();
55
56 for entry in walker.filter_map(std::result::Result::ok) {
57 if entry.depth() == 0 {
58 continue;
59 }
60
61 let name = entry.file_name().to_string_lossy().to_string();
62
63 let depth = entry.depth();
64 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
65
66 let file_count = if is_dir {
67 count_files_in_dir(entry.path())
68 } else {
69 0
70 };
71
72 entries.push((depth, name, is_dir, file_count));
73 }
74
75 for (depth, name, is_dir, file_count) in &entries {
76 let indent = " ".repeat(depth.saturating_sub(1));
77 if *is_dir {
78 lines.push(format!("{indent}{name}/ ({file_count})"));
79 } else {
80 lines.push(format!("{indent}{name}"));
81 }
82 }
83
84 lines.join("\n")
85}
86
87fn generate_raw_tree(root: &Path, depth: usize, show_hidden: bool) -> String {
88 let mut lines = Vec::new();
89
90 let walker = WalkBuilder::new(root)
91 .hidden(!show_hidden)
92 .git_ignore(true)
93 .git_global(true)
94 .git_exclude(true)
95 .max_depth(Some(depth))
96 .sort_by_file_name(std::cmp::Ord::cmp)
97 .build();
98
99 for entry in walker.filter_map(std::result::Result::ok) {
100 if entry.depth() == 0 {
101 continue;
102 }
103 let rel = entry
104 .path()
105 .strip_prefix(root)
106 .unwrap_or(entry.path())
107 .to_string_lossy();
108 lines.push(rel.to_string());
109 }
110
111 lines.join("\n")
112}
113
114fn count_files_in_dir(dir: &Path) -> usize {
115 WalkBuilder::new(dir)
116 .hidden(false)
117 .git_ignore(true)
118 .max_depth(Some(5))
119 .build()
120 .filter_map(std::result::Result::ok)
121 .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
122 .count()
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn tree_savings_are_reasonable() {
131 let dir = env!("CARGO_MANIFEST_DIR");
132 let (output, original) = handle(dir, 3, false);
133 let compact_tokens = count_tokens(&output);
134
135 eprintln!("=== ctx_tree savings test ===");
136 eprintln!(" original (raw) tokens: {original}");
137 eprintln!(" compact tokens: {compact_tokens}");
138 eprintln!(
139 " savings: {}",
140 original.saturating_sub(compact_tokens)
141 );
142
143 assert!(
144 original < 5000,
145 "raw tree at depth 3 should be < 5000 tokens, got {original}"
146 );
147 assert!(original > 0, "raw tree should have some tokens");
148 if original > compact_tokens {
149 let ratio = (original - compact_tokens) as f64 / original as f64;
150 eprintln!(" savings ratio: {:.1}%", ratio * 100.0);
151 assert!(
152 ratio < 0.90,
153 "savings ratio should be < 90% for same-depth comparison, got {:.1}%",
154 ratio * 100.0
155 );
156 }
157 }
158}