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 if path.is_dir() {
83 let dir_info = get_dir_info(&path);
85 output.push(format!("{}{} {}/ {}", prefix, connector, name_str, dir_info));
86
87 if current_depth + 1 < max_depth {
89 let new_prefix = format!("{}{}", prefix, extension);
90 generate_tree_recursive(&path, &new_prefix, max_depth, current_depth + 1, output)?;
91 }
92 } else {
93 let file_info = get_file_info(&path);
95 output.push(format!("{}{} {} {}", prefix, connector, name_str, file_info));
96 }
97 }
98
99 Ok(())
100}
101
102fn get_dir_info(dir: &Path) -> String {
104 if let Ok(entries) = fs::read_dir(dir) {
106 let count = entries
107 .filter_map(|e| e.ok())
108 .filter(|e| !should_exclude(&e.path()))
109 .count();
110
111 if count == 0 {
112 return "(empty)".to_string();
113 } else if count == 1 {
114 return "(1 file)".to_string();
115 } else {
116 return format!("({} files)", count);
117 }
118 }
119
120 String::new()
121}
122
123fn get_file_info(file: &Path) -> String {
125 if let Ok(metadata) = fs::metadata(file) {
126 let size = metadata.len();
127
128 if let Ok(content) = fs::read_to_string(file) {
130 let lines = content.lines().count();
131 if lines > 0 {
132 return format!("({} lines)", lines);
133 }
134 }
135
136 if size < 1024 {
138 format!("({} bytes)", size)
139 } else if size < 1024 * 1024 {
140 format!("({} KB)", size / 1024)
141 } else {
142 format!("({} MB)", size / (1024 * 1024))
143 }
144 } else {
145 String::new()
146 }
147}
148
149fn should_exclude(path: &Path) -> bool {
151 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
152 if EXCLUDED_DIRS.contains(&name) {
154 return true;
155 }
156
157 if name.starts_with('.') && name.len() > 1 {
159 let keep_files = ["gitignore", "gitattributes", "dockerignore", "editorconfig"];
160 if !keep_files.iter().any(|f| name == &format!(".{}", f)) {
161 return true;
162 }
163 }
164 }
165
166 false
167}
168
169pub fn generate_tree_json(root: &Path, max_depth: usize) -> Result<Value> {
171 let root_name = root.file_name()
172 .and_then(|n| n.to_str())
173 .unwrap_or(".");
174
175 Ok(json!({
176 "root": root_name,
177 "tree": generate_tree_json_recursive(root, max_depth, 0)?
178 }))
179}
180
181fn generate_tree_json_recursive(
183 dir: &Path,
184 max_depth: usize,
185 current_depth: usize,
186) -> Result<Value> {
187 if current_depth >= max_depth {
188 return Ok(json!({}));
189 }
190
191 let mut entries: Vec<_> = fs::read_dir(dir)?
192 .filter_map(|e| e.ok())
193 .filter(|e| !should_exclude(&e.path()))
194 .collect();
195
196 entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
197
198 let mut tree = serde_json::Map::new();
199 let mut files = Vec::new();
200 let mut subdirs = Vec::new();
201
202 for entry in entries {
203 let path = entry.path();
204 let name = entry.file_name().to_string_lossy().to_string();
205
206 if path.is_dir() {
207 if current_depth + 1 < max_depth {
208 let subtree = generate_tree_json_recursive(&path, max_depth, current_depth + 1)?;
209 tree.insert(name.clone(), subtree);
210 }
211 subdirs.push(name);
212 } else {
213 files.push(json!({
214 "name": name,
215 "size": fs::metadata(&path).ok().map(|m| m.len()),
216 "lines": count_lines(&path).ok(),
217 }));
218 }
219 }
220
221 Ok(json!({
222 "type": "directory",
223 "files": files,
224 "subdirectories": subdirs,
225 "children": tree,
226 }))
227}
228
229fn count_lines(path: &Path) -> Result<usize> {
231 let content = fs::read_to_string(path)?;
232 Ok(content.lines().count())
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use std::fs::File;
239 use std::io::Write;
240 use tempfile::TempDir;
241
242 #[test]
243 fn test_generate_tree_empty_dir() {
244 let temp = TempDir::new().unwrap();
245 let result = generate_tree(temp.path(), 3).unwrap();
246
247 assert!(result.contains(temp.path().file_name().unwrap().to_str().unwrap()));
249 }
250
251 #[test]
252 fn test_generate_tree_with_files() {
253 let temp = TempDir::new().unwrap();
254
255 File::create(temp.path().join("file1.txt")).unwrap()
257 .write_all(b"line1\nline2\nline3").unwrap();
258 File::create(temp.path().join("file2.rs")).unwrap()
259 .write_all(b"fn main() {}").unwrap();
260
261 let result = generate_tree(temp.path(), 3).unwrap();
262
263 assert!(result.contains("file1.txt"));
264 assert!(result.contains("file2.rs"));
265 assert!(result.contains("lines"));
266 }
267
268 #[test]
269 fn test_generate_tree_with_nested_dirs() {
270 let temp = TempDir::new().unwrap();
271
272 fs::create_dir(temp.path().join("src")).unwrap();
274 fs::create_dir(temp.path().join("src/api")).unwrap();
275 File::create(temp.path().join("src/main.rs")).unwrap();
276 File::create(temp.path().join("src/api/routes.rs")).unwrap();
277
278 let result = generate_tree(temp.path(), 3).unwrap();
279
280 assert!(result.contains("src/"));
281 assert!(result.contains("main.rs"));
282 assert!(result.contains("api/"));
283 assert!(result.contains("routes.rs"));
284 }
285
286 #[test]
287 fn test_exclude_build_dirs() {
288 let temp = TempDir::new().unwrap();
289
290 fs::create_dir(temp.path().join("target")).unwrap();
292 fs::create_dir(temp.path().join("node_modules")).unwrap();
293 File::create(temp.path().join("target/debug.txt")).unwrap();
294 File::create(temp.path().join("file.txt")).unwrap();
295
296 let result = generate_tree(temp.path(), 3).unwrap();
297
298 assert!(!result.contains("target"));
299 assert!(!result.contains("node_modules"));
300 assert!(!result.contains("debug.txt"));
301 assert!(result.contains("file.txt"));
302 }
303
304 #[test]
305 fn test_depth_limiting() {
306 let temp = TempDir::new().unwrap();
307
308 fs::create_dir_all(temp.path().join("a/b/c/d")).unwrap();
310 File::create(temp.path().join("a/b/c/d/deep.txt")).unwrap();
311
312 let result = generate_tree(temp.path(), 2).unwrap();
314 assert!(result.contains("a/"));
315 assert!(result.contains("b/"));
316 assert!(!result.contains("c/"));
317 assert!(!result.contains("deep.txt"));
318 }
319
320 #[test]
321 fn test_generate_tree_json() {
322 let temp = TempDir::new().unwrap();
323
324 File::create(temp.path().join("test.txt")).unwrap()
325 .write_all(b"hello\nworld").unwrap();
326 fs::create_dir(temp.path().join("subdir")).unwrap();
327
328 let result = generate_tree_json(temp.path(), 3).unwrap();
329
330 assert!(result["tree"]["files"].is_array());
331 assert!(result["tree"]["subdirectories"].is_array());
332 }
333
334 #[test]
335 fn test_should_exclude_hidden_files() {
336 let temp = TempDir::new().unwrap();
337 let hidden = temp.path().join(".hidden");
338 let gitignore = temp.path().join(".gitignore");
339
340 assert!(should_exclude(&hidden));
341 assert!(!should_exclude(&gitignore)); }
343}