reflex/context/
structure.rs1use anyhow::Result;
4use serde_json::{Value, json};
5use std::fs;
6use std::path::Path;
7
8const EXCLUDED_DIRS: &[&str] = &[
10 "target",
11 "node_modules",
12 "dist",
13 "build",
14 ".git",
15 ".reflex",
16 "__pycache__",
17 ".pytest_cache",
18 ".mypy_cache",
19 "vendor",
20 ".next",
21 ".nuxt",
22 "coverage",
23];
24
25pub fn generate_tree(root: &Path, max_depth: usize) -> Result<String> {
27 let mut output = Vec::new();
28
29 let root_name = root.file_name().and_then(|n| n.to_str()).unwrap_or(".");
31 output.push(format!("{}/", root_name));
32
33 generate_tree_recursive(root, "", max_depth, 0, &mut output)?;
34
35 Ok(output.join("\n"))
36}
37
38fn generate_tree_recursive(
40 dir: &Path,
41 prefix: &str,
42 max_depth: usize,
43 current_depth: usize,
44 output: &mut Vec<String>,
45) -> Result<()> {
46 if current_depth >= max_depth {
47 return Ok(());
48 }
49
50 let mut entries: Vec<_> = fs::read_dir(dir)?
52 .filter_map(|e| e.ok())
53 .filter(|e| !should_exclude(e.path().as_path()))
54 .collect();
55
56 entries.sort_by(|a, b| {
58 let a_is_dir = a.path().is_dir();
59 let b_is_dir = b.path().is_dir();
60
61 match (a_is_dir, b_is_dir) {
62 (true, false) => std::cmp::Ordering::Less,
63 (false, true) => std::cmp::Ordering::Greater,
64 _ => a.file_name().cmp(&b.file_name()),
65 }
66 });
67
68 let entry_count = entries.len();
69
70 for (idx, entry) in entries.iter().enumerate() {
71 let is_last = idx == entry_count - 1;
72 let path = entry.path();
73 let name = entry.file_name();
74 let name_str = name.to_string_lossy();
75
76 let connector = if is_last { "└──" } else { "├──" };
78 let extension = if is_last { " " } else { "│ " };
79
80 let is_real_dir = fs::symlink_metadata(&path)
82 .map(|m| m.is_dir())
83 .unwrap_or(false);
84
85 if is_real_dir {
86 let dir_info = get_dir_info(&path);
88 output.push(format!(
89 "{}{} {}/ {}",
90 prefix, connector, name_str, dir_info
91 ));
92
93 if current_depth + 1 < max_depth {
95 let new_prefix = format!("{}{}", prefix, extension);
96 generate_tree_recursive(&path, &new_prefix, max_depth, current_depth + 1, output)?;
97 }
98 } else {
99 let file_info = get_file_info(&path);
101 output.push(format!(
102 "{}{} {} {}",
103 prefix, connector, name_str, file_info
104 ));
105 }
106 }
107
108 Ok(())
109}
110
111fn get_dir_info(dir: &Path) -> String {
113 if let Ok(entries) = fs::read_dir(dir) {
115 let count = entries
116 .filter_map(|e| e.ok())
117 .filter(|e| !should_exclude(&e.path()))
118 .count();
119
120 if count == 0 {
121 return "(empty)".to_string();
122 } else if count == 1 {
123 return "(1 file)".to_string();
124 } else {
125 return format!("({} files)", count);
126 }
127 }
128
129 String::new()
130}
131
132fn get_file_info(file: &Path) -> String {
134 let Ok(meta) = fs::symlink_metadata(file) else {
136 return String::new();
137 };
138
139 if meta.file_type().is_symlink() {
140 if let Ok(target) = fs::read_link(file) {
142 return format!("→ {}", target.display());
143 }
144 return "(symlink)".to_string();
145 }
146
147 let size = meta.len();
148
149 if let Ok(content) = fs::read_to_string(file) {
151 let lines = content.lines().count();
152 if lines > 0 {
153 return format!("({} lines)", lines);
154 }
155 }
156
157 if size < 1024 {
159 format!("({} bytes)", size)
160 } else if size < 1024 * 1024 {
161 format!("({} KB)", size / 1024)
162 } else {
163 format!("({} MB)", size / (1024 * 1024))
164 }
165}
166
167fn should_exclude(path: &Path) -> bool {
169 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
170 if EXCLUDED_DIRS.contains(&name) {
172 return true;
173 }
174
175 if name.starts_with('.') && name.len() > 1 {
177 let keep_files = ["gitignore", "gitattributes", "dockerignore", "editorconfig"];
178 if !keep_files.iter().any(|f| name == format!(".{}", f)) {
179 return true;
180 }
181 }
182 }
183
184 false
185}
186
187pub fn generate_tree_json(root: &Path, max_depth: usize) -> Result<Value> {
189 let root_name = root.file_name().and_then(|n| n.to_str()).unwrap_or(".");
190
191 Ok(json!({
192 "root": root_name,
193 "tree": generate_tree_json_recursive(root, max_depth, 0)?
194 }))
195}
196
197fn generate_tree_json_recursive(
199 dir: &Path,
200 max_depth: usize,
201 current_depth: usize,
202) -> Result<Value> {
203 if current_depth >= max_depth {
204 return Ok(json!({}));
205 }
206
207 let mut entries: Vec<_> = fs::read_dir(dir)?
208 .filter_map(|e| e.ok())
209 .filter(|e| !should_exclude(&e.path()))
210 .collect();
211
212 entries.sort_by_key(|a| a.file_name());
213
214 let mut tree = serde_json::Map::new();
215 let mut files = Vec::new();
216 let mut subdirs = Vec::new();
217
218 for entry in entries {
219 let path = entry.path();
220 let name = entry.file_name().to_string_lossy().to_string();
221
222 let is_real_dir = fs::symlink_metadata(&path)
223 .map(|m| m.is_dir())
224 .unwrap_or(false);
225
226 if is_real_dir {
227 if current_depth + 1 < max_depth {
228 let subtree = generate_tree_json_recursive(&path, max_depth, current_depth + 1)?;
229 tree.insert(name.clone(), subtree);
230 }
231 subdirs.push(name);
232 } else {
233 let is_symlink = fs::symlink_metadata(&path)
234 .map(|m| m.file_type().is_symlink())
235 .unwrap_or(false);
236 files.push(json!({
237 "name": name,
238 "size": if is_symlink { None } else { fs::metadata(&path).ok().map(|m| m.len()) },
239 "lines": if is_symlink { None } else { count_lines(&path).ok() },
240 "symlink_target": if is_symlink { fs::read_link(&path).ok().map(|t| t.display().to_string()) } else { None },
241 }));
242 }
243 }
244
245 Ok(json!({
246 "type": "directory",
247 "files": files,
248 "subdirectories": subdirs,
249 "children": tree,
250 }))
251}
252
253fn count_lines(path: &Path) -> Result<usize> {
255 let content = fs::read_to_string(path)?;
256 Ok(content.lines().count())
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use std::fs::File;
263 use std::io::Write;
264 use tempfile::TempDir;
265
266 #[test]
267 fn test_generate_tree_empty_dir() {
268 let temp = TempDir::new().unwrap();
269 let result = generate_tree(temp.path(), 3).unwrap();
270
271 assert!(result.contains(temp.path().file_name().unwrap().to_str().unwrap()));
273 }
274
275 #[test]
276 fn test_generate_tree_with_files() {
277 let temp = TempDir::new().unwrap();
278
279 File::create(temp.path().join("file1.txt"))
281 .unwrap()
282 .write_all(b"line1\nline2\nline3")
283 .unwrap();
284 File::create(temp.path().join("file2.rs"))
285 .unwrap()
286 .write_all(b"fn main() {}")
287 .unwrap();
288
289 let result = generate_tree(temp.path(), 3).unwrap();
290
291 assert!(result.contains("file1.txt"));
292 assert!(result.contains("file2.rs"));
293 assert!(result.contains("lines"));
294 }
295
296 #[test]
297 fn test_generate_tree_with_nested_dirs() {
298 let temp = TempDir::new().unwrap();
299
300 fs::create_dir(temp.path().join("src")).unwrap();
302 fs::create_dir(temp.path().join("src/api")).unwrap();
303 File::create(temp.path().join("src/main.rs")).unwrap();
304 File::create(temp.path().join("src/api/routes.rs")).unwrap();
305
306 let result = generate_tree(temp.path(), 3).unwrap();
307
308 assert!(result.contains("src/"));
309 assert!(result.contains("main.rs"));
310 assert!(result.contains("api/"));
311 assert!(result.contains("routes.rs"));
312 }
313
314 #[test]
315 fn test_exclude_build_dirs() {
316 let temp = TempDir::new().unwrap();
317
318 fs::create_dir(temp.path().join("target")).unwrap();
320 fs::create_dir(temp.path().join("node_modules")).unwrap();
321 File::create(temp.path().join("target/debug.txt")).unwrap();
322 File::create(temp.path().join("file.txt")).unwrap();
323
324 let result = generate_tree(temp.path(), 3).unwrap();
325
326 assert!(!result.contains("target"));
327 assert!(!result.contains("node_modules"));
328 assert!(!result.contains("debug.txt"));
329 assert!(result.contains("file.txt"));
330 }
331
332 #[test]
333 fn test_depth_limiting() {
334 let temp = TempDir::new().unwrap();
335
336 fs::create_dir_all(temp.path().join("a/b/c/d")).unwrap();
338 File::create(temp.path().join("a/b/c/d/deep.txt")).unwrap();
339
340 let result = generate_tree(temp.path(), 2).unwrap();
342 assert!(result.contains("a/"));
343 assert!(result.contains("b/"));
344 assert!(!result.contains("c/"));
345 assert!(!result.contains("deep.txt"));
346 }
347
348 #[test]
349 fn test_generate_tree_json() {
350 let temp = TempDir::new().unwrap();
351
352 File::create(temp.path().join("test.txt"))
353 .unwrap()
354 .write_all(b"hello\nworld")
355 .unwrap();
356 fs::create_dir(temp.path().join("subdir")).unwrap();
357
358 let result = generate_tree_json(temp.path(), 3).unwrap();
359
360 assert!(result["tree"]["files"].is_array());
361 assert!(result["tree"]["subdirectories"].is_array());
362 }
363
364 #[test]
365 fn test_should_exclude_hidden_files() {
366 let temp = TempDir::new().unwrap();
367 let hidden = temp.path().join(".hidden");
368 let gitignore = temp.path().join(".gitignore");
369
370 assert!(should_exclude(&hidden));
371 assert!(!should_exclude(&gitignore)); }
373}