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        if path.is_dir() {
83            // Directory: show name with slash and possibly recurse
84            let dir_info = get_dir_info(&path);
85            output.push(format!("{}{} {}/ {}", prefix, connector, name_str, dir_info));
86
87            // Recurse if not at max depth
88            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            // File: show name with metadata
94            let file_info = get_file_info(&path);
95            output.push(format!("{}{} {} {}", prefix, connector, name_str, file_info));
96        }
97    }
98
99    Ok(())
100}
101
102/// Get directory information (file count, description)
103fn get_dir_info(dir: &Path) -> String {
104    // Count direct children
105    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
123/// Get file information (size, line count)
124fn get_file_info(file: &Path) -> String {
125    if let Ok(metadata) = fs::metadata(file) {
126        let size = metadata.len();
127
128        // Try to count lines for text files
129        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        // Fallback to size
137        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
149/// Check if path should be excluded
150fn should_exclude(path: &Path) -> bool {
151    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
152        // Check against exclusion list
153        if EXCLUDED_DIRS.contains(&name) {
154            return true;
155        }
156
157        // Exclude hidden files/directories (except .gitignore, etc.)
158        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
169/// Generate JSON tree structure
170pub 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
181/// Recursive JSON tree generation
182fn 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
229/// Count lines in a text file
230fn 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        // Should show directory name
248        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        // Create some files
256        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        // Create nested structure
273        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        // Create build directories that should be excluded
291        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        // Create deep nested structure
309        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        // Depth 2 should not show d/
313        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)); // Keep .gitignore
342    }
343}