1use crate::error::{PathErrorExt, SsgError};
34use std::collections::HashSet;
35use std::ffi::OsString;
36use std::{
37 fs,
38 path::{Path, PathBuf},
39};
40
41pub fn walk_files(
60 dir: &Path,
61 extension: &str,
62) -> Result<Vec<PathBuf>, SsgError> {
63 collect_files_matching(dir, usize::MAX, |p| has_extension(p, extension))
64}
65
66pub fn walk_files_multi(
86 dir: &Path,
87 extensions: &[&str],
88) -> Result<Vec<PathBuf>, SsgError> {
89 collect_files_matching(dir, usize::MAX, |p| {
90 has_any_extension_ignore_case(p, extensions)
91 })
92}
93
94pub fn visit_files_bounded_depth<E, F>(
115 dir: &Path,
116 ext: &str,
117 max_depth: usize,
118 mut visit: F,
119) -> Result<(), E>
120where
121 E: From<std::io::Error>,
122 F: FnMut(&Path) -> Result<(), E>,
123{
124 fn recurse<E, F>(
125 dir: &Path,
126 ext: &str,
127 depth_left: usize,
128 visit: &mut F,
129 ) -> Result<(), E>
130 where
131 E: From<std::io::Error>,
132 F: FnMut(&Path) -> Result<(), E>,
133 {
134 let mut names: Vec<(OsString, bool)> = Vec::new();
141 for entry in fs::read_dir(dir)? {
142 let entry = entry?;
143 let is_dir = entry.file_type()?.is_dir();
144 names.push((entry.file_name(), is_dir));
145 }
146 names.sort_by(|a, b| a.0.cmp(&b.0));
147 for (name, is_dir) in names {
148 let path = dir.join(&name);
149 if is_dir {
150 if depth_left > 0 {
151 recurse(&path, ext, depth_left - 1, visit)?;
152 }
153 } else if path.extension().is_some_and(|x| x == ext) {
154 visit(&path)?;
155 }
156 }
157 Ok(())
158 }
159 if !dir.exists() {
164 return Ok(());
165 }
166 recurse(dir, ext, max_depth, &mut visit)
167}
168
169pub fn walk_files_bounded_depth(
188 dir: &Path,
189 extension: &str,
190 max_depth: usize,
191) -> Result<Vec<PathBuf>, SsgError> {
192 collect_files_matching(dir, max_depth, |p| has_extension(p, extension))
193}
194
195pub(crate) fn collect_files_matching(
206 dir: &Path,
207 max_depth: usize,
208 matches: impl Fn(&Path) -> bool,
209) -> Result<Vec<PathBuf>, SsgError> {
210 let mut files = Vec::new();
211 let mut seen = SeenDirs::default();
212 let mut stack: Vec<(PathBuf, usize)> = vec![(dir.to_path_buf(), 0)];
213 while let Some((current, depth)) = stack.pop() {
214 if depth > max_depth || !current.is_dir() || !seen.first_visit(¤t)
215 {
216 continue;
217 }
218 let mut subdirs = Vec::new();
219 for entry in fs::read_dir(¤t).with_path(¤t)? {
220 let path = entry.with_path(¤t)?.path();
221 if path.is_dir() {
222 subdirs.push(path);
223 } else if matches(&path) {
224 files.push(path);
225 }
226 }
227 subdirs.sort_unstable_by(|a, b| b.cmp(a));
229 stack.extend(subdirs.into_iter().map(|p| (p, depth + 1)));
230 }
231 files.sort();
232 Ok(files)
233}
234
235#[derive(Default)]
237struct SeenDirs(HashSet<PathBuf>);
238
239impl SeenDirs {
240 fn first_visit(&mut self, dir: &Path) -> bool {
245 fs::canonicalize(dir).map_or(true, |real| self.0.insert(real))
246 }
247}
248
249fn has_extension(path: &Path, extension: &str) -> bool {
251 path.extension().is_some_and(|e| e == extension)
252}
253
254fn has_any_extension_ignore_case(path: &Path, extensions: &[&str]) -> bool {
256 path.extension().is_some_and(|ext| {
257 let lower = ext.to_string_lossy().to_lowercase();
258 extensions.contains(&lower.as_str())
259 })
260}
261
262pub fn walk_files_bounded_count(
283 dir: &Path,
284 extension: &str,
285 max_files: usize,
286) -> Result<Vec<PathBuf>, SsgError> {
287 let mut files = Vec::new();
288 let mut seen = SeenDirs::default();
289 let mut stack = vec![dir.to_path_buf()];
290
291 while let Some(current) = stack.pop() {
292 if files.len() >= max_files {
293 break;
294 }
295 if !current.is_dir() || !seen.first_visit(¤t) {
296 continue;
297 }
298 let entries = fs::read_dir(¤t).with_path(¤t)?;
299 for entry in entries {
300 let path = entry.with_path(¤t)?.path();
301 if path.is_dir() {
302 stack.push(path);
303 } else if path.extension().is_some_and(|e| e == extension) {
304 files.push(path);
305 if files.len() >= max_files {
306 break;
307 }
308 }
309 }
310 }
311
312 Ok(files)
313}
314
315#[cfg(test)]
316mod tests {
317 #[test]
322 fn streaming_walk_matches_collected_order() {
323 let dir = tempdir().unwrap();
324 let root = dir.path();
325 for rel in [
326 "zeta.md",
327 "alpha.md",
328 "sub/yak.md",
329 "sub/ant.md",
330 "mid.md",
331 "sub/deep/omega.md",
332 "sub/deep/beta.md",
333 "note.txt",
334 ] {
335 let p = root.join(rel);
336 fs::create_dir_all(p.parent().unwrap()).unwrap();
337 fs::write(&p, "x").unwrap();
338 }
339 let collected = walk_files_bounded_depth(root, "md", 8).unwrap();
340 let mut streamed = Vec::new();
341 visit_files_bounded_depth(
342 root,
343 "md",
344 8,
345 |p| -> Result<(), std::io::Error> {
346 streamed.push(p.to_path_buf());
347 Ok(())
348 },
349 )
350 .unwrap();
351 assert_eq!(streamed, collected);
352 assert_eq!(streamed.len(), 7, "the .txt must be excluded");
353 }
354
355 use super::*;
356 use tempfile::tempdir;
357
358 #[test]
363 fn walk_files_returns_empty_for_missing_directory() {
364 let dir = tempdir().unwrap();
365 let result = walk_files(&dir.path().join("missing"), "html").unwrap();
366 assert!(result.is_empty(), "{:?}", result);
367 }
368
369 #[test]
370 fn walk_files_filters_by_extension() {
371 let dir = tempdir().unwrap();
372 fs::write(dir.path().join("a.html"), "").unwrap();
373 fs::write(dir.path().join("b.css"), "").unwrap();
374 fs::write(dir.path().join("c.js"), "").unwrap();
375
376 let result = walk_files(dir.path(), "html").unwrap();
377 assert_eq!(result.len(), 1);
378 assert!(result[0].ends_with("a.html"));
379 }
380
381 #[test]
382 fn walk_files_recurses_into_subdirectories() {
383 let dir = tempdir().unwrap();
384 let nested = dir.path().join("a").join("b");
385 fs::create_dir_all(&nested).unwrap();
386 fs::write(dir.path().join("top.md"), "").unwrap();
387 fs::write(nested.join("deep.md"), "").unwrap();
388
389 let result = walk_files(dir.path(), "md").unwrap();
390 assert_eq!(result.len(), 2);
391 }
392
393 #[test]
394 fn walk_files_skips_extensionless_files() {
395 let dir = tempdir().unwrap();
400 fs::write(dir.path().join("README"), "").unwrap();
401 fs::write(dir.path().join("a.html"), "").unwrap();
402
403 let result = walk_files(dir.path(), "html").unwrap();
404 assert_eq!(result.len(), 1);
405 assert!(result[0].ends_with("a.html"));
406 }
407
408 #[test]
409 fn walk_files_returns_results_sorted() {
410 let dir = tempdir().unwrap();
411 for name in ["zebra.html", "apple.html", "mango.html"] {
412 fs::write(dir.path().join(name), "").unwrap();
413 }
414 let result = walk_files(dir.path(), "html").unwrap();
415 let names: Vec<_> = result
416 .iter()
417 .map(|p| p.file_name().unwrap().to_str().unwrap())
418 .collect();
419 assert_eq!(names, vec!["apple.html", "mango.html", "zebra.html"]);
420 }
421
422 #[test]
427 fn walk_files_multi_collects_each_supplied_extension() {
428 let dir = tempdir().unwrap();
429 for name in ["a.jpg", "b.jpeg", "c.png", "d.gif", "e.txt"] {
430 fs::write(dir.path().join(name), "").unwrap();
431 }
432 let result =
433 walk_files_multi(dir.path(), &["jpg", "jpeg", "png"]).unwrap();
434 assert_eq!(result.len(), 3);
435 }
436
437 #[test]
438 fn walk_files_multi_extension_match_is_case_insensitive() {
439 let dir = tempdir().unwrap();
440 for name in ["A.JPG", "B.PNG", "C.JPEG"] {
441 fs::write(dir.path().join(name), "").unwrap();
442 }
443 let result =
444 walk_files_multi(dir.path(), &["jpg", "jpeg", "png"]).unwrap();
445 assert_eq!(result.len(), 3);
446 }
447
448 #[test]
449 fn walk_files_multi_skips_extensionless_files() {
450 let dir = tempdir().unwrap();
453 fs::write(dir.path().join("README"), "").unwrap();
454 fs::write(dir.path().join("a.jpg"), "").unwrap();
455
456 let result = walk_files_multi(dir.path(), &["jpg"]).unwrap();
457 assert_eq!(result.len(), 1);
458 assert!(result[0].ends_with("a.jpg"));
459 }
460
461 #[test]
462 fn walk_files_multi_returns_empty_for_missing_directory() {
463 let dir = tempdir().unwrap();
464 let result =
465 walk_files_multi(&dir.path().join("missing"), &["jpg"]).unwrap();
466 assert!(result.is_empty(), "{:?}", result);
467 }
468
469 #[test]
474 fn walk_files_bounded_depth_respects_max_depth() {
475 let dir = tempdir().unwrap();
476 let mut current = dir.path().to_path_buf();
477 for i in 0..5 {
478 current = current.join(format!("d{i}"));
479 fs::create_dir_all(¤t).unwrap();
480 fs::write(current.join("p.md"), "").unwrap();
481 }
482 let result = walk_files_bounded_depth(dir.path(), "md", 2).unwrap();
484 assert!(result.len() <= 3);
485 }
486
487 #[test]
488 fn walk_files_bounded_depth_skips_extensionless_files() {
489 let dir = tempdir().unwrap();
490 fs::write(dir.path().join("README"), "").unwrap();
491 fs::write(dir.path().join("a.md"), "").unwrap();
492
493 let result = walk_files_bounded_depth(dir.path(), "md", 4).unwrap();
494 assert_eq!(result.len(), 1);
495 assert!(result[0].ends_with("a.md"));
496 }
497
498 #[test]
499 fn walk_files_bounded_depth_returns_empty_for_missing_directory() {
500 let dir = tempdir().unwrap();
501 let result =
502 walk_files_bounded_depth(&dir.path().join("missing"), "md", 8)
503 .unwrap();
504 assert!(result.is_empty(), "{:?}", result);
505 }
506
507 #[test]
512 fn walk_files_bounded_count_respects_max_files() {
513 let dir = tempdir().unwrap();
514 for i in 0..10 {
515 fs::write(dir.path().join(format!("f{i}.html")), "").unwrap();
516 }
517 let result = walk_files_bounded_count(dir.path(), "html", 5).unwrap();
518 assert_eq!(result.len(), 5);
519 }
520
521 #[test]
522 fn walk_files_bounded_count_skips_extensionless_files() {
523 let dir = tempdir().unwrap();
524 fs::write(dir.path().join("README"), "").unwrap();
525 fs::write(dir.path().join("a.html"), "").unwrap();
526
527 let result = walk_files_bounded_count(dir.path(), "html", 10).unwrap();
528 assert_eq!(result.len(), 1);
529 assert!(result[0].ends_with("a.html"));
530 }
531
532 #[test]
533 fn walk_files_bounded_count_returns_empty_for_missing_directory() {
534 let dir = tempdir().unwrap();
535 let result =
536 walk_files_bounded_count(&dir.path().join("missing"), "html", 100)
537 .unwrap();
538 assert!(result.is_empty(), "{:?}", result);
539 }
540
541 #[test]
542 fn walk_files_bounded_count_outer_loop_breaks_on_saturation() {
543 let dir = tempdir().unwrap();
546 let a = dir.path().join("a");
547 let b = dir.path().join("b");
548 fs::create_dir_all(&a).unwrap();
549 fs::create_dir_all(&b).unwrap();
550 for i in 0..3 {
551 fs::write(a.join(format!("f{i}.html")), "").unwrap();
552 fs::write(b.join(format!("f{i}.html")), "").unwrap();
553 }
554 let result = walk_files_bounded_count(dir.path(), "html", 2).unwrap();
555 assert!(result.len() <= 4);
556 }
557
558 #[cfg(unix)]
565 fn tree_with_symlink_loops() -> tempfile::TempDir {
566 use std::os::unix::fs::symlink;
567
568 let dir = tempdir().unwrap();
569 let root = dir.path();
570 fs::create_dir_all(root.join("sub")).unwrap();
571 fs::write(root.join("b.md"), "").unwrap();
572 fs::write(root.join("sub/a.md"), "").unwrap();
573 symlink(root, root.join("sub/up1")).unwrap();
574 symlink(root, root.join("sub/up2")).unwrap();
575 dir
576 }
577
578 #[cfg(unix)]
580 fn relative_within_deadline(
581 root: &Path,
582 walk: fn(&Path) -> Vec<PathBuf>,
583 ) -> Vec<PathBuf> {
584 let (tx, rx) = std::sync::mpsc::channel();
585 let walk_root = root.to_path_buf();
586 let _ = std::thread::spawn(move || {
587 let _ = tx.send(walk(&walk_root));
588 });
589 let found = rx
590 .recv_timeout(std::time::Duration::from_secs(10))
591 .expect("walk did not finish within 10 s");
592 found
593 .iter()
594 .map(|p| p.strip_prefix(root).unwrap().to_path_buf())
595 .collect()
596 }
597
598 #[cfg(unix)]
599 #[test]
600 fn collecting_walkers_enter_each_directory_once() {
601 let dir = tree_with_symlink_loops();
602 let expected = ["b.md", "sub/a.md"].map(PathBuf::from);
603 let walkers: [fn(&Path) -> Vec<PathBuf>; 4] = [
604 |r| walk_files(r, "md").unwrap(),
605 |r| walk_files_multi(r, &["md"]).unwrap(),
606 |r| walk_files_bounded_depth(r, "md", 128).unwrap(),
607 |r| {
608 let mut v = walk_files_bounded_count(r, "md", 1000).unwrap();
609 v.sort();
610 v
611 },
612 ];
613 for walk in walkers {
614 assert_eq!(relative_within_deadline(dir.path(), walk), expected);
615 }
616 }
617
618 #[cfg(unix)]
619 #[test]
620 fn collecting_walk_still_follows_a_symlinked_directory() {
621 use std::os::unix::fs::symlink;
622
623 let outside = tempdir().unwrap();
624 fs::write(outside.path().join("shared.md"), "").unwrap();
625 let dir = tempdir().unwrap();
626 symlink(outside.path(), dir.path().join("linked")).unwrap();
627
628 let found = walk_files(dir.path(), "md").unwrap();
629 assert_eq!(found, [dir.path().join("linked/shared.md")]);
630 }
631
632 #[cfg(unix)]
637 fn with_unreadable_subdir<F: FnOnce(&Path)>(run: F) {
638 use std::os::unix::fs::PermissionsExt;
639
640 let dir = tempdir().unwrap();
641 let locked = dir.path().join("locked");
642 fs::create_dir_all(&locked).unwrap();
643 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
644 .unwrap();
645
646 run(dir.path());
647
648 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
649 .unwrap();
650 }
651
652 #[cfg(unix)]
653 #[test]
654 fn walk_files_errors_on_unreadable_directory() {
655 with_unreadable_subdir(|root| {
656 let result = walk_files(root, "md");
657 assert!(result.is_err(), "unreadable dir must error");
658 });
659 }
660
661 #[cfg(unix)]
662 #[test]
663 fn walk_files_multi_errors_on_unreadable_directory() {
664 with_unreadable_subdir(|root| {
665 let result = walk_files_multi(root, &["md"]);
666 assert!(result.is_err(), "unreadable dir must error");
667 });
668 }
669
670 #[cfg(unix)]
671 #[test]
672 fn walk_files_bounded_depth_errors_on_unreadable_directory() {
673 with_unreadable_subdir(|root| {
674 let result = walk_files_bounded_depth(root, "md", 8);
675 assert!(result.is_err(), "unreadable dir must error");
676 });
677 }
678
679 #[cfg(unix)]
680 #[test]
681 fn walk_files_bounded_count_errors_on_unreadable_directory() {
682 with_unreadable_subdir(|root| {
683 let result = walk_files_bounded_count(root, "md", 10);
684 assert!(result.is_err(), "unreadable dir must error");
685 });
686 }
687}