Skip to main content

ssg/core/
walk.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Shared bounded directory walkers.
5//!
6//! Replaces the per-plugin `collect_*_files` helpers that previously
7//! lived in nearly every module. Each function performs an iterative
8//! (no-recursion) walk with optional bounds and returns a sorted
9//! `Vec<PathBuf>` for deterministic test output.
10//!
11//! ## Variants
12//!
13//! - [`walk_files`] — single-extension filter, no bounds.
14//! - [`walk_files_multi`] — multiple extensions (case-insensitive).
15//! - [`walk_files_bounded_depth`] — single extension with a maximum
16//!   directory depth (for content trees).
17//! - [`walk_files_bounded_count`] — single extension with a maximum
18//!   file-count cap (for live-reload / batch I/O fast-paths).
19//!
20//! All variants return `Ok(Vec::new())` when the root directory does
21//! not exist or is not a directory — matching the convention used by
22//! every previous local collector in the crate.
23//!
24//! ## Symlinks
25//!
26//! The collecting walkers follow symlinked directories, but enter each
27//! real directory (by canonical path) at most once. A link back to an
28//! ancestor is therefore skipped instead of walked again, so a symlink
29//! loop can neither repeat files nor make the walk branch without end.
30//! Subdirectories are visited in sorted order, so when one directory is
31//! reachable by two names the name kept is the same on every platform.
32
33use crate::error::{PathErrorExt, SsgError};
34use std::collections::HashSet;
35use std::ffi::OsString;
36use std::{
37    fs,
38    path::{Path, PathBuf},
39};
40
41/// Recursively collects files matching `extension` under `dir`.
42///
43/// Sorted output, no recursion (uses an explicit stack), no depth or
44/// count bounds. Returns `Ok(Vec::new())` if `dir` does not exist.
45///
46/// # Examples
47///
48/// ```rust
49/// use ssg::walk::walk_files;
50/// use tempfile::tempdir;
51/// use std::fs;
52///
53/// let dir = tempdir().unwrap();
54/// fs::write(dir.path().join("a.md"), "").unwrap();
55/// fs::write(dir.path().join("b.txt"), "").unwrap();
56/// let mds = walk_files(dir.path(), "md").unwrap();
57/// assert_eq!(mds.len(), 1);
58/// ```
59pub 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
66/// Recursively collects files matching any of `extensions` under `dir`.
67///
68/// Extension matching is **case-insensitive** so `IMG.JPG` and
69/// `img.jpg` are both collected when `extensions` contains `"jpg"`.
70/// Sorted output.
71///
72/// # Examples
73///
74/// ```rust
75/// use ssg::walk::walk_files_multi;
76/// use tempfile::tempdir;
77/// use std::fs;
78///
79/// let dir = tempdir().unwrap();
80/// fs::write(dir.path().join("a.jpg"), "").unwrap();
81/// fs::write(dir.path().join("B.PNG"), "").unwrap();
82/// let imgs = walk_files_multi(dir.path(), &["jpg", "png"]).unwrap();
83/// assert_eq!(imgs.len(), 2);
84/// ```
85pub 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
94/// Visits every file with extension `ext` under `dir`, in a deterministic
95/// order, without materialising the file list.
96///
97/// [`walk_files_bounded_depth`] returns a sorted `Vec<PathBuf>`. That is the
98/// right shape when a caller needs the whole list, and the wrong one when
99/// it only needs to see each path once: on a 10,000-page site the vector
100/// alone is ~1.9 MiB and it is held for the entire pass. `emit_sidecars`
101/// measured its peak heap at exactly that figure — the per-document work
102/// never exceeded the list it was iterating (#578).
103///
104/// This walks depth-first, sorting each directory's entries by file name
105/// before descending, so the visit order is identical to sorting the full
106/// list — on every platform, since `read_dir` order is not portable — while
107/// peak memory is one directory listing rather than the tree.
108///
109/// The callback's error stops the walk and is returned as-is.
110///
111/// # Errors
112///
113/// Returns the first I/O error from reading a directory, or the callback's.
114pub 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        // Names only, not paths. A flat 10,000-file directory is one listing,
135        // so what is held here *is* the walk's footprint: an `OsString` per
136        // entry (~40 bytes) rather than a `PathBuf` (~190), and the full path
137        // is built only for the entry being visited. Measured on the #578
138        // fixture, holding paths here peaked *above* the collected Vec it
139        // replaced.
140        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    // A missing root is not an error, matching `walk_files_bounded_depth`:
160    // `emit_sidecars` on a project with no content directory returns zero
161    // sidecars, and callers rely on that. An *unreadable* root still errors,
162    // also matching the collecting walk.
163    if !dir.exists() {
164        return Ok(());
165    }
166    recurse(dir, ext, max_depth, &mut visit)
167}
168
169/// Recursively collects files matching `extension`, bounded by depth.
170///
171/// Subdirectories beyond `max_depth` are silently skipped. Used by
172/// content walkers that respect [`crate::MAX_DIR_DEPTH`] as a guard
173/// against pathological symlink loops.
174///
175/// # Examples
176///
177/// ```rust
178/// use ssg::walk::walk_files_bounded_depth;
179/// use tempfile::tempdir;
180/// use std::fs;
181///
182/// let dir = tempdir().unwrap();
183/// fs::write(dir.path().join("a.md"), "").unwrap();
184/// let v = walk_files_bounded_depth(dir.path(), "md", 4).unwrap();
185/// assert_eq!(v.len(), 1);
186/// ```
187pub 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
195/// Collects every file under `dir` for which `matches` returns `true`,
196/// descending at most `max_depth` directories below `dir`.
197///
198/// The one walk behind [`walk_files`], [`walk_files_multi`] and
199/// [`walk_files_bounded_depth`]: iterative, sorted output, and each real
200/// directory entered at most once (see the module's *Symlinks* notes).
201///
202/// # Errors
203///
204/// Returns the first I/O error from reading a directory or an entry.
205pub(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(&current)
215        {
216            continue;
217        }
218        let mut subdirs = Vec::new();
219        for entry in fs::read_dir(&current).with_path(&current)? {
220            let path = entry.with_path(&current)?.path();
221            if path.is_dir() {
222                subdirs.push(path);
223            } else if matches(&path) {
224                files.push(path);
225            }
226        }
227        // Reverse-sorted onto the stack, so subdirectories pop in order.
228        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/// The real directories a walk has already entered.
236#[derive(Default)]
237struct SeenDirs(HashSet<PathBuf>);
238
239impl SeenDirs {
240    /// Records `dir` and reports whether this is its first visit.
241    ///
242    /// A directory whose canonical path cannot be resolved is entered,
243    /// so that reading it reports the underlying error as before.
244    fn first_visit(&mut self, dir: &Path) -> bool {
245        fs::canonicalize(dir).map_or(true, |real| self.0.insert(real))
246    }
247}
248
249/// Whether `path` has exactly the extension `extension`.
250fn has_extension(path: &Path, extension: &str) -> bool {
251    path.extension().is_some_and(|e| e == extension)
252}
253
254/// Whether `path`'s extension, lowercased, is one of `extensions`.
255fn 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
262/// Recursively collects files matching `extension`, capped at
263/// `max_files`. Provides `with_context` on the underlying `read_dir`
264/// failure.
265///
266/// Used by `livereload` (50 000 file cap) and similar fast-path
267/// walkers that need a bounded latency upper bound.
268///
269/// # Examples
270///
271/// ```rust
272/// use ssg::walk::walk_files_bounded_count;
273/// use tempfile::tempdir;
274/// use std::fs;
275///
276/// let dir = tempdir().unwrap();
277/// fs::write(dir.path().join("a.md"), "").unwrap();
278/// fs::write(dir.path().join("b.md"), "").unwrap();
279/// let v = walk_files_bounded_count(dir.path(), "md", 1).unwrap();
280/// assert_eq!(v.len(), 1);
281/// ```
282pub 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(&current) {
296            continue;
297        }
298        let entries = fs::read_dir(&current).with_path(&current)?;
299        for entry in entries {
300            let path = entry.with_path(&current)?.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    /// The streaming walk visits exactly what the collecting walk returns,
318    /// in exactly that order. Files are created in deliberately
319    /// non-alphabetical order across nested directories so a walk that
320    /// merely reflected `read_dir` order would fail here.
321    #[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    // -------------------------------------------------------------------
359    // walk_files
360    // -------------------------------------------------------------------
361
362    #[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        // `path.extension()` returns `None` for a file with no dot in
396        // its name, short-circuiting `is_some_and` without invoking
397        // the comparison closure — a branch distinct from a
398        // mismatched-extension file like `b.css` (covered above).
399        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    // -------------------------------------------------------------------
423    // walk_files_multi
424    // -------------------------------------------------------------------
425
426    #[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        // Exercises the `None` arm of `if let Some(ext) = path.extension()`
451        // — a file with no extension at all is silently skipped.
452        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    // -------------------------------------------------------------------
470    // walk_files_bounded_depth
471    // -------------------------------------------------------------------
472
473    #[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(&current).unwrap();
480            fs::write(current.join("p.md"), "").unwrap();
481        }
482        // max_depth=2 → only files at depths 0..=2 should be returned.
483        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    // -------------------------------------------------------------------
508    // walk_files_bounded_count
509    // -------------------------------------------------------------------
510
511    #[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        // Files spread across two subdirectories so the outer-loop
544        // saturation `break` fires (not the inner one).
545        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    // -------------------------------------------------------------------
559    // symlink loops (unix-only)
560    // -------------------------------------------------------------------
561
562    /// A tree with one file per level and two links back to the root,
563    /// so a walk that follows links blindly branches at every level.
564    #[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    /// Runs `walk` on a thread and fails if it does not return promptly.
579    #[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    // -------------------------------------------------------------------
633    // read_dir error propagation (unreadable directory, unix-only)
634    // -------------------------------------------------------------------
635
636    #[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}