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 = entry.map_err(|e| {
29                std::io::Error::other(format!("Failed to read directory entry: {e}"))
30            })?;
31
32            // Skip the root directory itself
33            if entry.path() == root_path {
34                continue;
35            }
36
37            // Get the relative path from the root
38            if let Ok(relative_path) = entry.path().strip_prefix(root_path) {
39                if let Some(path_str) = relative_path.to_str() {
40                    if !path_str.is_empty() {
41                        // Add trailing slash for directories
42                        if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
43                            files.push(format!("{path_str}/"));
44                        } else {
45                            files.push(path_str.to_string());
46                        }
47                    }
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            if max > 0 && filtered_files.len() > max {
75                filtered_files.truncate(max);
76            }
77        }
78
79        Ok(filtered_files)
80    }
81}
82
83/// Common git status functionality for workspaces
84pub struct GitStatusUtils;
85
86impl GitStatusUtils {
87    /// Get git status information for a repository
88    pub fn get_git_status(repo_path: &Path) -> Result<String, std::io::Error> {
89        let mut result = String::new();
90
91        let repo = gix::discover(repo_path)
92            .map_err(|e| std::io::Error::other(format!("Failed to open git repository: {e}")))?;
93
94        // Get current branch
95        match repo.head_name() {
96            Ok(Some(name)) => {
97                let branch = name.as_bstr().to_string();
98                // Remove "refs/heads/" prefix if present
99                let branch = branch.strip_prefix("refs/heads/").unwrap_or(&branch);
100                result.push_str(&format!("Current branch: {branch}\n\n"));
101            }
102            Ok(None) => {
103                result.push_str("Current branch: HEAD (detached)\n\n");
104            }
105            Err(e) => {
106                // Handle case where HEAD doesn't exist (new repo)
107                if e.to_string().contains("does not exist") {
108                    result.push_str("Current branch: <unborn>\n\n");
109                } else {
110                    return Err(std::io::Error::other(format!("Failed to get HEAD: {e}")));
111                }
112            }
113        }
114
115        // Get status
116        let iter = repo
117            .status(gix::progress::Discard)
118            .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?
119            .into_index_worktree_iter(Vec::new())
120            .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?;
121        result.push_str("Status:\n");
122        use gix::bstr::ByteSlice;
123        use gix::status::index_worktree::iter::Summary;
124        let mut has_changes = false;
125        for item_res in iter {
126            let item = item_res
127                .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?;
128            if let Some(summary) = item.summary() {
129                has_changes = true;
130                let path = item.rela_path().to_str_lossy();
131                let (status_char, wt_char) = match summary {
132                    Summary::Added => (" ", "?"),
133                    Summary::Removed => ("D", " "),
134                    Summary::Modified => ("M", " "),
135                    Summary::TypeChange => ("T", " "),
136                    Summary::Renamed => ("R", " "),
137                    Summary::Copied => ("C", " "),
138                    Summary::IntentToAdd => ("A", " "),
139                    Summary::Conflict => ("U", "U"),
140                };
141                result.push_str(&format!("{status_char}{wt_char} {path}\n"));
142            }
143        }
144        if !has_changes {
145            result.push_str("Working tree clean\n");
146        }
147
148        // Get recent commits
149        result.push_str("\nRecent commits:\n");
150        match repo.head_id() {
151            Ok(head_id) => {
152                let oid = head_id.detach();
153                let mut count = 0;
154                if let Ok(object) = repo.find_object(oid) {
155                    if let Ok(commit) = object.try_into_commit() {
156                        // Just show the HEAD commit for now, as rev_walk API changed
157                        let summary_bytes = commit.message_raw_sloppy();
158                        let summary = summary_bytes
159                            .lines()
160                            .next()
161                            .and_then(|line| std::str::from_utf8(line).ok())
162                            .unwrap_or("<no summary>");
163                        let short_id = oid.to_hex().to_string();
164                        let short_id = &short_id[..7.min(short_id.len())];
165                        result.push_str(&format!("{short_id} {summary}\n"));
166                        count = 1;
167                    }
168                }
169                if count == 0 {
170                    result.push_str("<no commits>\n");
171                }
172            }
173            Err(_) => {
174                result.push_str("<no commits>\n");
175            }
176        }
177
178        Ok(result)
179    }
180}
181
182/// Common directory structure functionality for workspaces
183pub struct DirectoryStructureUtils;
184
185impl DirectoryStructureUtils {
186    /// Get directory structure with limited depth
187    pub fn get_directory_structure(
188        root_path: &Path,
189        max_depth: usize,
190    ) -> Result<String, std::io::Error> {
191        let mut structure = vec![root_path.display().to_string()];
192
193        // Simple directory traversal (limited depth to avoid huge responses)
194        Self::collect_directory_paths(root_path, &mut structure, 0, max_depth)?;
195
196        structure.sort();
197        Ok(structure.join("\n"))
198    }
199
200    /// Recursively collect directory paths
201    fn collect_directory_paths(
202        dir: &Path,
203        paths: &mut Vec<String>,
204        current_depth: usize,
205        max_depth: usize,
206    ) -> Result<(), std::io::Error> {
207        if current_depth >= max_depth {
208            return Ok(());
209        }
210
211        let entries = std::fs::read_dir(dir)?;
212        for entry in entries {
213            let entry = entry?;
214            let path = entry.path();
215
216            // Get relative path from the original root directory
217            if let Some(rel_path) = path.file_name() {
218                let path_str = rel_path.to_string_lossy().to_string();
219                if path.is_dir() {
220                    paths.push(format!("{path_str}/"));
221                    Self::collect_directory_paths(&path, paths, current_depth + 1, max_depth)?;
222                } else {
223                    paths.push(path_str);
224                }
225            }
226        }
227
228        Ok(())
229    }
230}
231
232/// Common environment utilities for workspaces
233pub struct EnvironmentUtils;
234
235impl EnvironmentUtils {
236    /// Get the current platform string
237    pub fn get_platform() -> &'static str {
238        if cfg!(target_os = "windows") {
239            "windows"
240        } else if cfg!(target_os = "macos") {
241            "macos"
242        } else if cfg!(target_os = "linux") {
243            "linux"
244        } else {
245            "unknown"
246        }
247    }
248
249    /// Get the current date in YYYY-MM-DD format
250    pub fn get_current_date() -> String {
251        use chrono::Local;
252        Local::now().format("%Y-%m-%d").to_string()
253    }
254
255    /// Check if a directory is a git repository
256    pub fn is_git_repo(path: &Path) -> bool {
257        gix::discover(path).is_ok()
258    }
259
260    /// Read README.md if it exists
261    pub fn read_readme(path: &Path) -> Option<String> {
262        let readme_path = path.join("README.md");
263        std::fs::read_to_string(readme_path).ok()
264    }
265
266    /// Read CLAUDE.md if it exists
267    pub fn read_claude_md(path: &Path) -> Option<String> {
268        let claude_path = path.join("CLAUDE.md");
269        std::fs::read_to_string(claude_path).ok()
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use tempfile::tempdir;
277
278    #[test]
279    fn test_list_files_empty_dir() {
280        let temp_dir = tempdir().unwrap();
281        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
282        assert!(files.is_empty());
283    }
284
285    #[test]
286    fn test_list_files_with_content() {
287        let temp_dir = tempdir().unwrap();
288
289        // Create some test files
290        std::fs::write(temp_dir.path().join("test.rs"), "test").unwrap();
291        std::fs::write(temp_dir.path().join("main.rs"), "main").unwrap();
292        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
293        std::fs::write(temp_dir.path().join("src/lib.rs"), "lib").unwrap();
294
295        let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
296        assert_eq!(files.len(), 4); // 3 files + 1 directory
297        assert!(files.contains(&"test.rs".to_string()));
298        assert!(files.contains(&"main.rs".to_string()));
299        assert!(files.contains(&"src/".to_string()));
300        assert!(files.contains(&"src/lib.rs".to_string()));
301    }
302
303    #[test]
304    fn test_list_files_with_query() {
305        let temp_dir = tempdir().unwrap();
306        std::fs::write(temp_dir.path().join("test.rs"), "test").unwrap();
307        std::fs::write(temp_dir.path().join("main.rs"), "main").unwrap();
308
309        let files = FileListingUtils::list_files(temp_dir.path(), Some("test"), None).unwrap();
310        assert_eq!(files.len(), 1);
311        assert_eq!(files[0], "test.rs");
312    }
313
314    #[test]
315    fn test_platform_detection() {
316        let platform = EnvironmentUtils::get_platform();
317        assert!(["windows", "macos", "linux", "unknown"].contains(&platform));
318    }
319
320    #[test]
321    fn test_date_format() {
322        let date = EnvironmentUtils::get_current_date();
323        // Basic check for YYYY-MM-DD format
324        assert_eq!(date.len(), 10);
325        assert_eq!(date.chars().nth(4), Some('-'));
326        assert_eq!(date.chars().nth(7), Some('-'));
327    }
328
329    #[test]
330    fn test_git_repo_detection() {
331        let temp_dir = tempdir().unwrap();
332        assert!(!EnvironmentUtils::is_git_repo(temp_dir.path()));
333
334        // Create a git repo
335        gix::init(temp_dir.path()).unwrap();
336        assert!(EnvironmentUtils::is_git_repo(temp_dir.path()));
337    }
338}