context_builder/
file_utils.rs

1use ignore::{DirEntry, WalkBuilder, overrides::OverrideBuilder};
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5
6/// Collects all files to be processed using `ignore` crate for efficient traversal.
7pub fn collect_files(
8    base_path: &Path,
9    filters: &[String],
10    ignores: &[String],
11) -> io::Result<Vec<DirEntry>> {
12    let mut walker = WalkBuilder::new(base_path);
13    // By default, the "ignore" crate respects .gitignore and hidden files, so we don't need walker.hidden(false)
14
15    // Build overrides for custom ignore patterns
16    let mut override_builder = OverrideBuilder::new(base_path);
17    for pattern in ignores {
18        // Attention: Confusing pattern ahead!
19        // Add the pattern to the override builder with ! prefix to ignore matching files.
20        // In OverrideBuilder, patterns without ! are whitelist (include) patterns,
21        // while patterns with ! are ignore patterns.
22        let ignore_pattern = format!("!{}", pattern);
23        if let Err(e) = override_builder.add(&ignore_pattern) {
24            return Err(io::Error::new(
25                io::ErrorKind::InvalidInput,
26                format!("Invalid ignore pattern '{}': {}", pattern, e),
27            ));
28        }
29    }
30    // Also, always ignore the config file itself
31    if let Err(e) = override_builder.add("!context-builder.toml") {
32        return Err(io::Error::new(
33            io::ErrorKind::InvalidInput,
34            format!("Failed to add config ignore: {}", e),
35        ));
36    }
37
38    let overrides = override_builder.build().map_err(|e| {
39        io::Error::new(
40            io::ErrorKind::InvalidInput,
41            format!("Failed to build overrides: {}", e),
42        )
43    })?;
44    walker.overrides(overrides);
45
46    if !filters.is_empty() {
47        let mut type_builder = ignore::types::TypesBuilder::new();
48        type_builder.add_defaults();
49        for filter in filters {
50            let _ = type_builder.add(filter, &format!("*.{}", filter));
51            type_builder.select(filter);
52        }
53        let types = type_builder.build().unwrap();
54        walker.types(types);
55    }
56
57    let mut files: Vec<DirEntry> = walker
58        .build()
59        .filter_map(Result::ok)
60        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
61        .collect();
62
63    // FIX: Sort files deterministically by path to ensure consistent output order
64    files.sort_by(|a, b| a.path().cmp(b.path()));
65
66    Ok(files)
67}
68
69/// Asks for user confirmation if the number of files is large.
70pub fn confirm_processing(file_count: usize) -> io::Result<bool> {
71    if file_count > 100 {
72        print!(
73            "Warning: You're about to process {} files. This might take a while. Continue? [y/N] ",
74            file_count
75        );
76        io::stdout().flush()?;
77        let mut input = String::new();
78        io::stdin().read_line(&mut input)?;
79        if !input.trim().eq_ignore_ascii_case("y") {
80            return Ok(false);
81        }
82    }
83    Ok(true)
84}
85
86/// Asks for user confirmation to overwrite an existing file.
87pub fn confirm_overwrite(file_path: &str) -> io::Result<bool> {
88    print!("The file '{}' already exists. Overwrite? [y/N] ", file_path);
89    io::stdout().flush()?;
90    let mut input = String::new();
91    io::stdin().read_line(&mut input)?;
92
93    if input.trim().eq_ignore_ascii_case("y") {
94        Ok(true)
95    } else {
96        Ok(false)
97    }
98}
99
100pub fn find_latest_file(dir: &Path) -> io::Result<Option<PathBuf>> {
101    if !dir.is_dir() {
102        return Ok(None);
103    }
104
105    let mut latest_file = None;
106    let mut latest_time = std::time::SystemTime::UNIX_EPOCH;
107
108    for entry in fs::read_dir(dir)? {
109        let entry = entry?;
110        let path = entry.path();
111        if path.is_file() {
112            let metadata = fs::metadata(&path)?;
113            let modified = metadata.modified()?;
114            if modified > latest_time {
115                latest_time = modified;
116                latest_file = Some(path);
117            }
118        }
119    }
120
121    Ok(latest_file)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::fs;
128    use std::path::Path;
129    use tempfile::tempdir;
130
131    fn to_rel_paths(mut entries: Vec<DirEntry>, base: &Path) -> Vec<String> {
132        entries.sort_by_key(|e| e.path().to_path_buf());
133        entries
134            .iter()
135            .map(|e| {
136                e.path()
137                    .strip_prefix(base)
138                    .unwrap()
139                    .to_string_lossy()
140                    .replace('\\', "/")
141            })
142            .collect()
143    }
144
145    #[test]
146    fn collect_files_respects_filters() {
147        let dir = tempdir().unwrap();
148        let base = dir.path();
149
150        // create files
151        fs::create_dir_all(base.join("src")).unwrap();
152        fs::create_dir_all(base.join("scripts")).unwrap();
153        fs::write(base.join("src").join("main.rs"), "fn main() {}").unwrap();
154        fs::write(base.join("Cargo.toml"), "[package]\nname=\"x\"").unwrap();
155        fs::write(base.join("README.md"), "# readme").unwrap();
156        fs::write(base.join("scripts").join("build.sh"), "#!/bin/sh\n").unwrap();
157
158        let filters = vec!["rs".to_string(), "toml".to_string()];
159        let ignores: Vec<String> = vec![];
160
161        let files = collect_files(base, &filters, &ignores).unwrap();
162        let relative_paths = to_rel_paths(files, base);
163
164        assert!(relative_paths.contains(&"src/main.rs".to_string()));
165        assert!(relative_paths.contains(&"Cargo.toml".to_string()));
166        assert!(!relative_paths.contains(&"README.md".to_string()));
167        assert!(!relative_paths.contains(&"scripts/build.sh".to_string()));
168    }
169
170    #[test]
171    fn collect_files_respects_ignores_for_dirs_and_files() {
172        let dir = tempdir().unwrap();
173        let base = dir.path();
174
175        fs::create_dir_all(base.join("src")).unwrap();
176        fs::create_dir_all(base.join("target")).unwrap();
177        fs::create_dir_all(base.join("node_modules")).unwrap();
178
179        fs::write(base.join("src").join("main.rs"), "fn main() {}").unwrap();
180        fs::write(base.join("target").join("artifact.txt"), "bin").unwrap();
181        fs::write(base.join("node_modules").join("pkg.js"), "console.log();").unwrap();
182        fs::write(base.join("README.md"), "# readme").unwrap();
183
184        let filters: Vec<String> = vec![];
185        let ignores: Vec<String> = vec!["target".into(), "node_modules".into(), "README.md".into()];
186
187        let files = collect_files(base, &filters, &ignores).unwrap();
188        let relative_paths = to_rel_paths(files, base);
189
190        assert!(relative_paths.contains(&"src/main.rs".to_string()));
191        assert!(!relative_paths.contains(&"target/artifact.txt".to_string()));
192        assert!(!relative_paths.contains(&"node_modules/pkg.js".to_string()));
193        assert!(!relative_paths.contains(&"README.md".to_string()));
194    }
195
196    #[test]
197    fn collect_files_handles_invalid_ignore_pattern() {
198        let dir = tempdir().unwrap();
199        let base = dir.path();
200
201        fs::create_dir_all(base.join("src")).unwrap();
202        fs::write(base.join("src").join("main.rs"), "fn main() {}").unwrap();
203
204        let filters: Vec<String> = vec![];
205        let ignores: Vec<String> = vec!["[".into()]; // Invalid regex pattern
206
207        let result = collect_files(base, &filters, &ignores);
208        assert!(result.is_err());
209        assert!(
210            result
211                .unwrap_err()
212                .to_string()
213                .contains("Invalid ignore pattern")
214        );
215    }
216
217    #[test]
218    fn collect_files_empty_directory() {
219        let dir = tempdir().unwrap();
220        let base = dir.path();
221
222        let filters: Vec<String> = vec![];
223        let ignores: Vec<String> = vec![];
224
225        let files = collect_files(base, &filters, &ignores).unwrap();
226        assert!(files.is_empty());
227    }
228
229    #[test]
230    fn collect_files_no_matching_filters() {
231        let dir = tempdir().unwrap();
232        let base = dir.path();
233
234        fs::write(base.join("README.md"), "# readme").unwrap();
235        fs::write(base.join("script.py"), "print('hello')").unwrap();
236
237        let filters = vec!["rs".to_string()]; // Only Rust files
238        let ignores: Vec<String> = vec![];
239
240        let files = collect_files(base, &filters, &ignores).unwrap();
241        assert!(files.is_empty());
242    }
243
244    #[test]
245    fn collect_files_ignores_config_file() {
246        let dir = tempdir().unwrap();
247        let base = dir.path();
248
249        fs::write(base.join("context-builder.toml"), "[config]").unwrap();
250        fs::write(base.join("other.toml"), "[other]").unwrap();
251
252        let filters: Vec<String> = vec![];
253        let ignores: Vec<String> = vec![];
254
255        let files = collect_files(base, &filters, &ignores).unwrap();
256        let relative_paths = to_rel_paths(files, base);
257
258        assert!(!relative_paths.contains(&"context-builder.toml".to_string()));
259        assert!(relative_paths.contains(&"other.toml".to_string()));
260    }
261
262    #[test]
263    fn confirm_processing_small_count() {
264        // Test that small file counts don't require confirmation
265        let result = confirm_processing(50);
266        assert!(result.is_ok());
267        assert!(result.unwrap());
268    }
269
270    #[test]
271    fn find_latest_file_empty_directory() {
272        let dir = tempdir().unwrap();
273        let result = find_latest_file(dir.path()).unwrap();
274        assert!(result.is_none());
275    }
276
277    #[test]
278    fn find_latest_file_nonexistent_directory() {
279        let dir = tempdir().unwrap();
280        let nonexistent = dir.path().join("nonexistent");
281        let result = find_latest_file(&nonexistent).unwrap();
282        assert!(result.is_none());
283    }
284
285    #[test]
286    fn find_latest_file_single_file() {
287        let dir = tempdir().unwrap();
288        let file_path = dir.path().join("test.txt");
289        fs::write(&file_path, "content").unwrap();
290
291        let result = find_latest_file(dir.path()).unwrap();
292        assert!(result.is_some());
293        assert_eq!(result.unwrap(), file_path);
294    }
295
296    #[test]
297    fn find_latest_file_multiple_files() {
298        let dir = tempdir().unwrap();
299
300        let file1 = dir.path().join("old.txt");
301        let file2 = dir.path().join("new.txt");
302
303        fs::write(&file1, "old content").unwrap();
304        std::thread::sleep(std::time::Duration::from_millis(10));
305        fs::write(&file2, "new content").unwrap();
306
307        let result = find_latest_file(dir.path()).unwrap();
308        assert!(result.is_some());
309        assert_eq!(result.unwrap(), file2);
310    }
311
312    #[test]
313    fn find_latest_file_ignores_directories() {
314        let dir = tempdir().unwrap();
315        let subdir = dir.path().join("subdir");
316        fs::create_dir(&subdir).unwrap();
317
318        let file_path = dir.path().join("test.txt");
319        fs::write(&file_path, "content").unwrap();
320
321        let result = find_latest_file(dir.path()).unwrap();
322        assert!(result.is_some());
323        assert_eq!(result.unwrap(), file_path);
324    }
325
326    #[test]
327    fn test_confirm_processing_requires_user_interaction() {
328        // This test verifies the function signature and basic logic for large file counts
329        // The actual user interaction cannot be tested in unit tests
330
331        // For file counts <= 100, should return Ok(true) without prompting
332        // This is already tested implicitly by the fact that small counts don't prompt
333
334        // For file counts > 100, the function would prompt user input
335        // We can't easily test this without mocking stdin, but we can verify
336        // that the function exists and has the expected signature
337        use std::io::Cursor;
338
339        // Create a mock stdin that simulates user typing "y"
340        let input = b"y\n";
341        let _ = Cursor::new(input);
342
343        // We can't easily override stdin in a unit test without complex setup,
344        // so we'll just verify the function exists and handles small counts
345        let result = confirm_processing(50);
346        assert!(result.is_ok());
347        assert!(result.unwrap());
348    }
349
350    #[test]
351    fn test_confirm_overwrite_function_exists() {
352        // Similar to confirm_processing, this function requires user interaction
353        // We can verify it exists and has the expected signature
354
355        // For testing purposes, we know this function prompts for user input
356        // and returns Ok(true) if user types "y" or "Y", Ok(false) otherwise
357
358        // The function signature should be:
359        // pub fn confirm_overwrite(file_path: &str) -> io::Result<bool>
360
361        // We can't easily test the interactive behavior without mocking stdin,
362        // but we can ensure the function compiles and has the right signature
363        let _: fn(&str) -> std::io::Result<bool> = confirm_overwrite;
364    }
365
366    #[test]
367    fn test_collect_files_handles_permission_errors() {
368        // Test what happens when we can't access a directory
369        // This is harder to test portably, but we can test with invalid patterns
370        let dir = tempdir().unwrap();
371        let base = dir.path();
372
373        // Test with a pattern that might cause issues
374        let filters: Vec<String> = vec![];
375        let ignores: Vec<String> = vec!["[invalid".into()]; // Incomplete bracket
376
377        let result = collect_files(base, &filters, &ignores);
378        assert!(result.is_err());
379    }
380
381    #[test]
382    fn test_find_latest_file_permission_error() {
383        // Test behavior when we can't read directory metadata
384        use std::path::Path;
385
386        // Test with a path that doesn't exist
387        let nonexistent = Path::new("/this/path/should/not/exist/anywhere");
388        let result = find_latest_file(nonexistent);
389
390        // Should return Ok(None) for non-existent directories
391        assert!(result.is_ok());
392        assert!(result.unwrap().is_none());
393    }
394
395    #[test]
396    fn test_collect_files_with_symlinks() {
397        // Test behavior with symbolic links (if supported on platform)
398        let dir = tempdir().unwrap();
399        let base = dir.path();
400
401        // Create a regular file
402        fs::write(base.join("regular.txt"), "content").unwrap();
403
404        // On Unix-like systems, try creating a symlink
405        #[cfg(unix)]
406        {
407            use std::os::unix::fs::symlink;
408            let _ = symlink("regular.txt", base.join("link.txt"));
409        }
410
411        // On Windows, symlinks require special privileges, so skip this part
412        #[cfg(windows)]
413        {
414            // Just create another regular file to test
415            fs::write(base.join("another.txt"), "content2").unwrap();
416        }
417
418        let filters: Vec<String> = vec![];
419        let ignores: Vec<String> = vec![];
420
421        let files = collect_files(base, &filters, &ignores).unwrap();
422        // Should find at least the regular file
423        assert!(!files.is_empty());
424    }
425}