mib_rs/source.rs
1//! MIB source implementations for the loading pipeline.
2//!
3//! A [`Source`] provides access to MIB file content by module name. The library
4//! ships with directory-tree, in-memory, and chained multi-source
5//! implementations.
6
7use std::collections::{HashMap, HashSet};
8use std::io;
9use std::path::{Path, PathBuf};
10
11use tracing::debug;
12
13/// Default file extensions recognized as MIB files.
14///
15/// The empty string matches files with no extension (e.g., `IF-MIB`).
16pub const DEFAULT_EXTENSIONS: &[&str] = &["", ".mib", ".smi", ".txt", ".my"];
17
18/// The content and location of a found MIB file.
19///
20/// Returned by [`Source::find`] when a module is located.
21pub struct FindResult {
22 /// Raw file content (bytes, not necessarily UTF-8).
23 pub content: Vec<u8>,
24 /// Path used in diagnostic messages to identify the source.
25 ///
26 /// For on-disk sources this is the absolute file path. For in-memory
27 /// sources it is a synthetic label like `<memory:MY-MIB>`.
28 pub path: PathBuf,
29}
30
31/// Provides access to MIB files for the loading pipeline.
32///
33/// Implementations must be `Send + Sync` to support parallel loading.
34/// The library ships several constructors:
35///
36/// - [`file()`] / [`files()`] - individual files on disk
37/// - [`dir`] / [`dir_with_config`] - directory tree on disk
38/// - [`dirs()`] - multiple directory trees combined
39/// - [`memory`] / [`memory_modules`] - in-memory content
40/// - [`chain`] - combine arbitrary sources in priority order
41pub trait Source: Send + Sync {
42 /// Look up a module by name and return its content and source path.
43 ///
44 /// Returns `Ok(None)` if this source does not contain the named module.
45 /// The `name` parameter is the MIB module name (e.g. `"IF-MIB"`), not a
46 /// filename.
47 ///
48 /// # Errors
49 ///
50 /// Returns [`io::Error`] if the underlying storage cannot be read (e.g.
51 /// file I/O failure, permission denied).
52 fn find(&self, name: &str) -> io::Result<Option<FindResult>>;
53
54 /// List all module names available from this source.
55 ///
56 /// The returned names should match what [`find`](Source::find) accepts.
57 /// Callers use this to discover modules when no explicit module list is
58 /// provided to the loader.
59 ///
60 /// # Errors
61 ///
62 /// Returns [`io::Error`] if listing fails (e.g. directory read error).
63 fn list_modules(&self) -> io::Result<Vec<String>>;
64}
65
66/// Configuration for directory-based [`Source`] file matching.
67///
68/// Controls which file extensions are recognized as MIB files during
69/// directory indexing. Use [`SourceConfig::default`] for the standard
70/// set ([`DEFAULT_EXTENSIONS`]).
71///
72/// # Examples
73///
74/// ```
75/// let config = mib_rs::source::SourceConfig::default()
76/// .with_extensions(&[".mib", ".txt"]);
77/// ```
78#[derive(Clone)]
79pub struct SourceConfig {
80 extensions: Vec<String>,
81}
82
83impl Default for SourceConfig {
84 fn default() -> Self {
85 SourceConfig {
86 extensions: DEFAULT_EXTENSIONS.iter().map(|s| s.to_string()).collect(),
87 }
88 }
89}
90
91impl SourceConfig {
92 /// Override the default file extensions used to match MIB files.
93 ///
94 /// Extensions are normalized to lowercase with a leading dot.
95 /// An empty string (`""`) matches files with no extension (e.g. `IF-MIB`).
96 pub fn with_extensions(mut self, exts: &[&str]) -> Self {
97 self.extensions = exts
98 .iter()
99 .map(|ext| {
100 let ext = ext.to_lowercase();
101 if !ext.is_empty() && !ext.starts_with('.') {
102 format!(".{ext}")
103 } else {
104 ext
105 }
106 })
107 .collect();
108 self
109 }
110}
111
112/// A source backed by a directory tree on disk.
113/// The directory is eagerly indexed at construction time.
114struct DirSource {
115 root: PathBuf,
116 index: HashMap<String, PathBuf>,
117}
118
119/// Create a [`Source`] that recursively indexes a directory tree.
120///
121/// Module names are derived from file content (scanning for `DEFINITIONS`
122/// headers), not from filenames. When duplicate module names appear, the
123/// first file encountered wins.
124///
125/// The directory is eagerly indexed at construction time, so all file I/O
126/// for discovery happens during this call rather than during later
127/// [`Source::find`] lookups.
128///
129/// Uses [`DEFAULT_EXTENSIONS`] for file matching. For custom extensions,
130/// use [`dir_with_config`].
131///
132/// # Errors
133///
134/// Returns [`io::Error`] if `root` does not exist, is not a directory,
135/// or cannot be read.
136///
137/// # Examples
138///
139/// ```no_run
140/// let src = mib_rs::source::dir("/usr/share/snmp/mibs").unwrap();
141/// let modules = src.list_modules().unwrap();
142/// ```
143pub fn dir(root: impl AsRef<Path>) -> io::Result<Box<dyn Source>> {
144 dir_with_config(root, SourceConfig::default())
145}
146
147/// Create a [`Source`] backed by a directory tree with custom [`SourceConfig`].
148///
149/// Like [`dir`], but allows overriding file extension matching via
150/// [`SourceConfig::with_extensions`].
151///
152/// # Errors
153///
154/// Returns [`io::Error`] if `root` does not exist or is not a directory.
155pub fn dir_with_config(
156 root: impl AsRef<Path>,
157 config: SourceConfig,
158) -> io::Result<Box<dyn Source>> {
159 let root = root.as_ref();
160 let meta = std::fs::metadata(root)?;
161 if !meta.is_dir() {
162 return Err(io::Error::new(
163 io::ErrorKind::InvalidInput,
164 format!("not a directory: {}", root.display()),
165 ));
166 }
167 let index = build_tree_index(root, &config.extensions)?;
168 Ok(Box::new(DirSource {
169 root: root.to_path_buf(),
170 index,
171 }))
172}
173
174/// Create a [`Source`] that chains multiple directory trees.
175///
176/// Equivalent to calling [`dir`] on each root and combining with [`chain`].
177///
178/// # Errors
179///
180/// Returns [`io::Error`] if any root does not exist or is not a directory.
181pub fn dirs(roots: impl IntoIterator<Item = impl AsRef<Path>>) -> io::Result<Box<dyn Source>> {
182 let mut sources = Vec::new();
183 for root in roots {
184 sources.push(dir(root)?);
185 }
186 Ok(chain(sources))
187}
188
189impl Source for DirSource {
190 fn find(&self, name: &str) -> io::Result<Option<FindResult>> {
191 let rel_path = match self.index.get(name) {
192 Some(p) => p,
193 None => return Ok(None),
194 };
195 let full_path = self.root.join(rel_path);
196 let content = std::fs::read(&full_path)?;
197 Ok(Some(FindResult {
198 content,
199 path: full_path,
200 }))
201 }
202
203 fn list_modules(&self) -> io::Result<Vec<String>> {
204 let mut names: Vec<String> = self.index.keys().cloned().collect();
205 names.sort();
206 Ok(names)
207 }
208}
209
210/// A source combining multiple sources in order.
211/// Find() tries each source in order, returning the first match.
212struct MultiSource {
213 sources: Vec<Box<dyn Source>>,
214}
215
216/// Combine multiple [`Source`]s into one.
217///
218/// [`Source::find`] tries each source in order, returning the first match.
219/// [`Source::list_modules`] aggregates all sources, deduplicating by name.
220pub fn chain(sources: Vec<Box<dyn Source>>) -> Box<dyn Source> {
221 Box::new(MultiSource { sources })
222}
223
224impl Source for MultiSource {
225 fn find(&self, name: &str) -> io::Result<Option<FindResult>> {
226 for src in &self.sources {
227 match src.find(name)? {
228 Some(result) => return Ok(Some(result)),
229 None => continue,
230 }
231 }
232 Ok(None)
233 }
234
235 fn list_modules(&self) -> io::Result<Vec<String>> {
236 let mut seen = HashSet::new();
237 let mut names = Vec::new();
238 for src in &self.sources {
239 for name in src.list_modules()? {
240 if seen.insert(name.clone()) {
241 names.push(name);
242 }
243 }
244 }
245 Ok(names)
246 }
247}
248
249/// Create a [`Source`] from a single MIB file on disk.
250///
251/// The module name is extracted from the file content by scanning for
252/// `DEFINITIONS ::=` headers, just like [`dir`] does for directory trees.
253/// The caller does not need to know or provide the module name.
254///
255/// # Errors
256///
257/// Returns [`io::Error`] if the file cannot be read or does not contain
258/// a valid module definition.
259///
260/// # Examples
261///
262/// ```no_run
263/// let src = mib_rs::source::file("/path/to/IF-MIB.mib").unwrap();
264/// assert!(src.list_modules().unwrap().contains(&"IF-MIB".to_string()));
265/// ```
266pub fn file(path: impl AsRef<Path>) -> io::Result<Box<dyn Source>> {
267 files([path])
268}
269
270/// Create a [`Source`] from multiple MIB files on disk.
271///
272/// Module names are extracted from each file's content by scanning for
273/// `DEFINITIONS ::=` headers. When duplicate module names appear across
274/// files, the first file wins.
275///
276/// # Errors
277///
278/// Returns [`io::Error`] if any file cannot be read or contains no
279/// valid module definition.
280pub fn files(paths: impl IntoIterator<Item = impl AsRef<Path>>) -> io::Result<Box<dyn Source>> {
281 let mut modules = HashMap::new();
282 for path in paths {
283 let path = path.as_ref();
284 let content = std::fs::read(path)?;
285 let names = crate::scan::scan_module_names(&content);
286 if names.is_empty() {
287 return Err(io::Error::new(
288 io::ErrorKind::InvalidData,
289 format!("no module definition found in {}", path.display()),
290 ));
291 }
292 let diag_path = path.to_path_buf();
293 for name in names {
294 modules
295 .entry(name)
296 .or_insert_with(|| (diag_path.clone(), content.clone()));
297 }
298 }
299 Ok(Box::new(MemorySource { modules }))
300}
301
302/// A source backed by in-memory byte buffers keyed by module name.
303struct MemorySource {
304 modules: HashMap<String, (PathBuf, Vec<u8>)>,
305}
306
307/// Create a [`Source`] backed by a single in-memory MIB module.
308///
309/// Useful for testing or embedding MIB text directly in code.
310///
311/// # Examples
312///
313/// ```
314/// let src = mib_rs::source::memory(
315/// "MY-MIB",
316/// b"MY-MIB DEFINITIONS ::= BEGIN END".as_slice(),
317/// );
318/// assert_eq!(src.list_modules().unwrap(), vec!["MY-MIB"]);
319/// ```
320pub fn memory(name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Box<dyn Source> {
321 memory_modules([(name.into(), bytes.into())])
322}
323
324/// Create a [`Source`] backed by multiple in-memory MIB modules.
325///
326/// Each entry is a `(name, bytes)` pair. Module names must match the
327/// `DEFINITIONS` header inside the corresponding content.
328pub fn memory_modules(
329 modules: impl IntoIterator<Item = (impl Into<String>, impl Into<Vec<u8>>)>,
330) -> Box<dyn Source> {
331 let mut map = HashMap::new();
332 for (name, bytes) in modules {
333 let name = name.into();
334 map.insert(
335 name.clone(),
336 (PathBuf::from(format!("<memory:{name}>")), bytes.into()),
337 );
338 }
339 Box::new(MemorySource { modules: map })
340}
341
342impl Source for MemorySource {
343 fn find(&self, name: &str) -> io::Result<Option<FindResult>> {
344 Ok(self.modules.get(name).map(|(path, content)| FindResult {
345 content: content.clone(),
346 path: path.clone(),
347 }))
348 }
349
350 fn list_modules(&self) -> io::Result<Vec<String>> {
351 let mut names: Vec<String> = self.modules.keys().cloned().collect();
352 names.sort();
353 Ok(names)
354 }
355}
356
357/// Build a module name -> relative path index by walking a directory tree.
358fn build_tree_index(root: &Path, extensions: &[String]) -> io::Result<HashMap<String, PathBuf>> {
359 let ext_set: HashSet<&str> = extensions.iter().map(|s| s.as_str()).collect();
360 let mut index = HashMap::new();
361
362 for entry in walkdir::WalkDir::new(root).into_iter() {
363 let entry = match entry {
364 Ok(e) => e,
365 Err(e) => {
366 debug!(
367 target: "mib_rs::source",
368 component = "source",
369 reason = "walkdir_error",
370 error = %e,
371 "skipping directory entry",
372 );
373 continue;
374 }
375 };
376
377 if entry.file_type().is_dir() {
378 continue;
379 }
380
381 let path = entry.path();
382 if !has_valid_extension(path, &ext_set) {
383 continue;
384 }
385
386 let content = match std::fs::read(path) {
387 Ok(c) => c,
388 Err(e) => {
389 debug!(
390 target: "mib_rs::source",
391 component = "source",
392 path = %path.display(),
393 reason = "read_error",
394 error = %e,
395 "cannot read file",
396 );
397 continue;
398 }
399 };
400
401 let names = crate::scan::scan_module_names(&content);
402 let rel_path = path.strip_prefix(root).unwrap_or(path).to_path_buf();
403
404 for name in names {
405 index.entry(name).or_insert_with(|| rel_path.clone());
406 }
407 }
408
409 Ok(index)
410}
411
412fn has_valid_extension(path: &Path, ext_set: &HashSet<&str>) -> bool {
413 let ext = path
414 .extension()
415 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
416 .unwrap_or_default();
417 ext_set.contains(ext.as_str())
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn extension_check() {
426 let ext_set: HashSet<&str> = vec!["", ".mib", ".smi"].into_iter().collect();
427 assert!(has_valid_extension(Path::new("IF-MIB"), &ext_set));
428 assert!(has_valid_extension(Path::new("test.mib"), &ext_set));
429 assert!(has_valid_extension(Path::new("test.MIB"), &ext_set));
430 assert!(!has_valid_extension(Path::new("test.txt"), &ext_set));
431 }
432}