Skip to main content

reflex/context/
structure.rs

1//! Directory structure generation for context
2
3use anyhow::Result;
4use serde_json::{Value, json};
5use std::fs;
6use std::path::Path;
7
8/// Common directories to exclude from structure
9const 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
25/// Generate ASCII tree structure
26pub fn generate_tree(root: &Path, max_depth: usize) -> Result<String> {
27    let mut output = Vec::new();
28
29    // Show root directory name
30    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
38/// Recursive tree generation
39fn 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    // Read directory entries
51    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    // Sort: directories first, then files, alphabetically
57    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        // Determine tree characters
77        let connector = if is_last { "└──" } else { "├──" };
78        let extension = if is_last { "    " } else { "│   " };
79
80        // Use symlink_metadata to avoid following symlinks when checking is_dir.
81        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            // Directory: show name with slash and possibly recurse
87            let dir_info = get_dir_info(&path);
88            output.push(format!(
89                "{}{} {}/ {}",
90                prefix, connector, name_str, dir_info
91            ));
92
93            // Recurse if not at max depth
94            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            // File or symlink: show name with metadata
100            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
111/// Get directory information (file count, description)
112fn get_dir_info(dir: &Path) -> String {
113    // Count direct children
114    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
132/// Get file information (size, line count)
133fn get_file_info(file: &Path) -> String {
134    // Use symlink_metadata so we see the symlink itself, not its target.
135    let Ok(meta) = fs::symlink_metadata(file) else {
136        return String::new();
137    };
138
139    if meta.file_type().is_symlink() {
140        // Show the link target instead of resolving the target's size.
141        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    // Try to count lines for text files (use the real file content).
150    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    // Fallback to size
158    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
167/// Check if path should be excluded
168fn should_exclude(path: &Path) -> bool {
169    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
170        // Check against exclusion list
171        if EXCLUDED_DIRS.contains(&name) {
172            return true;
173        }
174
175        // Exclude hidden files/directories (except .gitignore, etc.)
176        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
187/// Generate JSON tree structure
188pub 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
197/// Recursive JSON tree generation
198fn 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
253/// Count lines in a text file
254fn 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        // Should show directory name
272        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        // Create some files
280        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        // Create nested structure
301        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        // Create build directories that should be excluded
319        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        // Create deep nested structure
337        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        // Depth 2 should not show d/
341        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)); // Keep .gitignore
372    }
373}