Skip to main content

reflex/context/
structure.rs

1//! Directory structure generation for context
2
3use anyhow::Result;
4use serde_json::{json, Value};
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()
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
40/// Recursive tree generation
41fn 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    // Read directory entries
53    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    // Sort: directories first, then files, alphabetically
59    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        // Determine tree characters
79        let connector = if is_last { "└──" } else { "├──" };
80        let extension = if is_last { "    " } else { "│   " };
81
82        // Use symlink_metadata to avoid following symlinks when checking is_dir.
83        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            // Directory: show name with slash and possibly recurse
89            let dir_info = get_dir_info(&path);
90            output.push(format!("{}{} {}/ {}", prefix, connector, name_str, dir_info));
91
92            // Recurse if not at max depth
93            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            // File or symlink: show name with metadata
99            let file_info = get_file_info(&path);
100            output.push(format!("{}{} {} {}", prefix, connector, name_str, file_info));
101        }
102    }
103
104    Ok(())
105}
106
107/// Get directory information (file count, description)
108fn get_dir_info(dir: &Path) -> String {
109    // Count direct children
110    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
128/// Get file information (size, line count)
129fn get_file_info(file: &Path) -> String {
130    // Use symlink_metadata so we see the symlink itself, not its target.
131    let Ok(meta) = fs::symlink_metadata(file) else {
132        return String::new();
133    };
134
135    if meta.file_type().is_symlink() {
136        // Show the link target instead of resolving the target's size.
137        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    // Try to count lines for text files (use the real file content).
146    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    // Fallback to size
154    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
163/// Check if path should be excluded
164fn should_exclude(path: &Path) -> bool {
165    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
166        // Check against exclusion list
167        if EXCLUDED_DIRS.contains(&name) {
168            return true;
169        }
170
171        // Exclude hidden files/directories (except .gitignore, etc.)
172        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
183/// Generate JSON tree structure
184pub 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
195/// Recursive JSON tree generation
196fn 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
251/// Count lines in a text file
252fn 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        // Should show directory name
270        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        // Create some files
278        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        // Create nested structure
295        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        // Create build directories that should be excluded
313        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        // Create deep nested structure
331        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        // Depth 2 should not show d/
335        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)); // Keep .gitignore
364    }
365}