Skip to main content

steer_workspace/utils/
file_listing.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 VCS dirs)
19        let walker = WalkBuilder::new(root_path)
20            .hidden(false) // Include hidden files
21            .filter_entry(|entry| {
22                // Skip VCS directories
23                entry.file_name() != ".git" && entry.file_name() != ".jj"
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                && let Some(path_str) = relative_path.to_str()
41                && !path_str.is_empty()
42            {
43                // Add trailing slash for directories
44                if entry.file_type().is_some_and(|ft| ft.is_dir()) {
45                    files.push(format!("{path_str}/"));
46                } else {
47                    files.push(path_str.to_string());
48                }
49            }
50        }
51
52        // Apply fuzzy filter if query is provided
53        let mut filtered_files = if let Some(query) = query {
54            if query.is_empty() {
55                files
56            } else {
57                let matcher = SkimMatcherV2::default();
58                let mut scored_files: Vec<(i64, String)> = files
59                    .into_iter()
60                    .filter_map(|file| matcher.fuzzy_match(&file, query).map(|score| (score, file)))
61                    .collect();
62
63                // Sort by score (highest first)
64                scored_files.sort_by(|a, b| b.0.cmp(&a.0));
65
66                scored_files.into_iter().map(|(_, file)| file).collect()
67            }
68        } else {
69            files
70        };
71
72        // Apply max_results limit if specified
73        if let Some(max) = max_results
74            && max > 0
75            && filtered_files.len() > max
76        {
77            filtered_files.truncate(max);
78        }
79
80        Ok(filtered_files)
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use tempfile::tempdir;
88
89    #[test]
90    fn test_list_files_empty_dir() {
91        let temp_dir = tempdir().unwrap();
92        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
93        assert!(files.is_empty());
94    }
95
96    #[test]
97    fn test_list_files_with_content() {
98        let temp_dir = tempdir().unwrap();
99
100        // Create some test files
101        std::fs::write(temp_dir.path().join("test.rs"), "test").unwrap();
102        std::fs::write(temp_dir.path().join("main.rs"), "main").unwrap();
103        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
104        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
105
106        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
107        assert_eq!(files.len(), 4); // 3 files + 1 directory
108        assert!(files.contains(&"test.rs".to_string()));
109        assert!(files.contains(&"main.rs".to_string()));
110        assert!(files.contains(&"src/".to_string()));
111        assert!(files.contains(&"src/lib.rs".to_string()));
112    }
113
114    #[test]
115    fn test_list_files_with_query() {
116        let temp_dir = tempdir().unwrap();
117        std::fs::write(temp_dir.path().join("test.rs"), "test").unwrap();
118        std::fs::write(temp_dir.path().join("main.rs"), "main").unwrap();
119
120        let files = FileListingUtils::list_files(temp_dir.path(), Some("test"), None).unwrap();
121        assert_eq!(files.len(), 1);
122        assert_eq!(files[0], "test.rs");
123    }
124
125    #[test]
126    #[cfg(unix)]
127    fn test_list_files_skips_inaccessible() {
128        use std::os::unix::fs::PermissionsExt;
129
130        let temp_dir = tempdir().unwrap();
131
132        // Create accessible files
133        std::fs::write(temp_dir.path().join("readable.txt"), "test").unwrap();
134
135        // Create an inaccessible directory
136        let restricted_dir = temp_dir.path().join("restricted");
137        std::fs::create_dir(&restricted_dir).unwrap();
138        std::fs::write(restricted_dir.join("hidden.txt"), "secret").unwrap();
139
140        // Remove read permissions from the directory
141        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
142        perms.set_mode(0o000);
143        std::fs::set_permissions(&restricted_dir, perms).unwrap();
144
145        // Should list files without error, skipping the inaccessible directory
146        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
147
148        // Should contain the readable file
149        assert!(files.contains(&"readable.txt".to_string()));
150
151        // Restore permissions for cleanup
152        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
153        perms.set_mode(0o755);
154        std::fs::set_permissions(&restricted_dir, perms).unwrap();
155    }
156}