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
35 let raw_output = generate_raw_tree(root, depth, show_hidden, respect_gitignore);
36 let compact_output = generate_compact_tree(root, depth, show_hidden, respect_gitignore);
37
38 if compact_output.trim().is_empty() {
39 return (format!("{path}/ (empty directory, depth={depth})"), 0);
40 }
41
42 let raw_tokens = count_tokens(&raw_output);
43 let compact_tokens = count_tokens(&compact_output);
44 let savings = protocol::format_savings(raw_tokens, compact_tokens);
45
46 (format!("{compact_output}\n{savings}"), raw_tokens)
47}
48
49fn generate_compact_tree(
50 root: &Path,
51 max_depth: usize,
52 show_hidden: bool,
53 respect_gitignore: bool,
54) -> String {
55 let mut lines = Vec::new();
56
57 struct Entry {
58 depth: usize,
59 name: String,
60 is_dir: bool,
61 path: std::path::PathBuf,
62 }
63 let mut entries: Vec<Entry> = Vec::new();
64
65 let walker = WalkBuilder::new(root)
66 .hidden(!show_hidden)
67 .git_ignore(respect_gitignore)
68 .git_global(respect_gitignore)
69 .git_exclude(respect_gitignore)
70 .max_depth(Some(max_depth))
71 .sort_by_file_name(std::cmp::Ord::cmp)
72 .build();
73
74 for entry in walker.filter_map(std::result::Result::ok) {
75 if entry.depth() == 0 {
76 continue;
77 }
78 entries.push(Entry {
79 depth: entry.depth(),
80 name: entry.file_name().to_string_lossy().to_string(),
81 is_dir: entry.file_type().is_some_and(|ft| ft.is_dir()),
82 path: entry.path().to_path_buf(),
83 });
84 }
85
86 let mut dir_file_counts: std::collections::HashMap<&std::path::Path, usize> =
87 std::collections::HashMap::new();
88 for e in &entries {
89 if !e.is_dir {
90 if let Some(parent) = e.path.parent() {
91 *dir_file_counts.entry(parent).or_default() += 1;
92 }
93 }
94 }
95
96 for e in &entries {
97 let indent = " ".repeat(e.depth.saturating_sub(1));
98 if e.is_dir {
99 let count = dir_file_counts.get(e.path.as_path()).copied().unwrap_or(0);
100 lines.push(format!("{indent}{}/ ({count})", e.name));
101 } else {
102 lines.push(format!("{indent}{}", e.name));
103 }
104 }
105
106 lines.join("\n")
107}
108
109fn generate_raw_tree(
110 root: &Path,
111 depth: usize,
112 show_hidden: bool,
113 respect_gitignore: bool,
114) -> String {
115 let mut lines = Vec::new();
116
117 let walker = WalkBuilder::new(root)
118 .hidden(!show_hidden)
119 .git_ignore(respect_gitignore)
120 .git_global(respect_gitignore)
121 .git_exclude(respect_gitignore)
122 .max_depth(Some(depth))
123 .sort_by_file_name(std::cmp::Ord::cmp)
124 .build();
125
126 for entry in walker.filter_map(std::result::Result::ok) {
127 if entry.depth() == 0 {
128 continue;
129 }
130 let rel = entry
131 .path()
132 .strip_prefix(root)
133 .unwrap_or(entry.path())
134 .to_string_lossy();
135 lines.push(rel.to_string());
136 }
137
138 lines.join("\n")
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn make_fixture() -> tempfile::TempDir {
150 let dir = tempfile::tempdir().unwrap();
151 let root = dir.path();
152 let files = [
153 "Cargo.toml",
154 "README.md",
155 "src/main.rs",
156 "src/lib.rs",
157 "src/core/mod.rs",
158 "src/core/engine.rs",
159 "src/core/util.rs",
160 "src/tools/mod.rs",
161 "src/tools/reader.rs",
162 "tests/integration.rs",
163 "tests/smoke.rs",
164 ];
165 for rel in files {
166 let p = root.join(rel);
167 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
168 std::fs::write(&p, "// fixture\n").unwrap();
169 }
170 dir
171 }
172
173 #[test]
174 fn tree_savings_are_reasonable() {
175 let dir = make_fixture();
176 let (output, original) = handle(&dir.path().to_string_lossy(), 3, false, true);
177 let compact_tokens = count_tokens(&output);
178
179 eprintln!("=== ctx_tree savings test ===");
180 eprintln!(" original (raw) tokens: {original}");
181 eprintln!(" compact tokens: {compact_tokens}");
182 eprintln!(
183 " savings: {}",
184 original.saturating_sub(compact_tokens)
185 );
186
187 assert!(original > 0, "raw tree should have some tokens");
188 assert!(
189 original < 2000,
190 "raw tree for the fixture should be small, got {original}"
191 );
192 if original > compact_tokens {
193 let ratio = (original - compact_tokens) as f64 / original as f64;
194 eprintln!(" savings ratio: {:.1}%", ratio * 100.0);
195 assert!(
196 ratio < 0.90,
197 "savings ratio should be < 90% for same-depth comparison, got {:.1}%",
198 ratio * 100.0
199 );
200 }
201 }
202}