steer_workspace/
utils.rs

1use fuzzy_matcher::FuzzyMatcher;
2use fuzzy_matcher::skim::SkimMatcherV2;
3use ignore::WalkBuilder;
4use std::path::Path;
5
6/// Common file listing functionality for workspaces
7pub struct FileListingUtils;
8
9impl FileListingUtils {
10    /// List files in a directory with optional fuzzy filtering
11    pub fn list_files(
12        root_path: &Path,
13        query: Option<&str>,
14        max_results: Option<usize>,
15    ) -> Result<Vec<String>, std::io::Error> {
16        let mut files = Vec::new();
17
18        // Walk the directory, respecting .gitignore but including hidden files (except .git)
19        let walker = WalkBuilder::new(root_path)
20            .hidden(false) // Include hidden files
21            .filter_entry(|entry| {
22                // Skip .git directory
23                entry.file_name() != ".git"
24            })
25            .build();
26
27        for entry in walker {
28            let entry = match entry {
29                Ok(e) => e,
30                Err(_) => continue, // Skip files we don't have access to
31            };
32
33            // Skip the root directory itself
34            if entry.path() == root_path {
35                continue;
36            }
37
38            // Get the relative path from the root
39            if let Ok(relative_path) = entry.path().strip_prefix(root_path) {
40                if let Some(path_str) = relative_path.to_str() {
41                    if !path_str.is_empty() {
42                        // Add trailing slash for directories
43                        if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
44                            files.push(format!("{path_str}/"));
45                        } else {
46                            files.push(path_str.to_string());
47                        }
48                    }
49                }
50            }
51        }
52
53        // Apply fuzzy filter if query is provided
54        let mut filtered_files = if let Some(query) = query {
55            if query.is_empty() {
56                files
57            } else {
58                let matcher = SkimMatcherV2::default();
59                let mut scored_files: Vec<(i64, String)> = files
60                    .into_iter()
61                    .filter_map(|file| matcher.fuzzy_match(&file, query).map(|score| (score, file)))
62                    .collect();
63
64                // Sort by score (highest first)
65                scored_files.sort_by(|a, b| b.0.cmp(&a.0));
66
67                scored_files.into_iter().map(|(_, file)| file).collect()
68            }
69        } else {
70            files
71        };
72
73        // Apply max_results limit if specified
74        if let Some(max) = max_results {
75            if max > 0 && filtered_files.len() > max {
76                filtered_files.truncate(max);
77            }
78        }
79
80        Ok(filtered_files)
81    }
82}
83
84/// Common git status functionality for workspaces
85pub struct GitStatusUtils;
86
87impl GitStatusUtils {
88    /// Get git status information for a repository
89    pub fn get_git_status(repo_path: &Path) -> Result<String, std::io::Error> {
90        let mut result = String::new();
91
92        let repo = gix::discover(repo_path)
93            .map_err(|e| std::io::Error::other(format!("Failed to open git repository: {e}")))?;
94
95        // Get current branch
96        match repo.head_name() {
97            Ok(Some(name)) => {
98                let branch = name.as_bstr().to_string();
99                // Remove "refs/heads/" prefix if present
100                let branch = branch.strip_prefix("refs/heads/").unwrap_or(&branch);
101                result.push_str(&format!("Current branch: {branch}\n\n"));
102            }
103            Ok(None) => {
104                result.push_str("Current branch: HEAD (detached)\n\n");
105            }
106            Err(e) => {
107                // Handle case where HEAD doesn't exist (new repo)
108                if e.to_string().contains("does not exist") {
109                    result.push_str("Current branch: <unborn>\n\n");
110                } else {
111                    return Err(std::io::Error::other(format!("Failed to get HEAD: {e}")));
112                }
113            }
114        }
115
116        // Get status
117        let iter = repo
118            .status(gix::progress::Discard)
119            .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?
120            .into_index_worktree_iter(Vec::new())
121            .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?;
122        result.push_str("Status:\n");
123        use gix::bstr::ByteSlice;
124        use gix::status::index_worktree::iter::Summary;
125        let mut has_changes = false;
126        for item_res in iter {
127            let item = item_res
128                .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?;
129            if let Some(summary) = item.summary() {
130                has_changes = true;
131                let path = item.rela_path().to_str_lossy();
132                let (status_char, wt_char) = match summary {
133                    Summary::Added => (" ", "?"),
134                    Summary::Removed => ("D", " "),
135                    Summary::Modified => ("M", " "),
136                    Summary::TypeChange => ("T", " "),
137                    Summary::Renamed => ("R", " "),
138                    Summary::Copied => ("C", " "),
139                    Summary::IntentToAdd => ("A", " "),
140                    Summary::Conflict => ("U", "U"),
141                };
142                result.push_str(&format!("{status_char}{wt_char} {path}\n"));
143            }
144        }
145        if !has_changes {
146            result.push_str("Working tree clean\n");
147        }
148
149        // Get recent commits
150        result.push_str("\nRecent commits:\n");
151        match repo.head_id() {
152            Ok(head_id) => {
153                let oid = head_id.detach();
154                let mut count = 0;
155                if let Ok(object) = repo.find_object(oid) {
156                    if let Ok(commit) = object.try_into_commit() {
157                        // Just show the HEAD commit for now, as rev_walk API changed
158                        let summary_bytes = commit.message_raw_sloppy();
159                        let summary = summary_bytes
160                            .lines()
161                            .next()
162                            .and_then(|line| std::str::from_utf8(line).ok())
163                            .unwrap_or("<no summary>");
164                        let short_id = oid.to_hex().to_string();
165                        let short_id = &short_id[..7.min(short_id.len())];
166                        result.push_str(&format!("{short_id} {summary}\n"));
167                        count = 1;
168                    }
169                }
170                if count == 0 {
171                    result.push_str("<no commits>\n");
172                }
173            }
174            Err(_) => {
175                result.push_str("<no commits>\n");
176            }
177        }
178
179        Ok(result)
180    }
181}
182
183/// Common directory structure functionality for workspaces
184pub struct DirectoryStructureUtils;
185
186impl DirectoryStructureUtils {
187    /// Get directory structure with limited depth and item count
188    /// Shows gitignored/hidden directories as leaf nodes with item counts
189    pub fn get_directory_structure(
190        root_path: &Path,
191        max_depth: usize,
192        max_items: Option<usize>,
193    ) -> Result<String, std::io::Error> {
194        let mut structure = vec![root_path.display().to_string()];
195
196        // Use WalkBuilder to respect .gitignore
197        let (paths, truncated) = Self::collect_directory_paths(root_path, max_depth, max_items)?;
198        structure.extend(paths);
199
200        structure.sort();
201        let mut result = structure.join("\n");
202
203        if truncated > 0 {
204            result.push_str(&format!("\n... and {truncated} more items"));
205        }
206
207        Ok(result)
208    }
209
210    /// Collect directory paths respecting .gitignore and filtering hidden directories
211    /// Returns (paths, number_of_truncated_items)
212    fn collect_directory_paths(
213        root_path: &Path,
214        max_depth: usize,
215        max_items: Option<usize>,
216    ) -> Result<(Vec<String>, usize), std::io::Error> {
217        let mut paths = Vec::new();
218        let mut item_count = 0;
219        let mut truncated = 0;
220        let limit = max_items.unwrap_or(usize::MAX);
221        let mut walker_seen_dirs = std::collections::HashSet::new();
222
223        // First pass: collect allowed entries using WalkBuilder (respects .gitignore)
224        // Note: We use hidden(true) to exclude hidden files/dirs from traversal
225        let walker = WalkBuilder::new(root_path)
226            .max_depth(Some(max_depth))
227            .hidden(true) // Exclude hidden files/dirs from traversal
228            .build();
229
230        for entry in walker {
231            let entry = match entry {
232                Ok(e) => e,
233                Err(_) => continue,
234            };
235
236            // Skip the root directory itself
237            if entry.path() == root_path {
238                continue;
239            }
240
241            if let Ok(relative_path) = entry.path().strip_prefix(root_path) {
242                if let Some(path_str) = relative_path.to_str() {
243                    if !path_str.is_empty() {
244                        // Track immediate child directories that walker saw
245                        if entry.depth() == 1
246                            && entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
247                        {
248                            if let Some(dir_name) = relative_path.file_name() {
249                                walker_seen_dirs.insert(dir_name.to_string_lossy().to_string());
250                            }
251                        }
252
253                        if item_count >= limit {
254                            truncated += 1;
255                            continue;
256                        }
257
258                        if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
259                            paths.push(format!("{path_str}/"));
260                        } else {
261                            paths.push(path_str.to_string());
262                        }
263                        item_count += 1;
264                    }
265                }
266            }
267        }
268
269        // Second pass: check immediate children for ignored/hidden directories
270        // and add them as leaf nodes with counts
271        if max_depth > 0 {
272            let entries = std::fs::read_dir(root_path)?;
273            for entry in entries {
274                let entry = match entry {
275                    Ok(e) => e,
276                    Err(_) => continue,
277                };
278
279                let path = entry.path();
280                if !path.is_dir() {
281                    continue;
282                }
283
284                let file_name = match path.file_name() {
285                    Some(name) => name.to_string_lossy().to_string(),
286                    None => continue,
287                };
288
289                // Skip directories that the walker already saw (even if truncated)
290                if walker_seen_dirs.contains(&file_name) {
291                    continue;
292                }
293
294                // Check if we've reached the limit
295                if item_count >= limit {
296                    truncated += 1;
297                    continue;
298                }
299
300                // This is an ignored or hidden directory - count its contents
301                let dir_item_count = Self::count_items_in_dir(&path);
302                if dir_item_count > 0 {
303                    paths.push(format!("{file_name}/ ({dir_item_count} items)"));
304                } else {
305                    paths.push(format!("{file_name}/ (empty)"));
306                }
307                item_count += 1;
308            }
309        }
310
311        Ok((paths, truncated))
312    }
313
314    /// Count items in a directory (for ignored/hidden directories)
315    fn count_items_in_dir(dir: &Path) -> usize {
316        std::fs::read_dir(dir)
317            .map(|entries| entries.count())
318            .unwrap_or(0)
319    }
320}
321
322/// Common environment utilities for workspaces
323pub struct EnvironmentUtils;
324
325impl EnvironmentUtils {
326    /// Get the current platform string
327    pub fn get_platform() -> &'static str {
328        if cfg!(target_os = "windows") {
329            "windows"
330        } else if cfg!(target_os = "macos") {
331            "macos"
332        } else if cfg!(target_os = "linux") {
333            "linux"
334        } else {
335            "unknown"
336        }
337    }
338
339    /// Get the current date in YYYY-MM-DD format
340    pub fn get_current_date() -> String {
341        use chrono::Local;
342        Local::now().format("%Y-%m-%d").to_string()
343    }
344
345    /// Check if a directory is a git repository
346    pub fn is_git_repo(path: &Path) -> bool {
347        gix::discover(path).is_ok()
348    }
349
350    /// Read README.md if it exists
351    pub fn read_readme(path: &Path) -> Option<String> {
352        let readme_path = path.join("README.md");
353        std::fs::read_to_string(readme_path).ok()
354    }
355
356    /// Read CLAUDE.md if it exists
357    pub fn read_claude_md(path: &Path) -> Option<String> {
358        let claude_path = path.join("CLAUDE.md");
359        std::fs::read_to_string(claude_path).ok()
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use tempfile::tempdir;
367
368    #[test]
369    fn test_list_files_empty_dir() {
370        let temp_dir = tempdir().unwrap();
371        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
372        assert!(files.is_empty());
373    }
374
375    #[test]
376    fn test_list_files_with_content() {
377        let temp_dir = tempdir().unwrap();
378
379        // Create some test files
380        std::fs::write(temp_dir.path().join("test.rs"), "test").unwrap();
381        std::fs::write(temp_dir.path().join("main.rs"), "main").unwrap();
382        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
383        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
384
385        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
386        assert_eq!(files.len(), 4); // 3 files + 1 directory
387        assert!(files.contains(&"test.rs".to_string()));
388        assert!(files.contains(&"main.rs".to_string()));
389        assert!(files.contains(&"src/".to_string()));
390        assert!(files.contains(&"src/lib.rs".to_string()));
391    }
392
393    #[test]
394    fn test_list_files_with_query() {
395        let temp_dir = tempdir().unwrap();
396        std::fs::write(temp_dir.path().join("test.rs"), "test").unwrap();
397        std::fs::write(temp_dir.path().join("main.rs"), "main").unwrap();
398
399        let files = FileListingUtils::list_files(temp_dir.path(), Some("test"), None).unwrap();
400        assert_eq!(files.len(), 1);
401        assert_eq!(files[0], "test.rs");
402    }
403
404    #[test]
405    fn test_platform_detection() {
406        let platform = EnvironmentUtils::get_platform();
407        assert!(["windows", "macos", "linux", "unknown"].contains(&platform));
408    }
409
410    #[test]
411    fn test_date_format() {
412        let date = EnvironmentUtils::get_current_date();
413        // Basic check for YYYY-MM-DD format
414        assert_eq!(date.len(), 10);
415        assert_eq!(date.chars().nth(4), Some('-'));
416        assert_eq!(date.chars().nth(7), Some('-'));
417    }
418
419    #[test]
420    fn test_git_repo_detection() {
421        let temp_dir = tempdir().unwrap();
422        assert!(!EnvironmentUtils::is_git_repo(temp_dir.path()));
423
424        // Create a git repo
425        gix::init(temp_dir.path()).unwrap();
426        assert!(EnvironmentUtils::is_git_repo(temp_dir.path()));
427    }
428
429    #[test]
430    #[cfg(unix)]
431    fn test_list_files_skips_inaccessible() {
432        use std::os::unix::fs::PermissionsExt;
433
434        let temp_dir = tempdir().unwrap();
435
436        // Create accessible files
437        std::fs::write(temp_dir.path().join("readable.txt"), "test").unwrap();
438
439        // Create an inaccessible directory
440        let restricted_dir = temp_dir.path().join("restricted");
441        std::fs::create_dir(&restricted_dir).unwrap();
442        std::fs::write(restricted_dir.join("hidden.txt"), "secret").unwrap();
443
444        // Remove read permissions from the directory
445        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
446        perms.set_mode(0o000);
447        std::fs::set_permissions(&restricted_dir, perms).unwrap();
448
449        // Should list files without error, skipping the inaccessible directory
450        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
451
452        // Should contain the readable file
453        assert!(files.contains(&"readable.txt".to_string()));
454
455        // May or may not contain the restricted directory itself depending on walker behavior
456        // but should not error out
457
458        // Restore permissions for cleanup
459        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
460        perms.set_mode(0o755);
461        std::fs::set_permissions(&restricted_dir, perms).unwrap();
462    }
463
464    #[test]
465    #[cfg(unix)]
466    fn test_directory_structure_skips_inaccessible() {
467        use std::os::unix::fs::PermissionsExt;
468
469        let temp_dir = tempdir().unwrap();
470
471        // Create accessible directory
472        let accessible_dir = temp_dir.path().join("accessible");
473        std::fs::create_dir(&accessible_dir).unwrap();
474        std::fs::write(accessible_dir.join("file.txt"), "test").unwrap();
475
476        // Create an inaccessible directory
477        let restricted_dir = temp_dir.path().join("restricted");
478        std::fs::create_dir(&restricted_dir).unwrap();
479        std::fs::write(restricted_dir.join("hidden.txt"), "secret").unwrap();
480
481        // Remove read permissions from the directory
482        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
483        perms.set_mode(0o000);
484        std::fs::set_permissions(&restricted_dir, perms).unwrap();
485
486        // Should get directory structure without error
487        let result =
488            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
489
490        // Should contain the accessible directory
491        assert!(result.contains("accessible/"));
492
493        // Should not error out due to inaccessible directory
494
495        // Restore permissions for cleanup
496        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
497        perms.set_mode(0o755);
498        std::fs::set_permissions(&restricted_dir, perms).unwrap();
499    }
500
501    #[test]
502    fn test_directory_structure_empty_dir() {
503        let temp_dir = tempdir().unwrap();
504        let expected = temp_dir.path().display().to_string();
505        let result =
506            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
507        assert_eq!(result, expected);
508    }
509
510    #[test]
511    fn test_directory_structure_with_gitignored_dirs() {
512        let temp_dir = tempdir().unwrap();
513
514        // Create .gitignore file
515        std::fs::write(
516            temp_dir.path().join(".gitignore"),
517            "target/\nnode_modules/\n*.log",
518        )
519        .unwrap();
520
521        // Create regular files and dirs
522        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
523        std::fs::write(temp_dir.path().join("src/main.rs"), "main").unwrap();
524        std::fs::write(temp_dir.path().join("Cargo.toml"), "cargo").unwrap();
525
526        // Create gitignored directories with content
527        std::fs::create_dir(temp_dir.path().join("target")).unwrap();
528        std::fs::create_dir(temp_dir.path().join("target/debug")).unwrap();
529        std::fs::write(temp_dir.path().join("target/debug/app"), "binary").unwrap();
530
531        std::fs::create_dir(temp_dir.path().join("node_modules")).unwrap();
532        std::fs::create_dir(temp_dir.path().join("node_modules/pkg1")).unwrap();
533        std::fs::create_dir(temp_dir.path().join("node_modules/pkg2")).unwrap();
534        std::fs::write(temp_dir.path().join("node_modules/pkg1/index.js"), "js").unwrap();
535
536        // Create .git directory (hidden)
537        std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
538        std::fs::write(temp_dir.path().join(".git/config"), "config").unwrap();
539        std::fs::write(temp_dir.path().join(".git/HEAD"), "HEAD").unwrap();
540
541        // Create gitignored file
542        std::fs::write(temp_dir.path().join("debug.log"), "log").unwrap();
543
544        // Build expected output
545        // Note: .git is hidden and shown with count
546        // .gitignore is excluded as a hidden file with hidden(true)
547        let mut expected_lines = [
548            temp_dir.path().display().to_string(),
549            ".git/ (2 items)".to_string(), // hidden dir, shown with count
550            "Cargo.toml".to_string(),
551            "node_modules/ (2 items)".to_string(), // gitignored, shown with count
552            "src/".to_string(),
553            "src/main.rs".to_string(),
554            "target/ (1 items)".to_string(), // gitignored, shown with count
555        ];
556        expected_lines.sort();
557        let expected = expected_lines.join("\n");
558
559        let result =
560            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
561        assert_eq!(result, expected);
562    }
563
564    #[test]
565    fn test_directory_structure_with_files() {
566        let temp_dir = tempdir().unwrap();
567
568        // Create some files
569        std::fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
570        std::fs::write(temp_dir.path().join("file2.rs"), "content2").unwrap();
571
572        let mut expected_lines = [
573            temp_dir.path().display().to_string(),
574            "file1.txt".to_string(),
575            "file2.rs".to_string(),
576        ];
577        expected_lines.sort();
578        let expected = expected_lines.join("\n");
579
580        let result =
581            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
582        assert_eq!(result, expected);
583    }
584
585    #[test]
586    fn test_directory_structure_with_subdirs() {
587        let temp_dir = tempdir().unwrap();
588
589        // Create nested directory structure
590        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
591        std::fs::create_dir(temp_dir.path().join("tests")).unwrap();
592        std::fs::write(temp_dir.path().join("src/main.rs"), "main").unwrap();
593        std::fs::write(temp_dir.path().join("tests/test.rs"), "test").unwrap();
594
595        let mut expected_lines = [
596            temp_dir.path().display().to_string(),
597            "src/".to_string(),
598            "src/main.rs".to_string(),
599            "tests/".to_string(),
600            "tests/test.rs".to_string(),
601        ];
602        expected_lines.sort();
603        let expected = expected_lines.join("\n");
604
605        let result =
606            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
607        assert_eq!(result, expected);
608    }
609
610    #[test]
611    fn test_directory_structure_max_depth_zero() {
612        let temp_dir = tempdir().unwrap();
613
614        // Create nested structure that shouldn't be traversed
615        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
616        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
617
618        let expected = temp_dir.path().display().to_string();
619        let result =
620            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 0, None).unwrap();
621        assert_eq!(result, expected);
622    }
623
624    #[test]
625    fn test_directory_structure_max_depth_one() {
626        let temp_dir = tempdir().unwrap();
627
628        // Create nested structure
629        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
630        std::fs::create_dir(temp_dir.path().join("src/nested")).unwrap();
631        std::fs::write(temp_dir.path().join("file.txt"), "root file").unwrap();
632        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
633        std::fs::write(temp_dir.path().join("src/nested/deep.rs"), "deep").unwrap();
634
635        // With max_depth = 1, should get root + immediate children only
636        let mut expected_lines = [
637            temp_dir.path().display().to_string(),
638            "file.txt".to_string(),
639            "src/".to_string(),
640        ];
641        expected_lines.sort();
642        let expected = expected_lines.join("\n");
643
644        let result =
645            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 1, None).unwrap();
646        assert_eq!(result, expected);
647    }
648
649    #[test]
650    fn test_directory_structure_deeply_nested() {
651        let temp_dir = tempdir().unwrap();
652
653        // Create deeply nested structure
654        std::fs::create_dir(temp_dir.path().join("a")).unwrap();
655        std::fs::create_dir(temp_dir.path().join("a/b")).unwrap();
656        std::fs::create_dir(temp_dir.path().join("a/b/c")).unwrap();
657        std::fs::write(temp_dir.path().join("a/file1.txt"), "1").unwrap();
658        std::fs::write(temp_dir.path().join("a/b/file2.txt"), "2").unwrap();
659        std::fs::write(temp_dir.path().join("a/b/c/file3.txt"), "3").unwrap();
660
661        // With max_depth = 2, should get a/ and a/b/ but not a/b/c/
662        // Note: a/b/c/ will be detected as a subdirectory and shown with count
663        let mut expected_lines = [
664            temp_dir.path().display().to_string(),
665            "a/".to_string(),
666            "a/b/".to_string(),
667            "a/file1.txt".to_string(),
668        ];
669        expected_lines.sort();
670        let expected = expected_lines.join("\n");
671
672        let result =
673            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 2, None).unwrap();
674        assert_eq!(result, expected);
675    }
676
677    #[test]
678    fn test_directory_structure_mixed_content() {
679        let temp_dir = tempdir().unwrap();
680
681        // Create mixed files and directories
682        std::fs::write(temp_dir.path().join("README.md"), "readme").unwrap();
683        std::fs::write(temp_dir.path().join("Cargo.toml"), "cargo").unwrap();
684        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
685        std::fs::create_dir(temp_dir.path().join("tests")).unwrap();
686        std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
687        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
688        std::fs::write(temp_dir.path().join("src/main.rs"), "main").unwrap();
689        std::fs::write(temp_dir.path().join("tests/integration.rs"), "test").unwrap();
690        std::fs::write(temp_dir.path().join(".git/config"), "config").unwrap();
691
692        // .git is not hidden from WalkBuilder with hidden(false), it traverses it
693        let mut expected_lines = vec![
694            temp_dir.path().display().to_string(),
695            ".git/ (1 items)".to_string(), // hidden dir, shown with count
696            "Cargo.toml".to_string(),
697            "README.md".to_string(),
698            "src/".to_string(),
699            "src/lib.rs".to_string(),
700            "src/main.rs".to_string(),
701            "tests/".to_string(),
702            "tests/integration.rs".to_string(),
703        ];
704        expected_lines.sort();
705        let expected = expected_lines.join("\n");
706
707        let result =
708            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
709        assert_eq!(result, expected);
710    }
711
712    #[test]
713    fn test_directory_structure_with_hidden_files() {
714        let temp_dir = tempdir().unwrap();
715
716        // Create some regular and hidden files/directories
717        std::fs::write(temp_dir.path().join("README.md"), "readme").unwrap();
718        std::fs::write(temp_dir.path().join(".env"), "secrets").unwrap(); // hidden file
719        std::fs::write(temp_dir.path().join(".gitignore"), "*.log").unwrap(); // hidden file
720
721        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
722        std::fs::write(temp_dir.path().join("src/main.rs"), "main").unwrap();
723
724        std::fs::create_dir(temp_dir.path().join(".cache")).unwrap(); // hidden dir
725        std::fs::write(temp_dir.path().join(".cache/data"), "cached").unwrap();
726
727        std::fs::create_dir(temp_dir.path().join(".hidden")).unwrap(); // hidden dir
728        std::fs::create_dir(temp_dir.path().join(".hidden/nested")).unwrap();
729        std::fs::write(temp_dir.path().join(".hidden/file.txt"), "hidden").unwrap();
730
731        // Build expected output
732        // Hidden directories shown with counts, hidden files excluded by hidden(true)
733        let mut expected_lines = [
734            temp_dir.path().display().to_string(),
735            ".cache/ (1 items)".to_string(), // hidden dir with count
736            // .env and .gitignore are excluded by hidden(true)
737            ".hidden/ (2 items)".to_string(), // hidden dir with count
738            "README.md".to_string(),
739            "src/".to_string(),
740            "src/main.rs".to_string(),
741        ];
742        expected_lines.sort();
743        let expected = expected_lines.join("\n");
744
745        let result =
746            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
747        assert_eq!(result, expected);
748    }
749
750    #[test]
751    fn test_directory_structure_special_chars() {
752        let temp_dir = tempdir().unwrap();
753
754        // Create files with special characters
755        std::fs::write(temp_dir.path().join("file with spaces.txt"), "content").unwrap();
756        std::fs::write(temp_dir.path().join("file-with-dashes.rs"), "content").unwrap();
757        std::fs::write(temp_dir.path().join("file_with_underscores.md"), "content").unwrap();
758        std::fs::create_dir(temp_dir.path().join("dir with spaces")).unwrap();
759
760        let mut expected_lines = [
761            temp_dir.path().display().to_string(),
762            "dir with spaces/".to_string(),
763            "file with spaces.txt".to_string(),
764            "file-with-dashes.rs".to_string(),
765            "file_with_underscores.md".to_string(),
766        ];
767        expected_lines.sort();
768        let expected = expected_lines.join("\n");
769
770        let result =
771            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, None).unwrap();
772        assert_eq!(result, expected);
773    }
774
775    #[test]
776    fn test_directory_structure_with_max_items_limit() {
777        let temp_dir = tempdir().unwrap();
778
779        // Create 20 files
780        for i in 0..20 {
781            std::fs::write(temp_dir.path().join(format!("file{i:02}.txt")), "content").unwrap();
782        }
783
784        // Test with limit of 5 items
785        let result =
786            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, Some(5)).unwrap();
787
788        let lines: Vec<&str> = result.lines().collect();
789
790        // Verify structure
791        assert_eq!(lines[0], temp_dir.path().display().to_string());
792        assert_eq!(lines.len(), 7); // root + 5 items + truncation
793        assert_eq!(lines[6], "... and 15 more items");
794
795        // Verify we got 5 files (can't predict which ones due to traversal order)
796        for line in lines.iter().take(6).skip(1) {
797            assert!(line.ends_with(".txt"));
798        }
799    }
800
801    #[test]
802    fn test_directory_structure_with_dirs_and_max_items() {
803        let temp_dir = tempdir().unwrap();
804
805        // Create 5 items
806        std::fs::create_dir(temp_dir.path().join("dir1")).unwrap();
807        std::fs::create_dir(temp_dir.path().join("dir2")).unwrap();
808        std::fs::write(temp_dir.path().join("file1.txt"), "content").unwrap();
809        std::fs::write(temp_dir.path().join("file2.txt"), "content").unwrap();
810        std::fs::create_dir(temp_dir.path().join("dir3")).unwrap();
811
812        // Test with limit of 3 items
813        let result =
814            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, Some(3)).unwrap();
815
816        let expected = format!(
817            "{}\ndir2/\nfile1.txt\nfile2.txt\n... and 2 more items",
818            temp_dir.path().display()
819        );
820
821        assert_eq!(result, expected);
822    }
823
824    #[test]
825    fn test_directory_structure_no_truncation_when_under_limit() {
826        let temp_dir = tempdir().unwrap();
827
828        // Create just a few files
829        std::fs::write(temp_dir.path().join("file1.txt"), "content").unwrap();
830        std::fs::write(temp_dir.path().join("file2.txt"), "content").unwrap();
831        std::fs::create_dir(temp_dir.path().join("subdir")).unwrap();
832
833        // Test with high limit
834        let result =
835            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, Some(100))
836                .unwrap();
837
838        // Should not have truncation message
839        assert!(!result.contains("... and"));
840        assert!(!result.contains("more items"));
841
842        let lines: Vec<&str> = result.lines().collect();
843        assert_eq!(lines.len(), 4); // root + 2 files + 1 dir
844    }
845
846    #[test]
847    fn test_directory_structure_with_hidden_dirs_and_limit() {
848        let temp_dir = tempdir().unwrap();
849
850        // Create regular files
851        for i in 0..5 {
852            std::fs::write(temp_dir.path().join(format!("file{i}.txt")), "content").unwrap();
853        }
854
855        // Create hidden directories
856        std::fs::create_dir(temp_dir.path().join(".hidden1")).unwrap();
857        std::fs::write(temp_dir.path().join(".hidden1/file.txt"), "hidden").unwrap();
858
859        std::fs::create_dir(temp_dir.path().join(".hidden2")).unwrap();
860        std::fs::write(temp_dir.path().join(".hidden2/file.txt"), "hidden").unwrap();
861
862        // Test with limit of 4 items
863        let result =
864            DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3, Some(4)).unwrap();
865
866        let lines: Vec<&str> = result.lines().collect();
867
868        // Verify structure
869        assert_eq!(lines[0], temp_dir.path().display().to_string());
870        assert_eq!(lines.len(), 6); // root + 4 items + truncation
871        assert_eq!(lines[5], "... and 3 more items");
872
873        // The walker sees the 5 regular files (not hidden dirs), picks first 4 in traversal order
874        // Hidden dirs are only seen by the second pass, but we've already hit the limit
875        for line in lines.iter().take(5).skip(1) {
876            assert!(line.ends_with(".txt"));
877        }
878    }
879}