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