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
30pub 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
90pub 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#[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#[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
155pub 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}