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
24use crate::error::{PathErrorExt, SsgError};
25use std::ffi::OsString;
26use std::{
27    fs,
28    path::{Path, PathBuf},
29};
30
31/// Recursively collects files matching `extension` under `dir`.
32///
33/// Sorted output, no recursion (uses an explicit stack), no depth or
34/// count bounds. Returns `Ok(Vec::new())` if `dir` does not exist.
35///
36/// # Examples
37///
38/// ```rust
39/// use ssg::walk::walk_files;
40/// use tempfile::tempdir;
41/// use std::fs;
42///
43/// let dir = tempdir().unwrap();
44/// fs::write(dir.path().join("a.md"), "").unwrap();
45/// fs::write(dir.path().join("b.txt"), "").unwrap();
46/// let mds = walk_files(dir.path(), "md").unwrap();
47/// assert_eq!(mds.len(), 1);
48/// ```
49pub fn walk_files(
50    dir: &Path,
51    extension: &str,
52) -> Result<Vec<PathBuf>, SsgError> {
53    let mut files = Vec::new();
54    let mut stack = vec![dir.to_path_buf()];
55    while let Some(current) = stack.pop() {
56        if !current.is_dir() {
57            continue;
58        }
59        for entry in fs::read_dir(&current).with_path(&current)? {
60            let entry = entry.with_path(&current)?;
61            let path = entry.path();
62            if path.is_dir() {
63                stack.push(path);
64            } else if path.extension().is_some_and(|e| e == extension) {
65                files.push(path);
66            }
67        }
68    }
69    files.sort();
70    Ok(files)
71}
72
73/// Recursively collects files matching any of `extensions` under `dir`.
74///
75/// Extension matching is **case-insensitive** so `IMG.JPG` and
76/// `img.jpg` are both collected when `extensions` contains `"jpg"`.
77/// Sorted output.
78///
79/// # Examples
80///
81/// ```rust
82/// use ssg::walk::walk_files_multi;
83/// use tempfile::tempdir;
84/// use std::fs;
85///
86/// let dir = tempdir().unwrap();
87/// fs::write(dir.path().join("a.jpg"), "").unwrap();
88/// fs::write(dir.path().join("B.PNG"), "").unwrap();
89/// let imgs = walk_files_multi(dir.path(), &["jpg", "png"]).unwrap();
90/// assert_eq!(imgs.len(), 2);
91/// ```
92pub fn walk_files_multi(
93    dir: &Path,
94    extensions: &[&str],
95) -> Result<Vec<PathBuf>, SsgError> {
96    let mut files = Vec::new();
97    let mut stack = vec![dir.to_path_buf()];
98    while let Some(current) = stack.pop() {
99        if !current.is_dir() {
100            continue;
101        }
102        for entry in fs::read_dir(&current).with_path(&current)? {
103            let entry = entry.with_path(&current)?;
104            let path = entry.path();
105            if path.is_dir() {
106                stack.push(path);
107            } else if let Some(ext) = path.extension() {
108                let ext_lower = ext.to_string_lossy().to_lowercase();
109                if extensions.contains(&ext_lower.as_str()) {
110                    files.push(path);
111                }
112            }
113        }
114    }
115    files.sort();
116    Ok(files)
117}
118
119/// Visits every file with extension `ext` under `dir`, in a deterministic
120/// order, without materialising the file list.
121///
122/// [`walk_files_bounded_depth`] returns a sorted `Vec<PathBuf>`. That is the
123/// right shape when a caller needs the whole list, and the wrong one when
124/// it only needs to see each path once: on a 10,000-page site the vector
125/// alone is ~1.9 MiB and it is held for the entire pass. `emit_sidecars`
126/// measured its peak heap at exactly that figure — the per-document work
127/// never exceeded the list it was iterating (#578).
128///
129/// This walks depth-first, sorting each directory's entries by file name
130/// before descending, so the visit order is identical to sorting the full
131/// list — on every platform, since `read_dir` order is not portable — while
132/// peak memory is one directory listing rather than the tree.
133///
134/// The callback's error stops the walk and is returned as-is.
135///
136/// # Errors
137///
138/// Returns the first I/O error from reading a directory, or the callback's.
139pub fn visit_files_bounded_depth<E, F>(
140    dir: &Path,
141    ext: &str,
142    max_depth: usize,
143    mut visit: F,
144) -> Result<(), E>
145where
146    E: From<std::io::Error>,
147    F: FnMut(&Path) -> Result<(), E>,
148{
149    fn recurse<E, F>(
150        dir: &Path,
151        ext: &str,
152        depth_left: usize,
153        visit: &mut F,
154    ) -> Result<(), E>
155    where
156        E: From<std::io::Error>,
157        F: FnMut(&Path) -> Result<(), E>,
158    {
159        // Names only, not paths. A flat 10,000-file directory is one listing,
160        // so what is held here *is* the walk's footprint: an `OsString` per
161        // entry (~40 bytes) rather than a `PathBuf` (~190), and the full path
162        // is built only for the entry being visited. Measured on the #578
163        // fixture, holding paths here peaked *above* the collected Vec it
164        // replaced.
165        let mut names: Vec<(OsString, bool)> = Vec::new();
166        for entry in fs::read_dir(dir)? {
167            let entry = entry?;
168            let is_dir = entry.file_type()?.is_dir();
169            names.push((entry.file_name(), is_dir));
170        }
171        names.sort_by(|a, b| a.0.cmp(&b.0));
172        for (name, is_dir) in names {
173            let path = dir.join(&name);
174            if is_dir {
175                if depth_left > 0 {
176                    recurse(&path, ext, depth_left - 1, visit)?;
177                }
178            } else if path.extension().is_some_and(|x| x == ext) {
179                visit(&path)?;
180            }
181        }
182        Ok(())
183    }
184    // A missing root is not an error, matching `walk_files_bounded_depth`:
185    // `emit_sidecars` on a project with no content directory returns zero
186    // sidecars, and callers rely on that. An *unreadable* root still errors,
187    // also matching the collecting walk.
188    if !dir.exists() {
189        return Ok(());
190    }
191    recurse(dir, ext, max_depth, &mut visit)
192}
193
194/// Recursively collects files matching `extension`, bounded by depth.
195///
196/// Subdirectories beyond `max_depth` are silently skipped. Used by
197/// content walkers that respect [`crate::MAX_DIR_DEPTH`] as a guard
198/// against pathological symlink loops.
199///
200/// # Examples
201///
202/// ```rust
203/// use ssg::walk::walk_files_bounded_depth;
204/// use tempfile::tempdir;
205/// use std::fs;
206///
207/// let dir = tempdir().unwrap();
208/// fs::write(dir.path().join("a.md"), "").unwrap();
209/// let v = walk_files_bounded_depth(dir.path(), "md", 4).unwrap();
210/// assert_eq!(v.len(), 1);
211/// ```
212pub fn walk_files_bounded_depth(
213    dir: &Path,
214    extension: &str,
215    max_depth: usize,
216) -> Result<Vec<PathBuf>, SsgError> {
217    let mut files = Vec::new();
218    let mut stack: Vec<(PathBuf, usize)> = vec![(dir.to_path_buf(), 0)];
219    while let Some((current, depth)) = stack.pop() {
220        if depth > max_depth || !current.is_dir() {
221            continue;
222        }
223        for entry in fs::read_dir(&current).with_path(&current)? {
224            let entry = entry.with_path(&current)?;
225            let path = entry.path();
226            if path.is_dir() {
227                stack.push((path, depth + 1));
228            } else if path.extension().is_some_and(|e| e == extension) {
229                files.push(path);
230            }
231        }
232    }
233    files.sort();
234    Ok(files)
235}
236
237/// Recursively collects files matching `extension`, capped at
238/// `max_files`. Provides `with_context` on the underlying `read_dir`
239/// failure.
240///
241/// Used by `livereload` (50 000 file cap) and similar fast-path
242/// walkers that need a bounded latency upper bound.
243///
244/// # Examples
245///
246/// ```rust
247/// use ssg::walk::walk_files_bounded_count;
248/// use tempfile::tempdir;
249/// use std::fs;
250///
251/// let dir = tempdir().unwrap();
252/// fs::write(dir.path().join("a.md"), "").unwrap();
253/// fs::write(dir.path().join("b.md"), "").unwrap();
254/// let v = walk_files_bounded_count(dir.path(), "md", 1).unwrap();
255/// assert_eq!(v.len(), 1);
256/// ```
257pub fn walk_files_bounded_count(
258    dir: &Path,
259    extension: &str,
260    max_files: usize,
261) -> Result<Vec<PathBuf>, SsgError> {
262    let mut files = Vec::new();
263    let mut stack = vec![dir.to_path_buf()];
264
265    while let Some(current) = stack.pop() {
266        if files.len() >= max_files {
267            break;
268        }
269        if !current.is_dir() {
270            continue;
271        }
272        let entries = fs::read_dir(&current).with_path(&current)?;
273        for entry in entries {
274            let path = entry.with_path(&current)?.path();
275            if path.is_dir() {
276                stack.push(path);
277            } else if path.extension().is_some_and(|e| e == extension) {
278                files.push(path);
279                if files.len() >= max_files {
280                    break;
281                }
282            }
283        }
284    }
285
286    Ok(files)
287}
288
289#[cfg(test)]
290mod tests {
291    /// The streaming walk visits exactly what the collecting walk returns,
292    /// in exactly that order. Files are created in deliberately
293    /// non-alphabetical order across nested directories so a walk that
294    /// merely reflected `read_dir` order would fail here.
295    #[test]
296    fn streaming_walk_matches_collected_order() {
297        let dir = tempdir().unwrap();
298        let root = dir.path();
299        for rel in [
300            "zeta.md",
301            "alpha.md",
302            "sub/yak.md",
303            "sub/ant.md",
304            "mid.md",
305            "sub/deep/omega.md",
306            "sub/deep/beta.md",
307            "note.txt",
308        ] {
309            let p = root.join(rel);
310            fs::create_dir_all(p.parent().unwrap()).unwrap();
311            fs::write(&p, "x").unwrap();
312        }
313        let collected = walk_files_bounded_depth(root, "md", 8).unwrap();
314        let mut streamed = Vec::new();
315        visit_files_bounded_depth(
316            root,
317            "md",
318            8,
319            |p| -> Result<(), std::io::Error> {
320                streamed.push(p.to_path_buf());
321                Ok(())
322            },
323        )
324        .unwrap();
325        assert_eq!(streamed, collected);
326        assert_eq!(streamed.len(), 7, "the .txt must be excluded");
327    }
328
329    use super::*;
330    use tempfile::tempdir;
331
332    // -------------------------------------------------------------------
333    // walk_files
334    // -------------------------------------------------------------------
335
336    #[test]
337    fn walk_files_returns_empty_for_missing_directory() {
338        let dir = tempdir().unwrap();
339        let result = walk_files(&dir.path().join("missing"), "html").unwrap();
340        assert!(result.is_empty());
341    }
342
343    #[test]
344    fn walk_files_filters_by_extension() {
345        let dir = tempdir().unwrap();
346        fs::write(dir.path().join("a.html"), "").unwrap();
347        fs::write(dir.path().join("b.css"), "").unwrap();
348        fs::write(dir.path().join("c.js"), "").unwrap();
349
350        let result = walk_files(dir.path(), "html").unwrap();
351        assert_eq!(result.len(), 1);
352        assert!(result[0].ends_with("a.html"));
353    }
354
355    #[test]
356    fn walk_files_recurses_into_subdirectories() {
357        let dir = tempdir().unwrap();
358        let nested = dir.path().join("a").join("b");
359        fs::create_dir_all(&nested).unwrap();
360        fs::write(dir.path().join("top.md"), "").unwrap();
361        fs::write(nested.join("deep.md"), "").unwrap();
362
363        let result = walk_files(dir.path(), "md").unwrap();
364        assert_eq!(result.len(), 2);
365    }
366
367    #[test]
368    fn walk_files_skips_extensionless_files() {
369        // `path.extension()` returns `None` for a file with no dot in
370        // its name, short-circuiting `is_some_and` without invoking
371        // the comparison closure — a branch distinct from a
372        // mismatched-extension file like `b.css` (covered above).
373        let dir = tempdir().unwrap();
374        fs::write(dir.path().join("README"), "").unwrap();
375        fs::write(dir.path().join("a.html"), "").unwrap();
376
377        let result = walk_files(dir.path(), "html").unwrap();
378        assert_eq!(result.len(), 1);
379        assert!(result[0].ends_with("a.html"));
380    }
381
382    #[test]
383    fn walk_files_returns_results_sorted() {
384        let dir = tempdir().unwrap();
385        for name in ["zebra.html", "apple.html", "mango.html"] {
386            fs::write(dir.path().join(name), "").unwrap();
387        }
388        let result = walk_files(dir.path(), "html").unwrap();
389        let names: Vec<_> = result
390            .iter()
391            .map(|p| p.file_name().unwrap().to_str().unwrap())
392            .collect();
393        assert_eq!(names, vec!["apple.html", "mango.html", "zebra.html"]);
394    }
395
396    // -------------------------------------------------------------------
397    // walk_files_multi
398    // -------------------------------------------------------------------
399
400    #[test]
401    fn walk_files_multi_collects_each_supplied_extension() {
402        let dir = tempdir().unwrap();
403        for name in ["a.jpg", "b.jpeg", "c.png", "d.gif", "e.txt"] {
404            fs::write(dir.path().join(name), "").unwrap();
405        }
406        let result =
407            walk_files_multi(dir.path(), &["jpg", "jpeg", "png"]).unwrap();
408        assert_eq!(result.len(), 3);
409    }
410
411    #[test]
412    fn walk_files_multi_extension_match_is_case_insensitive() {
413        let dir = tempdir().unwrap();
414        for name in ["A.JPG", "B.PNG", "C.JPEG"] {
415            fs::write(dir.path().join(name), "").unwrap();
416        }
417        let result =
418            walk_files_multi(dir.path(), &["jpg", "jpeg", "png"]).unwrap();
419        assert_eq!(result.len(), 3);
420    }
421
422    #[test]
423    fn walk_files_multi_skips_extensionless_files() {
424        // Exercises the `None` arm of `if let Some(ext) = path.extension()`
425        // — a file with no extension at all is silently skipped.
426        let dir = tempdir().unwrap();
427        fs::write(dir.path().join("README"), "").unwrap();
428        fs::write(dir.path().join("a.jpg"), "").unwrap();
429
430        let result = walk_files_multi(dir.path(), &["jpg"]).unwrap();
431        assert_eq!(result.len(), 1);
432        assert!(result[0].ends_with("a.jpg"));
433    }
434
435    #[test]
436    fn walk_files_multi_returns_empty_for_missing_directory() {
437        let dir = tempdir().unwrap();
438        let result =
439            walk_files_multi(&dir.path().join("missing"), &["jpg"]).unwrap();
440        assert!(result.is_empty());
441    }
442
443    // -------------------------------------------------------------------
444    // walk_files_bounded_depth
445    // -------------------------------------------------------------------
446
447    #[test]
448    fn walk_files_bounded_depth_respects_max_depth() {
449        let dir = tempdir().unwrap();
450        let mut current = dir.path().to_path_buf();
451        for i in 0..5 {
452            current = current.join(format!("d{i}"));
453            fs::create_dir_all(&current).unwrap();
454            fs::write(current.join("p.md"), "").unwrap();
455        }
456        // max_depth=2 → only files at depths 0..=2 should be returned.
457        let result = walk_files_bounded_depth(dir.path(), "md", 2).unwrap();
458        assert!(result.len() <= 3);
459    }
460
461    #[test]
462    fn walk_files_bounded_depth_skips_extensionless_files() {
463        let dir = tempdir().unwrap();
464        fs::write(dir.path().join("README"), "").unwrap();
465        fs::write(dir.path().join("a.md"), "").unwrap();
466
467        let result = walk_files_bounded_depth(dir.path(), "md", 4).unwrap();
468        assert_eq!(result.len(), 1);
469        assert!(result[0].ends_with("a.md"));
470    }
471
472    #[test]
473    fn walk_files_bounded_depth_returns_empty_for_missing_directory() {
474        let dir = tempdir().unwrap();
475        let result =
476            walk_files_bounded_depth(&dir.path().join("missing"), "md", 8)
477                .unwrap();
478        assert!(result.is_empty());
479    }
480
481    // -------------------------------------------------------------------
482    // walk_files_bounded_count
483    // -------------------------------------------------------------------
484
485    #[test]
486    fn walk_files_bounded_count_respects_max_files() {
487        let dir = tempdir().unwrap();
488        for i in 0..10 {
489            fs::write(dir.path().join(format!("f{i}.html")), "").unwrap();
490        }
491        let result = walk_files_bounded_count(dir.path(), "html", 5).unwrap();
492        assert_eq!(result.len(), 5);
493    }
494
495    #[test]
496    fn walk_files_bounded_count_skips_extensionless_files() {
497        let dir = tempdir().unwrap();
498        fs::write(dir.path().join("README"), "").unwrap();
499        fs::write(dir.path().join("a.html"), "").unwrap();
500
501        let result = walk_files_bounded_count(dir.path(), "html", 10).unwrap();
502        assert_eq!(result.len(), 1);
503        assert!(result[0].ends_with("a.html"));
504    }
505
506    #[test]
507    fn walk_files_bounded_count_returns_empty_for_missing_directory() {
508        let dir = tempdir().unwrap();
509        let result =
510            walk_files_bounded_count(&dir.path().join("missing"), "html", 100)
511                .unwrap();
512        assert!(result.is_empty());
513    }
514
515    #[test]
516    fn walk_files_bounded_count_outer_loop_breaks_on_saturation() {
517        // Files spread across two subdirectories so the outer-loop
518        // saturation `break` fires (not the inner one).
519        let dir = tempdir().unwrap();
520        let a = dir.path().join("a");
521        let b = dir.path().join("b");
522        fs::create_dir_all(&a).unwrap();
523        fs::create_dir_all(&b).unwrap();
524        for i in 0..3 {
525            fs::write(a.join(format!("f{i}.html")), "").unwrap();
526            fs::write(b.join(format!("f{i}.html")), "").unwrap();
527        }
528        let result = walk_files_bounded_count(dir.path(), "html", 2).unwrap();
529        assert!(result.len() <= 4);
530    }
531
532    // -------------------------------------------------------------------
533    // read_dir error propagation (unreadable directory, unix-only)
534    // -------------------------------------------------------------------
535
536    #[cfg(unix)]
537    fn with_unreadable_subdir<F: FnOnce(&Path)>(run: F) {
538        use std::os::unix::fs::PermissionsExt;
539
540        let dir = tempdir().unwrap();
541        let locked = dir.path().join("locked");
542        fs::create_dir_all(&locked).unwrap();
543        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
544            .unwrap();
545
546        run(dir.path());
547
548        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
549            .unwrap();
550    }
551
552    #[cfg(unix)]
553    #[test]
554    fn walk_files_errors_on_unreadable_directory() {
555        with_unreadable_subdir(|root| {
556            let result = walk_files(root, "md");
557            assert!(result.is_err(), "unreadable dir must error");
558        });
559    }
560
561    #[cfg(unix)]
562    #[test]
563    fn walk_files_multi_errors_on_unreadable_directory() {
564        with_unreadable_subdir(|root| {
565            let result = walk_files_multi(root, &["md"]);
566            assert!(result.is_err(), "unreadable dir must error");
567        });
568    }
569
570    #[cfg(unix)]
571    #[test]
572    fn walk_files_bounded_depth_errors_on_unreadable_directory() {
573        with_unreadable_subdir(|root| {
574            let result = walk_files_bounded_depth(root, "md", 8);
575            assert!(result.is_err(), "unreadable dir must error");
576        });
577    }
578
579    #[cfg(unix)]
580    #[test]
581    fn walk_files_bounded_count_errors_on_unreadable_directory() {
582        with_unreadable_subdir(|root| {
583            let result = walk_files_bounded_count(root, "md", 10);
584            assert!(result.is_err(), "unreadable dir must error");
585        });
586    }
587}