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