Skip to main content

steer_workspace/utils/
directory_structure.rs

1use ignore::WalkBuilder;
2use std::path::Path;
3
4/// Common directory structure functionality for workspaces
5pub struct DirectoryStructureUtils;
6
7impl DirectoryStructureUtils {
8    /// Get directory structure with limited depth and item count
9    /// Shows gitignored/hidden directories as leaf nodes with item counts
10    pub fn get_directory_structure(
11        root_path: &Path,
12        max_depth: usize,
13        max_items: Option<usize>,
14    ) -> Result<String, std::io::Error> {
15        let mut structure = vec![root_path.display().to_string()];
16
17        // Use WalkBuilder to respect .gitignore
18        let (paths, truncated) = Self::collect_directory_paths(root_path, max_depth, max_items)?;
19        structure.extend(paths);
20
21        structure.sort();
22        let mut result = structure.join("\n");
23
24        if truncated > 0 {
25            result.push_str(&format!("\n... and {truncated} more items"));
26        }
27
28        Ok(result)
29    }
30
31    /// Collect directory paths respecting .gitignore and filtering hidden directories
32    /// Returns (paths, number_of_truncated_items)
33    fn collect_directory_paths(
34        root_path: &Path,
35        max_depth: usize,
36        max_items: Option<usize>,
37    ) -> Result<(Vec<String>, usize), std::io::Error> {
38        let mut paths = Vec::new();
39        let mut item_count = 0;
40        let mut truncated = 0;
41        let limit = max_items.unwrap_or(usize::MAX);
42        let mut walker_seen_dirs = std::collections::HashSet::new();
43
44        // First pass: collect allowed entries using WalkBuilder (respects .gitignore)
45        // Note: We use hidden(true) to exclude hidden files/dirs from traversal
46        let walker = WalkBuilder::new(root_path)
47            .max_depth(Some(max_depth))
48            .hidden(true) // Exclude hidden files/dirs from traversal
49            .build();
50
51        for entry in walker {
52            let entry = match entry {
53                Ok(e) => e,
54                Err(_) => continue,
55            };
56
57            // Skip the root directory itself
58            if entry.path() == root_path {
59                continue;
60            }
61
62            if let Ok(relative_path) = entry.path().strip_prefix(root_path)
63                && let Some(path_str) = relative_path.to_str()
64                && !path_str.is_empty()
65            {
66                // Track immediate child directories that walker saw
67                if entry.depth() == 1
68                    && entry.file_type().is_some_and(|ft| ft.is_dir())
69                    && let Some(dir_name) = relative_path.file_name()
70                {
71                    walker_seen_dirs.insert(dir_name.to_string_lossy().to_string());
72                }
73
74                if item_count >= limit {
75                    truncated += 1;
76                    continue;
77                }
78
79                if entry.file_type().is_some_and(|ft| ft.is_dir()) {
80                    paths.push(format!("{path_str}/"));
81                } else {
82                    paths.push(path_str.to_string());
83                }
84                item_count += 1;
85            }
86        }
87
88        // Second pass: check immediate children for ignored/hidden directories
89        // and add them as leaf nodes with counts
90        if max_depth > 0 {
91            let entries = std::fs::read_dir(root_path)?;
92            for entry in entries {
93                let entry = match entry {
94                    Ok(e) => e,
95                    Err(_) => continue,
96                };
97
98                let path = entry.path();
99                if !path.is_dir() {
100                    continue;
101                }
102
103                let file_name = match path.file_name() {
104                    Some(name) => name.to_string_lossy().to_string(),
105                    None => continue,
106                };
107
108                // Skip directories that the walker already saw (even if truncated)
109                if walker_seen_dirs.contains(&file_name) {
110                    continue;
111                }
112
113                // Check if we've reached the limit
114                if item_count >= limit {
115                    truncated += 1;
116                    continue;
117                }
118
119                // This is an ignored or hidden directory - count its contents
120                let dir_item_count = Self::count_items_in_dir(&path);
121                if dir_item_count > 0 {
122                    paths.push(format!("{file_name}/ ({dir_item_count} items)"));
123                } else {
124                    paths.push(format!("{file_name}/ (empty)"));
125                }
126                item_count += 1;
127            }
128        }
129
130        Ok((paths, truncated))
131    }
132
133    /// Count items in a directory (for ignored/hidden directories)
134    fn count_items_in_dir(dir: &Path) -> usize {
135        std::fs::read_dir(dir)
136            .map(|entries| entries.count())
137            .unwrap_or(0)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use tempfile::tempdir;
145
146    #[test]
147    #[cfg(unix)]
148    fn test_directory_structure_skips_inaccessible() {
149        use std::os::unix::fs::PermissionsExt;
150
151        let temp_dir = tempdir().unwrap();
152
153        // Create accessible directory
154        let accessible_dir = temp_dir.path().join("accessible");
155        std::fs::create_dir(&accessible_dir).unwrap();
156        std::fs::write(accessible_dir.join("file.txt"), "test").unwrap();
157
158        // Create an inaccessible directory
159        let restricted_dir = temp_dir.path().join("restricted");
160        std::fs::create_dir(&restricted_dir).unwrap();
161        std::fs::write(restricted_dir.join("hidden.txt"), "secret").unwrap();
162
163        // Remove read permissions from the directory
164        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
165        perms.set_mode(0o000);
166        std::fs::set_permissions(&restricted_dir, perms).unwrap();
167
168        // Should get directory structure without error
169        let result =
170            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
171
172        // Should contain the accessible directory
173        assert!(result.contains("accessible/"));
174
175        // Restore permissions for cleanup
176        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
177        perms.set_mode(0o755);
178        std::fs::set_permissions(&restricted_dir, perms).unwrap();
179    }
180
181    #[test]
182    fn test_directory_structure_empty_dir() {
183        let temp_dir = tempdir().unwrap();
184        let expected = temp_dir.path().display().to_string();
185        let result =
186            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
187        assert_eq!(result, expected);
188    }
189
190    #[test]
191    fn test_directory_structure_with_gitignored_dirs() {
192        let temp_dir = tempdir().unwrap();
193
194        // Create .gitignore file
195        std::fs::write(
196            temp_dir.path().join(".gitignore"),
197            "target/\nnode_modules/\n*.log",
198        )
199        .unwrap();
200
201        // Create regular files and dirs
202        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
203        std::fs::write(temp_dir.path().join("src/main.rs"), "main").unwrap();
204        std::fs::write(temp_dir.path().join("Cargo.toml"), "cargo").unwrap();
205
206        // Create gitignored directories with content
207        std::fs::create_dir(temp_dir.path().join("target")).unwrap();
208        std::fs::create_dir(temp_dir.path().join("target/debug")).unwrap();
209        std::fs::write(temp_dir.path().join("target/debug/app"), "binary").unwrap();
210
211        std::fs::create_dir(temp_dir.path().join("node_modules")).unwrap();
212        std::fs::create_dir(temp_dir.path().join("node_modules/pkg1")).unwrap();
213        std::fs::create_dir(temp_dir.path().join("node_modules/pkg2")).unwrap();
214        std::fs::write(temp_dir.path().join("node_modules/pkg1/index.js"), "js").unwrap();
215
216        // Create .git directory (hidden)
217        std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
218        std::fs::write(temp_dir.path().join(".git/config"), "config").unwrap();
219        std::fs::write(temp_dir.path().join(".git/HEAD"), "HEAD").unwrap();
220
221        // Create gitignored file
222        std::fs::write(temp_dir.path().join("debug.log"), "log").unwrap();
223
224        // Build expected output
225        // Note: .git is hidden and shown with count
226        // .gitignore is excluded as a hidden file with hidden(true)
227        let mut expected_lines = [
228            temp_dir.path().display().to_string(),
229            ".git/ (2 items)".to_string(), // hidden dir, shown with count
230            "Cargo.toml".to_string(),
231            "node_modules/ (2 items)".to_string(), // gitignored, shown with count
232            "src/".to_string(),
233            "src/main.rs".to_string(),
234            "target/ (1 items)".to_string(), // gitignored, shown with count
235        ];
236        expected_lines.sort();
237        let expected = expected_lines.join("\n");
238
239        let result =
240            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
241        assert_eq!(result, expected);
242    }
243
244    #[test]
245    fn test_directory_structure_with_files() {
246        let temp_dir = tempdir().unwrap();
247
248        // Create some files
249        std::fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
250        std::fs::write(temp_dir.path().join("file2.rs"), "content2").unwrap();
251
252        let mut expected_lines = [
253            temp_dir.path().display().to_string(),
254            "file1.txt".to_string(),
255            "file2.rs".to_string(),
256        ];
257        expected_lines.sort();
258        let expected = expected_lines.join("\n");
259
260        let result =
261            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
262        assert_eq!(result, expected);
263    }
264
265    #[test]
266    fn test_directory_structure_with_subdirs() {
267        let temp_dir = tempdir().unwrap();
268
269        // Create nested directory structure
270        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
271        std::fs::create_dir(temp_dir.path().join("tests")).unwrap();
272        std::fs::write(temp_dir.path().join("src/main.rs"), "main").unwrap();
273        std::fs::write(temp_dir.path().join("tests/test.rs"), "test").unwrap();
274
275        let mut expected_lines = [
276            temp_dir.path().display().to_string(),
277            "src/".to_string(),
278            "src/main.rs".to_string(),
279            "tests/".to_string(),
280            "tests/test.rs".to_string(),
281        ];
282        expected_lines.sort();
283        let expected = expected_lines.join("\n");
284
285        let result =
286            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
287        assert_eq!(result, expected);
288    }
289
290    #[test]
291    fn test_directory_structure_max_depth_zero() {
292        let temp_dir = tempdir().unwrap();
293
294        // Create nested structure that shouldn't be traversed
295        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
296        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
297
298        let expected = temp_dir.path().display().to_string();
299        let result =
300            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 0, None).unwrap();
301        assert_eq!(result, expected);
302    }
303
304    #[test]
305    fn test_directory_structure_max_depth_one() {
306        let temp_dir = tempdir().unwrap();
307
308        // Create nested structure
309        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
310        std::fs::create_dir(temp_dir.path().join("src/nested")).unwrap();
311        std::fs::write(temp_dir.path().join("file.txt"), "root file").unwrap();
312        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
313        std::fs::write(temp_dir.path().join("src/nested/deep.rs"), "deep").unwrap();
314
315        // With max_depth = 1, should get root + immediate children only
316        let mut expected_lines = [
317            temp_dir.path().display().to_string(),
318            "file.txt".to_string(),
319            "src/".to_string(),
320        ];
321        expected_lines.sort();
322        let expected = expected_lines.join("\n");
323
324        let result =
325            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 1, None).unwrap();
326        assert_eq!(result, expected);
327    }
328
329    #[test]
330    fn test_directory_structure_deeply_nested() {
331        let temp_dir = tempdir().unwrap();
332
333        // Create deeply nested structure
334        std::fs::create_dir(temp_dir.path().join("a")).unwrap();
335        std::fs::create_dir(temp_dir.path().join("a/b")).unwrap();
336        std::fs::create_dir(temp_dir.path().join("a/b/c")).unwrap();
337        std::fs::write(temp_dir.path().join("a/file1.txt"), "1").unwrap();
338        std::fs::write(temp_dir.path().join("a/b/file2.txt"), "2").unwrap();
339        std::fs::write(temp_dir.path().join("a/b/c/file3.txt"), "3").unwrap();
340
341        // With max_depth = 2, should get a/ and a/b/ but not a/b/c/
342        // Note: a/b/c/ will be detected as a subdirectory and shown with count
343        let mut expected_lines = [
344            temp_dir.path().display().to_string(),
345            "a/".to_string(),
346            "a/b/".to_string(),
347            "a/file1.txt".to_string(),
348        ];
349        expected_lines.sort();
350        let expected = expected_lines.join("\n");
351
352        let result =
353            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 2, None).unwrap();
354        assert_eq!(result, expected);
355    }
356
357    #[test]
358    fn test_directory_structure_mixed_content() {
359        let temp_dir = tempdir().unwrap();
360
361        // Create mixed files and directories
362        std::fs::write(temp_dir.path().join("README.md"), "readme").unwrap();
363        std::fs::write(temp_dir.path().join("Cargo.toml"), "cargo").unwrap();
364        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
365        std::fs::create_dir(temp_dir.path().join("tests")).unwrap();
366        std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
367        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
368        std::fs::write(temp_dir.path().join("src/main.rs"), "main").unwrap();
369        std::fs::write(temp_dir.path().join("tests/integration.rs"), "test").unwrap();
370        std::fs::write(temp_dir.path().join(".git/config"), "config").unwrap();
371
372        // .git is not hidden from WalkBuilder with hidden(false), it traverses it
373        let mut expected_lines = vec![
374            temp_dir.path().display().to_string(),
375            ".git/ (1 items)".to_string(), // hidden dir, shown with count
376            "Cargo.toml".to_string(),
377            "README.md".to_string(),
378            "src/".to_string(),
379            "src/lib.rs".to_string(),
380            "src/main.rs".to_string(),
381            "tests/".to_string(),
382            "tests/integration.rs".to_string(),
383        ];
384        expected_lines.sort();
385        let expected = expected_lines.join("\n");
386
387        let result =
388            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
389        assert_eq!(result, expected);
390    }
391
392    #[test]
393    fn test_directory_structure_with_hidden_files() {
394        let temp_dir = tempdir().unwrap();
395
396        // Create some regular and hidden files/directories
397        std::fs::write(temp_dir.path().join("README.md"), "readme").unwrap();
398        std::fs::write(temp_dir.path().join(".env"), "secrets").unwrap(); // hidden file
399        std::fs::write(temp_dir.path().join(".gitignore"), "*.log").unwrap(); // hidden file
400
401        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
402        std::fs::write(temp_dir.path().join("src/main.rs"), "main").unwrap();
403
404        std::fs::create_dir(temp_dir.path().join(".cache")).unwrap(); // hidden dir
405        std::fs::write(temp_dir.path().join(".cache/data"), "cached").unwrap();
406
407        std::fs::create_dir(temp_dir.path().join(".hidden")).unwrap(); // hidden dir
408        std::fs::create_dir(temp_dir.path().join(".hidden/nested")).unwrap();
409        std::fs::write(temp_dir.path().join(".hidden/file.txt"), "hidden").unwrap();
410
411        // Build expected output
412        // Hidden directories shown with counts, hidden files excluded by hidden(true)
413        let mut expected_lines = [
414            temp_dir.path().display().to_string(),
415            ".cache/ (1 items)".to_string(), // hidden dir with count
416            // .env and .gitignore are excluded by hidden(true)
417            ".hidden/ (2 items)".to_string(), // hidden dir with count
418            "README.md".to_string(),
419            "src/".to_string(),
420            "src/main.rs".to_string(),
421        ];
422        expected_lines.sort();
423        let expected = expected_lines.join("\n");
424
425        let result =
426            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
427        assert_eq!(result, expected);
428    }
429
430    #[test]
431    fn test_directory_structure_special_chars() {
432        let temp_dir = tempdir().unwrap();
433
434        // Create files with special characters
435        std::fs::write(temp_dir.path().join("file with spaces.txt"), "content").unwrap();
436        std::fs::write(temp_dir.path().join("file-with-dashes.rs"), "content").unwrap();
437        std::fs::write(temp_dir.path().join("file_with_underscores.md"), "content").unwrap();
438        std::fs::create_dir(temp_dir.path().join("dir with spaces")).unwrap();
439
440        let mut expected_lines = [
441            temp_dir.path().display().to_string(),
442            "dir with spaces/".to_string(),
443            "file with spaces.txt".to_string(),
444            "file-with-dashes.rs".to_string(),
445            "file_with_underscores.md".to_string(),
446        ];
447        expected_lines.sort();
448        let expected = expected_lines.join("\n");
449
450        let result =
451            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
452        assert_eq!(result, expected);
453    }
454
455    #[test]
456    fn test_directory_structure_with_max_items_limit() {
457        let temp_dir = tempdir().unwrap();
458
459        // Create 20 files
460        for i in 0..20 {
461            std::fs::write(temp_dir.path().join(format!("file{i:02}.txt")), "content").unwrap();
462        }
463
464        // Test with limit of 5 items
465        let result =
466            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, Some(5)).unwrap();
467
468        let lines: Vec<&str> = result.lines().collect();
469
470        // Verify structure
471        assert_eq!(lines[0], temp_dir.path().display().to_string());
472        assert_eq!(lines.len(), 7); // root + 5 items + truncation
473        assert_eq!(lines[6], "... and 15 more items");
474
475        // Verify we got 5 files (can't predict which ones due to traversal order)
476        for line in lines.iter().take(6).skip(1) {
477            assert!(line.ends_with(".txt"));
478        }
479    }
480
481    #[test]
482    fn test_directory_structure_with_dirs_and_max_items() {
483        let temp_dir = tempdir().unwrap();
484
485        // Create 5 items
486        std::fs::create_dir(temp_dir.path().join("dir1")).unwrap();
487        std::fs::create_dir(temp_dir.path().join("dir2")).unwrap();
488        std::fs::write(temp_dir.path().join("file1.txt"), "content").unwrap();
489        std::fs::write(temp_dir.path().join("file2.txt"), "content").unwrap();
490        std::fs::create_dir(temp_dir.path().join("dir3")).unwrap();
491
492        // Test with limit of 3 items
493        let result =
494            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, Some(3)).unwrap();
495
496        let expected = format!(
497            "{}\ndir2/\nfile1.txt\nfile2.txt\n... and 2 more items",
498            temp_dir.path().display()
499        );
500
501        assert_eq!(result, expected);
502    }
503
504    #[test]
505    fn test_directory_structure_no_truncation_when_under_limit() {
506        let temp_dir = tempdir().unwrap();
507
508        // Create just a few files
509        std::fs::write(temp_dir.path().join("file1.txt"), "content").unwrap();
510        std::fs::write(temp_dir.path().join("file2.txt"), "content").unwrap();
511        std::fs::create_dir(temp_dir.path().join("subdir")).unwrap();
512
513        // Test with high limit
514        let result =
515            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, Some(100))
516                .unwrap();
517
518        // Should not have truncation message
519        assert!(!result.contains("... and"));
520        assert!(!result.contains("more items"));
521
522        let lines: Vec<&str> = result.lines().collect();
523        assert_eq!(lines.len(), 4); // root + 2 files + 1 dir
524    }
525
526    #[test]
527    fn test_directory_structure_with_hidden_dirs_and_limit() {
528        let temp_dir = tempdir().unwrap();
529
530        // Create regular files
531        for i in 0..5 {
532            std::fs::write(temp_dir.path().join(format!("file{i}.txt")), "content").unwrap();
533        }
534
535        // Create hidden directories
536        std::fs::create_dir(temp_dir.path().join(".hidden1")).unwrap();
537        std::fs::write(temp_dir.path().join(".hidden1/file.txt"), "hidden").unwrap();
538
539        std::fs::create_dir(temp_dir.path().join(".hidden2")).unwrap();
540        std::fs::write(temp_dir.path().join(".hidden2/file.txt"), "hidden").unwrap();
541
542        // Test with limit of 4 items
543        let result =
544            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, Some(4)).unwrap();
545
546        let lines: Vec<&str> = result.lines().collect();
547
548        // Verify structure
549        assert_eq!(lines[0], temp_dir.path().display().to_string());
550        assert_eq!(lines.len(), 6); // root + 4 items + truncation
551        assert_eq!(lines[5], "... and 3 more items");
552
553        // The walker sees the 5 regular files (not hidden dirs), picks first 4 in traversal order
554        // Hidden dirs are only seen by the second pass, but we've already hit the limit
555        for line in lines.iter().take(5).skip(1) {
556            assert!(line.ends_with(".txt"));
557        }
558    }
559}