Skip to main content

rustledger_plugin/
wasm_dir_scan.rs

1//! Shared `.wasm` directory scanner.
2//!
3//! Both [`crate::PluginManager::register_wasm_dir`] and
4//! `rustledger_importer::ImporterRegistry::register_wasm_dir` walk a
5//! directory looking for `.wasm` files to register. (The latter isn't
6//! an intra-doc link because that crate sits downstream of this one
7//! in the dep graph — link from text on the importer side instead.)
8//! The two used to
9//! be near-copies — same listing, filtering, sorting, and per-entry
10//! error handling logic, only the error wrapping and per-file load fn
11//! differed. This module factors out the shared listing-and-filtering
12//! step.
13//!
14//! # What's shared
15//!
16//! - `read_dir` + iteration
17//! - `is_file()` + case-insensitive `.wasm` extension filter
18//! - Per-entry I/O errors collected (not propagated, so a single
19//!   permission-denied inode doesn't abort the scan)
20//! - Lexicographic sort by path so load order is deterministic across
21//!   filesystems and platforms
22//!
23//! # What stays caller-side
24//!
25//! - **Dir-level `read_dir` error wrapping**: each caller has its own
26//!   error type and preferred context message ("failed to read plugin
27//!   dir ..." vs `WasmImporterError::Io`).
28//! - **Per-file load**: the importer calls
29//!   `register_wasm_from_path`, the plugin manager calls `load`.
30//!   Their return values and error shapes are different (importer
31//!   returns the module's declared name; plugin returns an index +
32//!   uses the registered Plugin's `name()`).
33//!
34//! Both kept caller-side because forcing them through a generic
35//! adapter would add more code than it saved.
36
37use std::path::{Path, PathBuf};
38
39/// Outcome of [`collect_wasm_paths`].
40///
41/// `sorted_paths` is what callers iterate to do the actual loading;
42/// `entry_failures` carries per-entry I/O errors (broken-during-iter
43/// inodes) that callers fold into their own failure-tracking shape.
44#[derive(Debug, Default)]
45pub struct WasmDirScan {
46    /// `.wasm` files found in the directory, sorted lexicographically
47    /// by full path. Sorting at this layer means callers don't have
48    /// to think about deterministic load order.
49    pub sorted_paths: Vec<PathBuf>,
50    /// Per-entry I/O errors from the `read_dir` iterator (rare —
51    /// permission denied on a single inode, broken symlinks the
52    /// dirent-read step caught). Paired with the dir path because
53    /// the per-entry `Err` doesn't carry the inode's name.
54    pub entry_failures: Vec<(PathBuf, std::io::Error)>,
55}
56
57/// Collect `.wasm` files from `dir` (one level, no recursion) and
58/// sort them lexicographically.
59///
60/// Filter rules:
61/// - Skips subdirectories (matches `path.is_file()`).
62/// - Skips files whose `path.is_file()` returns false for any reason
63///   — broken symlinks, transient metadata errors, etc. (`is_file`
64///   swallows the underlying I/O error; this is a documented
65///   limitation, not a bug.)
66/// - Extension matching is case-insensitive: `foo.wasm`, `BAR.WASM`,
67///   and `mixed.Wasm` all match.
68/// - Per-entry I/O errors (where the `read_dir` iterator itself
69///   returns an `Err`) are collected into
70///   [`WasmDirScan::entry_failures`] rather than aborting the scan.
71///
72/// # Errors
73///
74/// Returns the dir-level `read_dir` error verbatim. Callers wrap it
75/// with whatever context they need ("failed to read importer dir ...",
76/// `WasmImporterError::Io { path, source }`, etc.).
77pub fn collect_wasm_paths(dir: &Path) -> std::io::Result<WasmDirScan> {
78    let entries = std::fs::read_dir(dir)?;
79    let mut scan = WasmDirScan::default();
80    for entry in entries {
81        match entry {
82            Ok(e) => {
83                let path = e.path();
84                if path.is_file()
85                    && path
86                        .extension()
87                        .is_some_and(|ext| ext.eq_ignore_ascii_case("wasm"))
88                {
89                    scan.sorted_paths.push(path);
90                }
91            }
92            Err(source) => {
93                // Per-entry I/O error — the `read_dir` iterator's
94                // `next()` can return `Err` for a single inode (rare;
95                // usually permission denied or a broken symlink)
96                // without exposing the entry name. Tag with the dir
97                // path so the caller's report is useful for debugging.
98                scan.entry_failures.push((dir.to_path_buf(), source));
99            }
100        }
101    }
102    scan.sorted_paths.sort();
103    Ok(scan)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn touch(path: &Path) {
111        std::fs::write(path, b"placeholder").expect("write fixture");
112    }
113
114    #[test]
115    fn collects_only_top_level_wasm_files_in_sorted_order() {
116        let dir = tempfile::tempdir().expect("tempdir");
117        let p = dir.path();
118        touch(&p.join("b_second.wasm"));
119        touch(&p.join("a_first.wasm"));
120        touch(&p.join("README.md")); // wrong extension
121        touch(&p.join(".gitignore")); // no extension
122        // Subdir with a .wasm inside — should NOT be picked up.
123        std::fs::create_dir(p.join("sub")).expect("subdir");
124        touch(&p.join("sub").join("recursed.wasm"));
125
126        let scan = collect_wasm_paths(p).expect("scan succeeds");
127        let names: Vec<_> = scan
128            .sorted_paths
129            .iter()
130            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
131            .collect();
132        assert_eq!(names, vec!["a_first.wasm", "b_second.wasm"]);
133        assert!(scan.entry_failures.is_empty());
134    }
135
136    #[test]
137    fn extension_match_is_case_insensitive() {
138        let dir = tempfile::tempdir().expect("tempdir");
139        touch(&dir.path().join("upper.WASM"));
140        touch(&dir.path().join("mixed.Wasm"));
141        let scan = collect_wasm_paths(dir.path()).expect("scan succeeds");
142        assert_eq!(scan.sorted_paths.len(), 2);
143    }
144
145    #[test]
146    fn missing_dir_propagates_read_dir_error() {
147        let dir = tempfile::tempdir().expect("tempdir");
148        let nonexistent = dir.path().join("does-not-exist");
149        let err = collect_wasm_paths(&nonexistent)
150            .expect_err("missing dir should error at the read_dir step, not in entry_failures");
151        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
152    }
153}