Skip to main content

scute_core/
files.rs

1use std::path::{Path, PathBuf};
2use std::{fmt, io};
3
4use ignore::WalkBuilder;
5
6pub fn walk_source_files(
7    dir: &Path,
8    skip_ignored: bool,
9    exclude: &[String],
10) -> impl Iterator<Item = ignore::DirEntry> {
11    let mut builder = WalkBuilder::new(dir);
12    builder.standard_filters(skip_ignored);
13
14    if !exclude.is_empty() {
15        let mut overrides = ignore::overrides::OverrideBuilder::new(dir);
16        for pattern in exclude {
17            overrides.add(&format!("!{pattern}")).ok();
18        }
19        if let Ok(built) = overrides.build() {
20            builder.overrides(built);
21        }
22    }
23
24    builder
25        .build()
26        .filter_map(Result::ok)
27        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
28}
29
30/// Validate and canonicalize focus files.
31///
32/// Checks that each file has a supported extension and exists on disk.
33/// Returns canonical paths on success, or errored evaluations on failure.
34///
35/// # Errors
36///
37/// Returns errored [`Evaluation`](crate::Evaluation)s when any file has an
38/// unsupported extension or cannot be read.
39pub fn validate_focus_files(
40    files: &[PathBuf],
41    supported_extensions: &[&str],
42    supported_msg: &str,
43) -> Result<Vec<PathBuf>, Vec<crate::Evaluation>> {
44    let mut canonical = Vec::new();
45    let mut errors = Vec::new();
46    for path in files {
47        match validate_focus_file(path, supported_extensions, supported_msg) {
48            Ok(p) => canonical.push(p),
49            Err(e) => errors.push(e),
50        }
51    }
52    if errors.is_empty() {
53        Ok(canonical)
54    } else {
55        Err(errors)
56    }
57}
58
59fn validate_focus_file(
60    path: &Path,
61    supported_extensions: &[&str],
62    supported_msg: &str,
63) -> Result<PathBuf, crate::Evaluation> {
64    let has_supported_ext = path
65        .extension()
66        .and_then(|e| e.to_str())
67        .is_some_and(|ext| supported_extensions.contains(&ext));
68    if !has_supported_ext {
69        return Err(crate::Evaluation::errored(
70            path.display().to_string(),
71            crate::ExecutionError {
72                code: "unsupported_language".into(),
73                message: format!("unsupported file type: {}", path.display()),
74                recovery: supported_msg.into(),
75            },
76        ));
77    }
78    path.canonicalize().map_err(|_| {
79        crate::Evaluation::errored(
80            path.display().to_string(),
81            crate::ExecutionError {
82                code: "unreadable_file".into(),
83                message: format!("cannot read file: {}", path.display()),
84                recovery: "check that the file exists and is readable".into(),
85            },
86        )
87    })
88}
89
90/// Validate and canonicalize a directory path.
91///
92/// # Errors
93///
94/// Returns `InvalidPath` if the path doesn't exist, isn't a directory,
95/// or cannot be canonicalized.
96pub fn validate_source_dir(source_dir: &Path) -> Result<PathBuf, InvalidPath> {
97    let canonical = source_dir.canonicalize().map_err(|e| InvalidPath {
98        path: source_dir.display().to_string(),
99        kind: InvalidPathKind::InvalidDirectory(e),
100    })?;
101    if !canonical.is_dir() {
102        return Err(InvalidPath {
103            path: source_dir.display().to_string(),
104            kind: InvalidPathKind::ExpectedDirectory,
105        });
106    }
107    Ok(canonical)
108}
109
110/// A path that couldn't be resolved.
111#[derive(Debug)]
112pub struct InvalidPath {
113    pub path: String,
114    pub kind: InvalidPathKind,
115}
116
117impl fmt::Display for InvalidPath {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match &self.kind {
120            InvalidPathKind::UnsupportedExtension => {
121                write!(f, "unsupported file type: {}", self.path)
122            }
123            InvalidPathKind::Unreadable(e) => write!(f, "cannot read {}: {e}", self.path),
124            InvalidPathKind::ExpectedDirectory => {
125                write!(f, "not a directory: {}", self.path)
126            }
127            InvalidPathKind::InvalidDirectory(e) => {
128                write!(f, "cannot read directory {}: {e}", self.path)
129            }
130        }
131    }
132}
133
134impl std::error::Error for InvalidPath {}
135
136#[derive(Debug)]
137pub enum InvalidPathKind {
138    UnsupportedExtension,
139    Unreadable(io::Error),
140    ExpectedDirectory,
141    InvalidDirectory(io::Error),
142}
143
144/// Returns `paths` as-is if non-empty, otherwise a single-element vec
145/// containing `default`.
146#[must_use]
147pub fn paths_or_default(paths: Vec<PathBuf>, default: &Path) -> Vec<PathBuf> {
148    if paths.is_empty() {
149        vec![default.to_path_buf()]
150    } else {
151        paths
152    }
153}
154
155/// Resolve mixed file/directory paths into a flat list of source files.
156///
157/// Each path is classified: files are validated individually (extension +
158/// readability), directories are walked to discover matching files.
159///
160/// Fails fast on the first invalid path.
161///
162/// # Errors
163///
164/// Returns `InvalidPath` if any path has an unsupported extension,
165/// doesn't exist, or is an unreadable directory.
166pub fn resolve_paths(
167    paths: &[PathBuf],
168    supported_extensions: &[&str],
169    exclude: &[String],
170) -> Result<Vec<PathBuf>, InvalidPath> {
171    let mut resolved = Vec::new();
172    for path in paths {
173        if path.is_dir() {
174            let dir = validate_source_dir(path)?;
175            resolved.extend(discover_files(&dir, supported_extensions, exclude));
176        } else {
177            resolved.push(resolve_file(path, supported_extensions)?);
178        }
179    }
180    Ok(resolved)
181}
182
183fn discover_files(dir: &Path, extensions: &[&str], exclude: &[String]) -> Vec<PathBuf> {
184    let mut files: Vec<PathBuf> = walk_source_files(dir, true, exclude)
185        .filter(|e| has_extension(e.path(), extensions))
186        .map(ignore::DirEntry::into_path)
187        .collect();
188    files.sort();
189    files
190}
191
192fn resolve_file(path: &Path, supported_extensions: &[&str]) -> Result<PathBuf, InvalidPath> {
193    if !has_extension(path, supported_extensions) {
194        return Err(InvalidPath {
195            path: path.display().to_string(),
196            kind: InvalidPathKind::UnsupportedExtension,
197        });
198    }
199    path.canonicalize().map_err(|e| InvalidPath {
200        path: path.display().to_string(),
201        kind: InvalidPathKind::Unreadable(e),
202    })
203}
204
205fn has_extension(path: &Path, extensions: &[&str]) -> bool {
206    path.extension()
207        .and_then(|e| e.to_str())
208        .is_some_and(|ext| extensions.contains(&ext))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use googletest::prelude::*;
215    use std::fs;
216
217    #[test]
218    fn validates_existing_directory() {
219        let dir = tempfile::tempdir().unwrap();
220
221        let result = validate_source_dir(dir.path());
222
223        assert!(result.is_ok());
224        assert_eq!(result.unwrap(), dir.path().canonicalize().unwrap());
225    }
226
227    #[test]
228    fn rejects_nonexistent_directory() {
229        let result = validate_source_dir(Path::new("/does/not/exist"));
230
231        assert_that!(
232            result,
233            err(field!(
234                InvalidPath.kind,
235                pat!(InvalidPathKind::InvalidDirectory(_))
236            ))
237        );
238    }
239
240    fn walk(dir: &Path, exclude: &[String]) -> Vec<PathBuf> {
241        walk_source_files(dir, false, exclude)
242            .map(ignore::DirEntry::into_path)
243            .collect()
244    }
245
246    #[test]
247    fn walks_only_files() {
248        let dir = tempfile::tempdir().unwrap();
249        fs::write(dir.path().join("a.rs"), "").unwrap();
250        fs::create_dir(dir.path().join("sub")).unwrap();
251        fs::write(dir.path().join("sub/b.rs"), "").unwrap();
252
253        assert_eq!(walk(dir.path(), &[]).len(), 2);
254    }
255
256    #[test]
257    fn focus_files_rejects_unsupported_extension() {
258        let dir = tempfile::tempdir().unwrap();
259        let py_file = dir.path().join("script.py");
260        fs::write(&py_file, "").unwrap();
261
262        let result = validate_focus_files(&[py_file], &["rs"], "only Rust files are supported");
263
264        let errors = result.unwrap_err();
265        assert_eq!(errors.len(), 1);
266        assert!(errors[0].is_error());
267        assert!(errors[0].target.contains("script.py"));
268    }
269
270    #[test]
271    fn focus_files_rejects_nonexistent_file() {
272        let missing = PathBuf::from("/does/not/exist.rs");
273
274        let result = validate_focus_files(&[missing], &["rs"], "only Rust files are supported");
275
276        let errors = result.unwrap_err();
277        assert_eq!(errors.len(), 1);
278        assert!(errors[0].is_error());
279    }
280
281    #[test]
282    fn focus_files_canonicalizes_valid_paths() {
283        let dir = tempfile::tempdir().unwrap();
284        let file = dir.path().join("real.rs");
285        fs::write(&file, "").unwrap();
286
287        let result = validate_focus_files(
288            std::slice::from_ref(&file),
289            &["rs"],
290            "only Rust files are supported",
291        );
292
293        let paths = result.unwrap();
294        assert_eq!(paths.len(), 1);
295        assert_eq!(paths[0], file.canonicalize().unwrap());
296    }
297
298    #[test]
299    fn focus_files_returns_empty_for_no_files() {
300        let result = validate_focus_files(&[], &["rs"], "only Rust files are supported");
301
302        assert_that!(result, ok(is_empty()));
303    }
304
305    #[test]
306    fn excludes_matching_patterns() {
307        let dir = tempfile::tempdir().unwrap();
308        fs::write(dir.path().join("keep.rs"), "").unwrap();
309        fs::create_dir(dir.path().join("vendor")).unwrap();
310        fs::write(dir.path().join("vendor/skip.rs"), "").unwrap();
311
312        let files = walk(dir.path(), &["vendor/**".into()]);
313
314        assert_eq!(files.len(), 1);
315        assert!(files[0].ends_with("keep.rs"));
316    }
317
318    #[test]
319    fn rejects_file_passed_as_directory() {
320        let dir = tempfile::tempdir().unwrap();
321        let file = dir.path().join("not_a_dir.rs");
322        fs::write(&file, "").unwrap();
323
324        let result = validate_source_dir(&file);
325
326        assert_that!(
327            result,
328            err(field!(
329                InvalidPath.kind,
330                pat!(InvalidPathKind::ExpectedDirectory)
331            ))
332        );
333    }
334
335    mod resolve_paths_tests {
336        use super::*;
337        use scute_test_utils::TestDir;
338
339        #[test]
340        fn resolves_single_file() {
341            let dir = TestDir::new().file("main.rs");
342
343            let result = resolve_paths(&[dir.path("main.rs")], &["rs"], &[]);
344
345            assert_that!(result, ok(len(eq(1))));
346        }
347
348        #[test]
349        fn resolves_directory() {
350            let dir = TestDir::new().file("a.rs").file("b.rs");
351
352            let result = resolve_paths(&[dir.root()], &["rs"], &[]);
353
354            assert_that!(result, ok(len(eq(2))));
355        }
356
357        #[test]
358        fn resolves_mixed_files_and_directories() {
359            let dir = TestDir::new().file("main.rs").file("src/lib.rs");
360
361            let result = resolve_paths(&[dir.path("main.rs"), dir.path("src")], &["rs"], &[]);
362
363            assert_that!(result, ok(len(eq(2))));
364        }
365
366        #[test]
367        fn returns_empty_for_empty_input() {
368            let result = resolve_paths(&[], &["rs"], &[]);
369
370            assert_that!(result, ok(is_empty()));
371        }
372
373        #[test]
374        fn fails_fast_on_first_invalid_path() {
375            let dir = TestDir::new().file("good.rs");
376            let bad = PathBuf::from("/nonexistent/file.rs");
377
378            let result = resolve_paths(&[bad.clone(), dir.path("good.rs")], &["rs"], &[]);
379
380            let err = result.unwrap_err();
381            assert_eq!(err.path, bad.display().to_string());
382        }
383
384        #[test]
385        fn forwards_exclude_patterns_to_directory_walk() {
386            let dir = TestDir::new().file("keep.rs").file("gen/skip.rs");
387
388            let result = resolve_paths(&[dir.root()], &["rs"], &["gen/**".into()]);
389
390            let files = result.unwrap();
391            assert_eq!(files.len(), 1);
392            assert!(files[0].ends_with("keep.rs"));
393        }
394
395        #[test]
396        fn rejects_unsupported_extension() {
397            let dir = TestDir::new().file("script.py");
398
399            let result = resolve_paths(&[dir.path("script.py")], &["rs"], &[]);
400
401            assert_that!(
402                result,
403                err(field!(
404                    InvalidPath.kind,
405                    pat!(InvalidPathKind::UnsupportedExtension)
406                ))
407            );
408        }
409
410        #[test]
411        fn preserves_os_error_for_unreadable_file() {
412            let result = resolve_paths(&[PathBuf::from("/nonexistent/file.rs")], &["rs"], &[]);
413
414            assert_that!(
415                result,
416                err(field!(
417                    InvalidPath.kind,
418                    pat!(InvalidPathKind::Unreadable(_))
419                ))
420            );
421        }
422    }
423}