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 = match entry {
29 Ok(e) => e,
30 Err(_) => continue, };
32
33 if entry.path() == root_path {
35 continue;
36 }
37
38 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 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 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 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 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
84pub struct GitStatusUtils;
86
87impl GitStatusUtils {
88 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 match repo.head_name() {
97 Ok(Some(name)) => {
98 let branch = name.as_bstr().to_string();
99 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 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 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 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 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
183pub struct DirectoryStructureUtils;
185
186impl DirectoryStructureUtils {
187 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 Self::collect_directory_paths(root_path, &mut structure, 0, max_depth)?;
196
197 structure.sort();
198 Ok(structure.join("\n"))
199 }
200
201 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(()), };
216 for entry in entries {
217 let entry = match entry {
218 Ok(e) => e,
219 Err(_) => continue, };
221 let path = entry.path();
222
223 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
239pub struct EnvironmentUtils;
241
242impl EnvironmentUtils {
243 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 pub fn get_current_date() -> String {
258 use chrono::Local;
259 Local::now().format("%Y-%m-%d").to_string()
260 }
261
262 pub fn is_git_repo(path: &Path) -> bool {
264 gix::discover(path).is_ok()
265 }
266
267 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 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 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); 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 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 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 std::fs::write(temp_dir.path().join("readable.txt"), "test").unwrap();
355
356 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 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 let files = FileListingUtils::list_files(temp_dir.path(), None, None).unwrap();
368
369 assert!(files.contains(&"readable.txt".to_string()));
371
372 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 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 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 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 let result = DirectoryStructureUtils::get_directory_structure(temp_dir.path(), 3).unwrap();
405
406 assert!(result.contains("accessible/"));
408
409 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}