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
188    pub fn get_directory_structure(
189        root_path: &Path,
190        max_depth: usize,
191    ) -> Result<String, std::io::Error> {
192        let mut structure = vec![root_path.display().to_string()];
193
194        // Simple directory traversal (limited depth to avoid huge responses)
195        Self::collect_directory_paths(root_path, &mut structure, 0, max_depth)?;
196
197        structure.sort();
198        Ok(structure.join("\n"))
199    }
200
201    /// Recursively collect directory paths
202    fn collect_directory_paths(
203        dir: &Path,
204        paths: &mut Vec<String>,
205        current_depth: usize,
206        max_depth: usize,
207    ) -> Result<(), std::io::Error> {
208        if current_depth >= max_depth {
209            return Ok(());
210        }
211
212        let entries = match std::fs::read_dir(dir) {
213            Ok(entries) => entries,
214            Err(_) => return Ok(()), // Skip directories we can't access
215        };
216        for entry in entries {
217            let entry = match entry {
218                Ok(e) => e,
219                Err(_) => continue, // Skip entries we can't access
220            };
221            let path = entry.path();
222
223            // Get relative path from the original root directory
224            if let Some(rel_path) = path.file_name() {
225                let path_str = rel_path.to_string_lossy().to_string();
226                if path.is_dir() {
227                    paths.push(format!("{path_str}/"));
228                    Self::collect_directory_paths(&path, paths, current_depth + 1, max_depth)?;
229                } else {
230                    paths.push(path_str);
231                }
232            }
233        }
234
235        Ok(())
236    }
237}
238
239/// Common environment utilities for workspaces
240pub struct EnvironmentUtils;
241
242impl EnvironmentUtils {
243    /// Get the current platform string
244    pub fn get_platform() -> &'static str {
245        if cfg!(target_os = "windows") {
246            "windows"
247        } else if cfg!(target_os = "macos") {
248            "macos"
249        } else if cfg!(target_os = "linux") {
250            "linux"
251        } else {
252            "unknown"
253        }
254    }
255
256    /// Get the current date in YYYY-MM-DD format
257    pub fn get_current_date() -> String {
258        use chrono::Local;
259        Local::now().format("%Y-%m-%d").to_string()
260    }
261
262    /// Check if a directory is a git repository
263    pub fn is_git_repo(path: &Path) -> bool {
264        gix::discover(path).is_ok()
265    }
266
267    /// Read README.md if it exists
268    pub fn read_readme(path: &Path) -> Option<String> {
269        let readme_path = path.join("README.md");
270        std::fs::read_to_string(readme_path).ok()
271    }
272
273    /// Read CLAUDE.md if it exists
274    pub fn read_claude_md(path: &Path) -> Option<String> {
275        let claude_path = path.join("CLAUDE.md");
276        std::fs::read_to_string(claude_path).ok()
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use tempfile::tempdir;
284
285    #[test]
286    fn test_list_files_empty_dir() {
287        let temp_dir = tempdir().unwrap();
288        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
289        assert!(files.is_empty());
290    }
291
292    #[test]
293    fn test_list_files_with_content() {
294        let temp_dir = tempdir().unwrap();
295
296        // Create some test files
297        std::fs::write(temp_dir.path().join("test.rs"), "test").unwrap();
298        std::fs::write(temp_dir.path().join("main.rs"), "main").unwrap();
299        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
300        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
301
302        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
303        assert_eq!(files.len(), 4); // 3 files + 1 directory
304        assert!(files.contains(&"test.rs".to_string()));
305        assert!(files.contains(&"main.rs".to_string()));
306        assert!(files.contains(&"src/".to_string()));
307        assert!(files.contains(&"src/lib.rs".to_string()));
308    }
309
310    #[test]
311    fn test_list_files_with_query() {
312        let temp_dir = tempdir().unwrap();
313        std::fs::write(temp_dir.path().join("test.rs"), "test").unwrap();
314        std::fs::write(temp_dir.path().join("main.rs"), "main").unwrap();
315
316        let files = FileListingUtils::list_files(temp_dir.path(), Some("test"), None).unwrap();
317        assert_eq!(files.len(), 1);
318        assert_eq!(files[0], "test.rs");
319    }
320
321    #[test]
322    fn test_platform_detection() {
323        let platform = EnvironmentUtils::get_platform();
324        assert!(["windows", "macos", "linux", "unknown"].contains(&platform));
325    }
326
327    #[test]
328    fn test_date_format() {
329        let date = EnvironmentUtils::get_current_date();
330        // Basic check for YYYY-MM-DD format
331        assert_eq!(date.len(), 10);
332        assert_eq!(date.chars().nth(4), Some('-'));
333        assert_eq!(date.chars().nth(7), Some('-'));
334    }
335
336    #[test]
337    fn test_git_repo_detection() {
338        let temp_dir = tempdir().unwrap();
339        assert!(!EnvironmentUtils::is_git_repo(temp_dir.path()));
340
341        // Create a git repo
342        gix::init(temp_dir.path()).unwrap();
343        assert!(EnvironmentUtils::is_git_repo(temp_dir.path()));
344    }
345
346    #[test]
347    #[cfg(unix)]
348    fn test_list_files_skips_inaccessible() {
349        use std::os::unix::fs::PermissionsExt;
350
351        let temp_dir = tempdir().unwrap();
352
353        // Create accessible files
354        std::fs::write(temp_dir.path().join("readable.txt"), "test").unwrap();
355
356        // Create an inaccessible directory
357        let restricted_dir = temp_dir.path().join("restricted");
358        std::fs::create_dir(&restricted_dir).unwrap();
359        std::fs::write(restricted_dir.join("hidden.txt"), "secret").unwrap();
360
361        // Remove read permissions from the directory
362        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
363        perms.set_mode(0o000);
364        std::fs::set_permissions(&restricted_dir, perms).unwrap();
365
366        // Should list files without error, skipping the inaccessible directory
367        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
368
369        // Should contain the readable file
370        assert!(files.contains(&"readable.txt".to_string()));
371
372        // May or may not contain the restricted directory itself depending on walker behavior
373        // but should not error out
374
375        // Restore permissions for cleanup
376        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
377        perms.set_mode(0o755);
378        std::fs::set_permissions(&restricted_dir, perms).unwrap();
379    }
380
381    #[test]
382    #[cfg(unix)]
383    fn test_directory_structure_skips_inaccessible() {
384        use std::os::unix::fs::PermissionsExt;
385
386        let temp_dir = tempdir().unwrap();
387
388        // Create accessible directory
389        let accessible_dir = temp_dir.path().join("accessible");
390        std::fs::create_dir(&accessible_dir).unwrap();
391        std::fs::write(accessible_dir.join("file.txt"), "test").unwrap();
392
393        // Create an inaccessible directory
394        let restricted_dir = temp_dir.path().join("restricted");
395        std::fs::create_dir(&restricted_dir).unwrap();
396        std::fs::write(restricted_dir.join("hidden.txt"), "secret").unwrap();
397
398        // Remove read permissions from the directory
399        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
400        perms.set_mode(0o000);
401        std::fs::set_permissions(&restricted_dir, perms).unwrap();
402
403        // Should get directory structure without error
404        let result = DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3).unwrap();
405
406        // Should contain the accessible directory
407        assert!(result.contains("accessible/"));
408
409        // Should not error out due to inaccessible directory
410
411        // Restore permissions for cleanup
412        let mut perms = std::fs::metadata(&restricted_dir).unwrap().permissions();
413        perms.set_mode(0o755);
414        std::fs::set_permissions(&restricted_dir, perms).unwrap();
415    }
416}