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