Skip to main content

mib_rs/
load.rs

1//! MIB loading pipeline: source discovery, parallel parsing, and resolution.
2//!
3//! The main entry point is [`Loader`], a builder that configures sources,
4//! module restrictions, diagnostics, and strictness, then runs the full
5//! pipeline via [`Loader::load`]. The free function [`load`] is equivalent.
6
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use dashmap::DashMap;
12use rayon::prelude::*;
13use tracing::{debug, debug_span, info, info_span, warn};
14
15use crate::error::LoadError;
16use crate::ir;
17use crate::lower;
18use crate::mib::Mib;
19use crate::parser;
20use crate::scan;
21use crate::searchpath;
22use crate::source::{FindResult, Source};
23use crate::types::{DiagnosticConfig, ResolverStrictness};
24
25/// Builder for loading and resolving MIB modules.
26///
27/// Typical usage starts with [`Loader::new`], adds one or more [`Source`]s,
28/// optionally restricts the requested modules, and finishes with
29/// [`Loader::load`].
30///
31/// If no module list is provided, all modules visible from the configured
32/// sources are loaded.
33///
34/// # Examples
35///
36/// Load a specific module from a directory:
37///
38/// ```no_run
39/// use mib_rs::Loader;
40///
41/// let mib = Loader::new()
42///     .source(mib_rs::source::dir("/usr/share/snmp/mibs").unwrap())
43///     .modules(["IF-MIB"])
44///     .load()
45///     .expect("load failed");
46/// ```
47///
48/// Load from an in-memory source:
49///
50/// ```no_run
51/// use mib_rs::Loader;
52///
53/// let src = mib_rs::source::memory("MY-MIB", b"MY-MIB DEFINITIONS ::= BEGIN END".as_slice());
54/// let mib = Loader::new()
55///     .source(src)
56///     .load()
57///     .expect("load failed");
58/// ```
59pub struct Loader {
60    sources: Vec<Box<dyn Source>>,
61    modules: Option<Vec<String>>,
62    resolver_strictness: ResolverStrictness,
63    diag_config: DiagnosticConfig,
64    system_paths: bool,
65}
66
67impl Default for Loader {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Loader {
74    /// Create a new loader with no sources.
75    ///
76    /// Uses [`ResolverStrictness::Normal`] and the default [`DiagnosticConfig`].
77    pub fn new() -> Self {
78        Loader {
79            sources: Vec::new(),
80            modules: None,
81            resolver_strictness: ResolverStrictness::Normal,
82            diag_config: DiagnosticConfig::default(),
83            system_paths: false,
84        }
85    }
86
87    /// Add a MIB source.
88    ///
89    /// Sources are searched in the order they are added. When the same module
90    /// is available from multiple sources, the first matching source wins.
91    pub fn source(mut self, src: Box<dyn Source>) -> Self {
92        self.sources.push(src);
93        self
94    }
95
96    /// Add multiple MIB sources.
97    ///
98    /// Sources are appended in order and searched left-to-right.
99    pub fn sources(mut self, srcs: Vec<Box<dyn Source>>) -> Self {
100        self.sources.extend(srcs);
101        self
102    }
103
104    /// Restrict loading to the named modules and their transitive dependencies.
105    ///
106    /// When omitted, all modules from the configured sources are loaded.
107    pub fn modules(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
108        let names: Vec<String> = names.into_iter().map(|n| n.into()).collect();
109        self.modules = Some(names);
110        self
111    }
112
113    /// Set the [`DiagnosticConfig`] controlling which diagnostics are
114    /// reported and which severity triggers a [`LoadError::DiagnosticThreshold`].
115    pub fn diagnostic_config(mut self, config: DiagnosticConfig) -> Self {
116        self.diag_config = config;
117        self
118    }
119
120    /// Set the [`ResolverStrictness`] level used during resolution.
121    pub fn resolver_strictness(mut self, strictness: ResolverStrictness) -> Self {
122        self.resolver_strictness = strictness;
123        self
124    }
125
126    /// Enable automatic discovery of system MIB directories.
127    ///
128    /// Probes net-snmp and libsmi config files and environment variables.
129    /// Discovered paths are appended after any explicitly added sources.
130    /// See [`searchpath::discover_system_paths`] for details.
131    pub fn system_paths(mut self) -> Self {
132        self.system_paths = true;
133        self
134    }
135}
136
137/// Load MIB modules from configured sources and resolve them.
138///
139/// This is the free-function form of [`Loader::load`]. It consumes the
140/// [`Loader`] builder, runs the full pipeline (scan, parse, lower, resolve),
141/// and returns the resolved [`Mib`] or a [`LoadError`].
142///
143/// Synthetic base modules (SNMPv2-SMI, SNMPv2-TC, etc.) are always included
144/// automatically, even if no external sources provide them.
145///
146/// # Errors
147///
148/// See [`Loader::load`] for the full list of error conditions.
149pub fn load(options: Loader) -> Result<Mib, LoadError> {
150    let requested_module_count = options.modules.as_ref().map_or(0, Vec::len);
151    let load_mode = if options.modules.is_some() {
152        "modules"
153    } else {
154        "all"
155    };
156    let span = info_span!(
157        target: "mib_rs::load",
158        "load",
159        component = "load",
160        mode = load_mode,
161        explicit_source_count = options.sources.len(),
162        requested_module_count = requested_module_count,
163        system_paths = options.system_paths,
164        strictness = ?options.resolver_strictness,
165        reporting = ?options.diag_config.reporting,
166    );
167    let _guard = span.enter();
168
169    let mut sources = options.sources;
170
171    if options.system_paths {
172        debug!(
173            target: "mib_rs::load",
174            component = "load",
175            phase = "source_discovery",
176            "discovering system sources",
177        );
178        sources.extend(searchpath::discover_system_sources());
179    }
180    if sources.is_empty() {
181        return Err(LoadError::NoSources);
182    }
183
184    let strictness = options.resolver_strictness;
185    let diag_config = options.diag_config;
186
187    let (ir_modules, requested_names) = if let Some(names) = options.modules {
188        let mods = load_modules_by_name(&sources, &names, &diag_config)?;
189        (mods, Some(names))
190    } else {
191        let mods = load_all_modules(&sources, &diag_config)?;
192        (mods, None)
193    };
194
195    debug!(
196        target: "mib_rs::load",
197        component = "load",
198        module_count = ir_modules.len(),
199        phase = "resolve",
200        "load pipeline complete, starting resolver",
201    );
202    let mib = crate::mib::resolver::resolve(ir_modules, strictness, &diag_config);
203
204    check_load_result(&mib, &diag_config, requested_names.as_deref())?;
205
206    info!(
207        target: "mib_rs::load",
208        component = "load",
209        module_count = mib.modules_slice().len(),
210        type_count = mib.types_slice().len(),
211        node_count = mib.tree().len(),
212        diagnostic_count = mib.diagnostics().len(),
213        "load complete",
214    );
215    Ok(mib)
216}
217
218impl Loader {
219    /// Execute the full load pipeline and return the resolved [`Mib`].
220    ///
221    /// Runs source discovery, parallel parsing, lowering, and resolution.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`LoadError::NoSources`] if no sources are configured,
226    /// [`LoadError::MissingModules`] if explicitly requested modules cannot
227    /// be found, [`LoadError::DiagnosticThreshold`] if any diagnostic
228    /// exceeds the configured severity threshold, or [`LoadError::Io`] on
229    /// file read failures.
230    pub fn load(self) -> Result<Mib, LoadError> {
231        load(self)
232    }
233}
234
235/// Load all modules from all sources in parallel.
236fn load_all_modules(
237    sources: &[Box<dyn Source>],
238    diag_config: &DiagnosticConfig,
239) -> Result<Vec<ir::Module>, LoadError> {
240    // Collect all module names, deduplicating (first source wins).
241    let mut seen = HashSet::new();
242    let mut all_modules: Vec<(usize, String)> = Vec::new();
243    for (src_idx, src) in sources.iter().enumerate() {
244        let names = src.list_modules().map_err(LoadError::Io)?;
245        for name in names {
246            if seen.insert(name.clone()) {
247                all_modules.push((src_idx, name));
248            }
249        }
250    }
251
252    if all_modules.is_empty() {
253        let base = collect_base_modules(HashMap::new());
254        return Ok(base);
255    }
256
257    info!(
258        target: "mib_rs::load",
259        component = "load",
260        phase = "parallel_decode",
261        module_count = all_modules.len(),
262        "parallel loading",
263    );
264
265    // Cache decoded files by path to avoid re-parsing multi-module files.
266    let path_cache: DashMap<PathBuf, Arc<Vec<ir::Module>>> = DashMap::new();
267
268    // Parallel load.
269    let results: Result<Vec<Option<ir::Module>>, LoadError> = all_modules
270        .par_iter()
271        .map(|(src_idx, name)| {
272            let span = debug_span!(
273                target: "mib_rs::load",
274                "load_module",
275                component = "load",
276                module = %name,
277                source_index = *src_idx,
278            );
279            let _guard = span.enter();
280            let src = &sources[*src_idx];
281            let result = match src.find(name).map_err(LoadError::Io)? {
282                Some(r) => r,
283                None => {
284                    debug!(
285                        target: "mib_rs::load",
286                        component = "load",
287                        module = %name,
288                        reason = "not_found",
289                        "module not found",
290                    );
291                    return Ok(None);
292                }
293            };
294
295            let cached = path_cache
296                .entry(result.path.clone())
297                .or_insert_with(|| {
298                    Arc::new(decode_modules(&result.content, &result.path, diag_config))
299                })
300                .clone();
301
302            // Return only the requested module from possibly multi-module file.
303            let target = cached.iter().find(|m| m.name == *name).cloned();
304            Ok(target)
305        })
306        .collect();
307
308    let results = results?;
309    let mut modules: HashMap<String, ir::Module> = HashMap::new();
310    for module in results.into_iter().flatten() {
311        modules.entry(module.name.clone()).or_insert(module);
312    }
313
314    info!(
315        target: "mib_rs::load",
316        component = "load",
317        phase = "parallel_decode",
318        module_count = modules.len(),
319        "parallel loading complete",
320    );
321
322    Ok(collect_base_modules(modules))
323}
324
325/// Load specific modules and their dependencies sequentially.
326fn load_modules_by_name(
327    sources: &[Box<dyn Source>],
328    names: &[String],
329    diag_config: &DiagnosticConfig,
330) -> Result<Vec<ir::Module>, LoadError> {
331    let mut modules: HashMap<String, ir::Module> = HashMap::new();
332    let mut file_cache: HashMap<PathBuf, Vec<ir::Module>> = HashMap::new();
333
334    fn find_in_sources(
335        sources: &[Box<dyn Source>],
336        name: &str,
337    ) -> Result<Option<FindResult>, LoadError> {
338        for src in sources {
339            match src.find(name).map_err(LoadError::Io)? {
340                Some(result) => return Ok(Some(result)),
341                None => continue,
342            }
343        }
344        Ok(None)
345    }
346
347    fn load_one(
348        name: &str,
349        sources: &[Box<dyn Source>],
350        modules: &mut HashMap<String, ir::Module>,
351        file_cache: &mut HashMap<PathBuf, Vec<ir::Module>>,
352        diag_config: &DiagnosticConfig,
353    ) -> Result<(), LoadError> {
354        if modules.contains_key(name) {
355            return Ok(());
356        }
357
358        // Check base modules.
359        if let Some(base) = lower::base_modules::get_base_module(name) {
360            modules.insert(name.to_string(), base.clone());
361            return Ok(());
362        }
363
364        let result = match find_in_sources(sources, name)? {
365            Some(r) => r,
366            None => {
367                debug!(
368                    target: "mib_rs::load",
369                    component = "load",
370                    module = %name,
371                    reason = "not_found",
372                    "module not found",
373                );
374                return Ok(());
375            }
376        };
377
378        let mods = file_cache
379            .entry(result.path.clone())
380            .or_insert_with(|| decode_modules(&result.content, &result.path, diag_config));
381
382        // Find the target module.
383        let target = mods.iter().find(|m| m.name == name);
384        let target = match target {
385            Some(t) => t.clone(),
386            None => return Ok(()),
387        };
388
389        // Collect import module names before inserting.
390        let import_modules: Vec<String> = target
391            .imports
392            .iter()
393            .map(|imp| imp.module.clone())
394            .collect::<HashSet<_>>()
395            .into_iter()
396            .collect();
397
398        modules.insert(name.to_string(), target);
399
400        // Recursively load dependencies.
401        for dep in import_modules {
402            load_one(&dep, sources, modules, file_cache, diag_config)?;
403        }
404
405        Ok(())
406    }
407
408    for name in names {
409        load_one(name, sources, &mut modules, &mut file_cache, diag_config)?;
410    }
411
412    Ok(collect_base_modules(modules))
413}
414
415/// Ensure base modules are included and return sorted module list.
416fn collect_base_modules(mut modules: HashMap<String, ir::Module>) -> Vec<ir::Module> {
417    for &name in lower::base_modules::base_module_names() {
418        if !modules.contains_key(name)
419            && let Some(base) = lower::base_modules::get_base_module(name)
420        {
421            modules.insert(name.to_string(), base.clone());
422        }
423    }
424    let mut mods: Vec<ir::Module> = modules.into_values().collect();
425    mods.sort_by(|a, b| a.name.cmp(&b.name));
426    mods
427}
428
429/// Run the heuristic/parse/lower pipeline on raw MIB content.
430fn decode_modules(
431    content: &[u8],
432    source_path: &Path,
433    diag_config: &DiagnosticConfig,
434) -> Vec<ir::Module> {
435    let path_display = source_path.display();
436    let span = debug_span!(
437        target: "mib_rs::load",
438        "decode_modules",
439        component = "load",
440        path = %path_display,
441        byte_count = content.len(),
442    );
443    let _guard = span.enter();
444
445    if !scan::looks_like_mib_content(content) {
446        debug!(
447            target: "mib_rs::load",
448            component = "load",
449            path = %path_display,
450            reason = "heuristic_rejected",
451            "content rejected by heuristic",
452        );
453        return Vec::new();
454    }
455
456    let ast_modules = parser::parse(content, diag_config);
457    let path_str = source_path.to_string_lossy();
458    debug!(
459        target: "mib_rs::load",
460        component = "load",
461        path = %path_display,
462        ast_module_count = ast_modules.len(),
463        "parsed source into AST modules",
464    );
465
466    let mut modules = Vec::new();
467    for am in ast_modules {
468        let mut module = lower::lower(am, content, diag_config);
469        module.source_path = path_str.to_string();
470        modules.push(module);
471    }
472    debug!(
473        target: "mib_rs::load",
474        component = "load",
475        path = %path_display,
476        ir_module_count = modules.len(),
477        "lowered source into IR modules",
478    );
479    modules
480}
481
482/// Check the resolved Mib for diagnostic threshold violations and missing modules.
483fn check_load_result(
484    mib: &Mib,
485    diag_config: &DiagnosticConfig,
486    requested_modules: Option<&[String]>,
487) -> Result<(), LoadError> {
488    // Check for missing requested modules.
489    if let Some(requested) = requested_modules {
490        let mut missing = Vec::new();
491        for name in requested {
492            if mib.module_by_name(name).is_none() {
493                missing.push(name.clone());
494            }
495        }
496        if !missing.is_empty() {
497            warn!(
498                target: "mib_rs::load",
499                component = "load",
500                reason = "missing_requested_modules",
501                missing_module_count = missing.len(),
502                "requested modules not found",
503            );
504            return Err(LoadError::MissingModules(missing));
505        }
506    }
507
508    // Check FailAt threshold.
509    for d in mib.diagnostics() {
510        if diag_config.should_fail(d.severity) {
511            warn!(
512                target: "mib_rs::load",
513                component = "load",
514                reason = "diagnostic_threshold",
515                severity = ?d.severity,
516                code = %d.code,
517                "diagnostic threshold exceeded",
518            );
519            return Err(LoadError::DiagnosticThreshold);
520        }
521    }
522
523    Ok(())
524}