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}