1use fuzzy_matcher::FuzzyMatcher;
2use fuzzy_matcher::skim::SkimMatcherV2;
3use ignore::WalkBuilder;
4use std::path::Path;
5
6pub struct FileListingUtils;
8
9impl FileListingUtils {
10 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 let walker = WalkBuilder::new(root_path)
20 .hidden(false) .filter_entry(|entry| {
22 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 if entry.path() == root_path {
34 continue;
35 }
36
37 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 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 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 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 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
83pub struct GitStatusUtils;
85
86impl GitStatusUtils {
87 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 match repo.head_name() {
96 Ok(Some(name)) => {
97 let branch = name.as_bstr().to_string();
98 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 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 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 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 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
182pub struct DirectoryStructureUtils;
184
185impl DirectoryStructureUtils {
186 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 Self::collect_directory_paths(root_path, &mut structure, 0, max_depth)?;
195
196 structure.sort();
197 Ok(structure.join("\n"))
198 }
199
200 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 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
232pub struct EnvironmentUtils;
234
235impl EnvironmentUtils {
236 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 pub fn get_current_date() -> String {
251 use chrono::Local;
252 Local::now().format("%Y-%m-%d").to_string()
253 }
254
255 pub fn is_git_repo(path: &Path) -> bool {
257 gix::discover(path).is_ok()
258 }
259
260 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 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 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); 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 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 gix::init(temp_dir.path()).unwrap();
336 assert!(EnvironmentUtils::is_git_repo(temp_dir.path()));
337 }
338}