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::atomic::{AtomicUsize, Ordering};
10use std::sync::{Arc, Mutex, OnceLock};
11
12use tracing::{debug, debug_span, info, info_span, warn};
13
14use crate::error::LoadError;
15use crate::ir;
16use crate::lower;
17use crate::mib::Mib;
18use crate::parser;
19use crate::scan;
20use crate::searchpath;
21use crate::source::Source;
22use crate::types::{DiagnosticConfig, ResolverStrictness};
23
24/// Builder for loading and resolving MIB modules.
25///
26/// Typical usage starts with [`Loader::new`], adds one or more [`Source`]s,
27/// optionally restricts the requested modules, and finishes with
28/// [`Loader::load`].
29///
30/// If no module list is provided, all modules visible from the configured
31/// sources are loaded.
32///
33/// # Examples
34///
35/// Load a specific module from a directory:
36///
37/// ```no_run
38/// use mib_rs::Loader;
39///
40/// let mib = Loader::new()
41///     .source(mib_rs::source::dir("/usr/share/snmp/mibs").unwrap())
42///     .modules(["IF-MIB"])
43///     .load()
44///     .expect("load failed");
45/// ```
46///
47/// Load from an in-memory source:
48///
49/// ```no_run
50/// use mib_rs::Loader;
51///
52/// let src = mib_rs::source::memory("MY-MIB", b"MY-MIB DEFINITIONS ::= BEGIN END".as_slice());
53/// let mib = Loader::new()
54///     .source(src)
55///     .load()
56///     .expect("load failed");
57/// ```
58pub struct Loader {
59    sources: Vec<Box<dyn Source>>,
60    modules: Option<Vec<String>>,
61    resolver_strictness: ResolverStrictness,
62    diag_config: DiagnosticConfig,
63    system_paths: bool,
64    parallelism: Option<usize>,
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            parallelism: None,
85        }
86    }
87
88    /// Add a MIB source.
89    ///
90    /// Sources are searched in the order they are added. When the same module
91    /// is available from multiple sources, the first matching source wins.
92    pub fn source(mut self, src: Box<dyn Source>) -> Self {
93        self.sources.push(src);
94        self
95    }
96
97    /// Add multiple MIB sources.
98    ///
99    /// Sources are appended in order and searched left-to-right.
100    pub fn sources(mut self, srcs: Vec<Box<dyn Source>>) -> Self {
101        self.sources.extend(srcs);
102        self
103    }
104
105    /// Restrict loading to the named modules and their transitive dependencies.
106    ///
107    /// When omitted, all modules from the configured sources are loaded.
108    pub fn modules(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
109        let names: Vec<String> = names.into_iter().map(|n| n.into()).collect();
110        self.modules = Some(names);
111        self
112    }
113
114    /// Set the [`DiagnosticConfig`] controlling diagnostic collection,
115    /// severity overrides, and the [`LoadError::DiagnosticThreshold`].
116    pub fn diagnostic_config(mut self, config: DiagnosticConfig) -> Self {
117        self.diag_config = config;
118        self
119    }
120
121    /// Set the [`ResolverStrictness`] level used during resolution.
122    pub fn resolver_strictness(mut self, strictness: ResolverStrictness) -> Self {
123        self.resolver_strictness = strictness;
124        self
125    }
126
127    /// Enable automatic discovery of system MIB directories.
128    ///
129    /// Probes net-snmp and libsmi config files and environment variables.
130    /// Discovered paths are appended after any explicitly added sources.
131    /// See [`searchpath::discover_system_paths`] for details.
132    pub fn system_paths(mut self) -> Self {
133        self.system_paths = true;
134        self
135    }
136
137    /// Set the number of threads used when loading all discoverable modules.
138    ///
139    /// Defaults to the number of available logical CPUs. Set to `1` to
140    /// disable parallel loading. Loading a selected module list with
141    /// [`Loader::modules`] and resolving parsed modules are sequential.
142    pub fn parallelism(mut self, threads: usize) -> Self {
143        self.parallelism = Some(threads.max(1));
144        self
145    }
146}
147
148/// Load MIB modules from configured sources and resolve them.
149///
150/// This is the free-function form of [`Loader::load`]. It consumes the
151/// [`Loader`] builder, runs the full pipeline (scan, parse, lower, resolve),
152/// and returns the resolved [`Mib`] or a [`LoadError`].
153///
154/// Synthetic base modules (SNMPv2-SMI, SNMPv2-TC, etc.) are always included
155/// automatically, even if no external sources provide them.
156///
157/// # Errors
158///
159/// See [`Loader::load`] for the full list of error conditions.
160pub fn load(options: Loader) -> Result<Mib, LoadError> {
161    let requested_module_count = options.modules.as_ref().map_or(0, Vec::len);
162    let load_mode = if options.modules.is_some() {
163        "modules"
164    } else {
165        "all"
166    };
167    let span = info_span!(
168        target: "mib_rs::load",
169        "load",
170        component = "load",
171        mode = load_mode,
172        explicit_source_count = options.sources.len(),
173        requested_module_count = requested_module_count,
174        system_paths = options.system_paths,
175        strictness = ?options.resolver_strictness,
176        reporting = ?options.diag_config.reporting,
177    );
178    let _guard = span.enter();
179
180    let mut sources = options.sources;
181
182    if options.system_paths {
183        debug!(
184            target: "mib_rs::load",
185            component = "load",
186            phase = "source_discovery",
187            "discovering system sources",
188        );
189        sources.extend(searchpath::discover_system_sources());
190    }
191    if sources.is_empty() {
192        return Err(LoadError::NoSources);
193    }
194
195    let strictness = options.resolver_strictness;
196    let diag_config = options.diag_config;
197
198    let (ir_modules, requested_names) = if let Some(names) = options.modules {
199        let mods = load_modules_by_name(&sources, &names, &diag_config)?;
200        (mods, Some(names))
201    } else {
202        let mods = load_all_modules(&sources, &diag_config, options.parallelism)?;
203        (mods, None)
204    };
205
206    debug!(
207        target: "mib_rs::load",
208        component = "load",
209        module_count = ir_modules.len(),
210        phase = "resolve",
211        "load pipeline complete, starting resolver",
212    );
213    let mib = crate::mib::resolver::resolve(ir_modules, strictness, &diag_config);
214
215    check_load_result(&mib, &diag_config, requested_names.as_deref())?;
216
217    info!(
218        target: "mib_rs::load",
219        component = "load",
220        module_count = mib.modules_slice().len(),
221        type_count = mib.types_slice().len(),
222        node_count = mib.tree().len(),
223        diagnostic_count = mib.diagnostics().len(),
224        "load complete",
225    );
226    Ok(mib)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::sync::atomic::{AtomicUsize, Ordering};
233    use std::sync::{Barrier, Condvar, mpsc};
234    use std::time::Duration;
235
236    fn cache_key(index: usize) -> FileCacheKey {
237        (0, format!("TEST-{index}"), index, PathBuf::from("<test>"))
238    }
239
240    #[test]
241    fn cache_initializes_distinct_entries_concurrently() {
242        let cache = Arc::new(Mutex::new(HashMap::new()));
243        let gate = Arc::new((Mutex::new(false), Condvar::new()));
244        let (started_tx, started_rx) = mpsc::channel();
245        let mut handles = Vec::new();
246
247        for index in 0..2 {
248            let cache = Arc::clone(&cache);
249            let gate = Arc::clone(&gate);
250            let started_tx = started_tx.clone();
251            handles.push(std::thread::spawn(move || {
252                cached_modules(&cache, cache_key(index), || {
253                    started_tx.send(index).unwrap();
254                    let (released, wake) = &*gate;
255                    let mut released = released.lock().unwrap();
256                    while !*released {
257                        released = wake.wait(released).unwrap();
258                    }
259                    Vec::new()
260                })
261            }));
262        }
263        drop(started_tx);
264
265        let first_started = started_rx.recv_timeout(Duration::from_secs(5));
266        let second_started = started_rx.recv_timeout(Duration::from_secs(5));
267
268        let (released, wake) = &*gate;
269        *released.lock().unwrap() = true;
270        wake.notify_all();
271        for handle in handles {
272            handle.join().unwrap();
273        }
274
275        assert!(first_started.is_ok(), "no cache initializer started");
276        assert!(
277            second_started.is_ok(),
278            "distinct cache entry was blocked by another entry's initializer"
279        );
280    }
281
282    #[test]
283    fn cache_initializes_shared_entry_once() {
284        let cache = Arc::new(Mutex::new(HashMap::new()));
285        let start = Arc::new(Barrier::new(3));
286        let initialization_count = Arc::new(AtomicUsize::new(0));
287        let mut handles = Vec::new();
288
289        for _ in 0..2 {
290            let cache = Arc::clone(&cache);
291            let start = Arc::clone(&start);
292            let initialization_count = Arc::clone(&initialization_count);
293            handles.push(std::thread::spawn(move || {
294                start.wait();
295                cached_modules(&cache, cache_key(0), || {
296                    initialization_count.fetch_add(1, Ordering::Relaxed);
297                    Vec::new()
298                })
299            }));
300        }
301        start.wait();
302
303        let first = handles.remove(0).join().unwrap();
304        let second = handles.remove(0).join().unwrap();
305        assert_eq!(initialization_count.load(Ordering::Relaxed), 1);
306        assert!(Arc::ptr_eq(&first, &second));
307    }
308}
309
310impl Loader {
311    /// Execute the full load pipeline and return the resolved [`Mib`].
312    ///
313    /// Runs source discovery, parallel parsing, lowering, and resolution.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`LoadError::NoSources`] if no sources are configured,
318    /// [`LoadError::MissingModules`] if explicitly requested modules cannot
319    /// be found, [`LoadError::DiagnosticThreshold`] if any diagnostic
320    /// exceeds the configured severity threshold, or [`LoadError::Io`] on
321    /// file read failures.
322    pub fn load(self) -> Result<Mib, LoadError> {
323        load(self)
324    }
325}
326
327type FileCacheKey = (usize, String, usize, PathBuf);
328type ModuleCacheEntry = Arc<OnceLock<Arc<Vec<ir::Module>>>>;
329type SharedModuleCache = Mutex<HashMap<FileCacheKey, ModuleCacheEntry>>;
330
331fn cached_modules(
332    cache: &SharedModuleCache,
333    key: FileCacheKey,
334    decode: impl FnOnce() -> Vec<ir::Module>,
335) -> Arc<Vec<ir::Module>> {
336    let entry = {
337        let mut cache = cache.lock().unwrap();
338        cache
339            .entry(key)
340            .or_insert_with(|| Arc::new(OnceLock::new()))
341            .clone()
342    };
343
344    entry.get_or_init(|| Arc::new(decode())).clone()
345}
346
347#[derive(Debug)]
348struct ModuleCandidate {
349    source_index: usize,
350    name: String,
351}
352
353/// Load all modules from all sources in parallel.
354fn load_all_modules(
355    sources: &[Box<dyn Source>],
356    diag_config: &DiagnosticConfig,
357    parallelism: Option<usize>,
358) -> Result<Vec<ir::Module>, LoadError> {
359    // Keep every source advertising a name until decoding confirms which
360    // candidate actually contains the module. Only then is precedence fixed.
361    let mut module_indexes = HashMap::<String, usize>::new();
362    let mut all_modules: Vec<(String, Vec<ModuleCandidate>)> = Vec::new();
363    for (source_index, source) in sources.iter().enumerate() {
364        let names = source.list_modules().map_err(LoadError::Io)?;
365        let mut seen_in_source = HashSet::new();
366        for name in names {
367            if !seen_in_source.insert(name.clone()) {
368                continue;
369            }
370            let candidate = ModuleCandidate {
371                source_index,
372                name: name.clone(),
373            };
374            if let Some(&index) = module_indexes.get(&name) {
375                all_modules[index].1.push(candidate);
376            } else {
377                module_indexes.insert(name.clone(), all_modules.len());
378                all_modules.push((name, vec![candidate]));
379            }
380        }
381    }
382
383    if all_modules.is_empty() {
384        let base = collect_base_modules(HashMap::new());
385        return Ok(base);
386    }
387
388    info!(
389        target: "mib_rs::load",
390        component = "load",
391        phase = "parallel_decode",
392        module_count = all_modules.len(),
393        "parallel loading",
394    );
395
396    // Cache decoded candidates without conflating module-specific lookups or
397    // sources that reuse candidate positions and diagnostic paths.
398    let path_cache: SharedModuleCache = Mutex::new(HashMap::new());
399
400    // Parallel load using std::thread::scope with an atomic work queue.
401    let thread_count =
402        parallelism.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get()));
403    let next_idx = AtomicUsize::new(0);
404    let error: Mutex<Option<LoadError>> = Mutex::new(None);
405    let collected: Mutex<HashMap<String, ir::Module>> = Mutex::new(HashMap::new());
406
407    std::thread::scope(|s| {
408        for _ in 0..thread_count {
409            s.spawn(|| {
410                let mut local_modules: Vec<(String, ir::Module)> = Vec::new();
411                loop {
412                    let idx = next_idx.fetch_add(1, Ordering::Relaxed);
413                    if idx >= all_modules.len() {
414                        break;
415                    }
416                    // Check if another thread hit an error.
417                    if error.lock().unwrap().is_some() {
418                        break;
419                    }
420
421                    let (name, candidates) = &all_modules[idx];
422                    let span = debug_span!(
423                        target: "mib_rs::load",
424                        "load_module",
425                        component = "load",
426                        module = %name,
427                    );
428                    let _guard = span.enter();
429
430                    'sources: for candidate in candidates {
431                        let src = &sources[candidate.source_index];
432                        for (candidate_index, result) in
433                            src.find_candidates(&candidate.name).enumerate()
434                        {
435                            let result = match result {
436                                Ok(result) => result,
437                                Err(error_value) => {
438                                    *error.lock().unwrap() = Some(LoadError::Io(error_value));
439                                    break 'sources;
440                                }
441                            };
442                            let cached = cached_modules(
443                                &path_cache,
444                                (
445                                    candidate.source_index,
446                                    name.clone(),
447                                    candidate_index,
448                                    result.path.clone(),
449                                ),
450                                || decode_modules(&result.content, &result.path, diag_config),
451                            );
452
453                            // A source advertisement is only a candidate until
454                            // its decoded content contains the requested module.
455                            if let Some(target) = cached.iter().find(|m| m.name == *name) {
456                                local_modules.push((name.clone(), target.clone()));
457                                break 'sources;
458                            }
459                            debug!(
460                                target: "mib_rs::load",
461                                component = "load",
462                                module = %name,
463                                source_index = candidate.source_index,
464                                path = %result.path.display(),
465                                reason = "decoded_module_missing",
466                                "candidate did not contain advertised module",
467                            );
468                        }
469                    }
470                }
471                // Merge local results.
472                let mut map = collected.lock().unwrap();
473                for (name, module) in local_modules {
474                    map.entry(name).or_insert(module);
475                }
476            });
477        }
478    });
479
480    if let Some(e) = error.into_inner().unwrap() {
481        return Err(e);
482    }
483    let modules = collected.into_inner().unwrap();
484
485    info!(
486        target: "mib_rs::load",
487        component = "load",
488        phase = "parallel_decode",
489        module_count = modules.len(),
490        "parallel loading complete",
491    );
492
493    Ok(collect_base_modules(modules))
494}
495
496/// Load specific modules and their dependencies sequentially.
497fn load_modules_by_name(
498    sources: &[Box<dyn Source>],
499    names: &[String],
500    diag_config: &DiagnosticConfig,
501) -> Result<Vec<ir::Module>, LoadError> {
502    let mut modules: HashMap<String, ir::Module> = HashMap::new();
503    let mut file_cache: HashMap<FileCacheKey, Vec<ir::Module>> = HashMap::new();
504
505    fn load_one(
506        name: &str,
507        sources: &[Box<dyn Source>],
508        modules: &mut HashMap<String, ir::Module>,
509        file_cache: &mut HashMap<FileCacheKey, Vec<ir::Module>>,
510        diag_config: &DiagnosticConfig,
511    ) -> Result<(), LoadError> {
512        if modules.contains_key(name) {
513            return Ok(());
514        }
515
516        // Check base modules.
517        if let Some(base) = lower::base_modules::get_base_module(name) {
518            modules.insert(name.to_string(), base.clone());
519            return Ok(());
520        }
521
522        // A Source::find result is only a candidate until decoding confirms
523        // that its content contains the requested module. Phantom source
524        // advertisements must not shadow valid modules in later sources.
525        let mut target = None;
526        'sources: for (source_index, source) in sources.iter().enumerate() {
527            for (candidate_index, result) in source.find_candidates(name).enumerate() {
528                let result = result.map_err(LoadError::Io)?;
529                let mods = file_cache
530                    .entry((
531                        source_index,
532                        name.to_string(),
533                        candidate_index,
534                        result.path.clone(),
535                    ))
536                    .or_insert_with(|| decode_modules(&result.content, &result.path, diag_config));
537                if let Some(module) = mods.iter().find(|module| module.name == name) {
538                    target = Some(module.clone());
539                    break 'sources;
540                }
541
542                debug!(
543                    target: "mib_rs::load",
544                    component = "load",
545                    module = %name,
546                    path = %result.path.display(),
547                    reason = "decoded_module_missing",
548                    "candidate did not contain advertised module",
549                );
550            }
551        }
552
553        let target = match target {
554            Some(target) => target,
555            None => {
556                debug!(
557                    target: "mib_rs::load",
558                    component = "load",
559                    module = %name,
560                    reason = "not_found",
561                    "module not found",
562                );
563                return Ok(());
564            }
565        };
566
567        // Collect import module names before inserting.
568        let import_modules: Vec<String> = target
569            .imports
570            .iter()
571            .map(|imp| imp.module.clone())
572            .collect::<HashSet<_>>()
573            .into_iter()
574            .collect();
575
576        modules.insert(name.to_string(), target);
577
578        // Recursively load dependencies.
579        for dep in import_modules {
580            load_one(&dep, sources, modules, file_cache, diag_config)?;
581        }
582
583        Ok(())
584    }
585
586    for name in names {
587        load_one(name, sources, &mut modules, &mut file_cache, diag_config)?;
588    }
589
590    Ok(collect_base_modules(modules))
591}
592
593/// Ensure base modules are included and return sorted module list.
594fn collect_base_modules(mut modules: HashMap<String, ir::Module>) -> Vec<ir::Module> {
595    for &name in lower::base_modules::base_module_names() {
596        if !modules.contains_key(name)
597            && let Some(base) = lower::base_modules::get_base_module(name)
598        {
599            modules.insert(name.to_string(), base.clone());
600        }
601    }
602    let mut mods: Vec<ir::Module> = modules.into_values().collect();
603    mods.sort_by(|a, b| a.name.cmp(&b.name));
604    mods
605}
606
607/// Run the heuristic/parse/lower pipeline on raw MIB content.
608fn decode_modules(
609    content: &[u8],
610    source_path: &Path,
611    diag_config: &DiagnosticConfig,
612) -> Vec<ir::Module> {
613    let path_display = source_path.display();
614    let span = debug_span!(
615        target: "mib_rs::load",
616        "decode_modules",
617        component = "load",
618        path = %path_display,
619        byte_count = content.len(),
620    );
621    let _guard = span.enter();
622
623    if !scan::looks_like_mib_content(content) {
624        debug!(
625            target: "mib_rs::load",
626            component = "load",
627            path = %path_display,
628            reason = "heuristic_rejected",
629            "content rejected by heuristic",
630        );
631        return Vec::new();
632    }
633
634    let ast_modules = parser::parse(content, diag_config);
635    let path_str = source_path.to_string_lossy();
636    debug!(
637        target: "mib_rs::load",
638        component = "load",
639        path = %path_display,
640        ast_module_count = ast_modules.len(),
641        "parsed source into AST modules",
642    );
643
644    let mut modules = Vec::new();
645    for am in ast_modules {
646        let mut module = lower::lower(am, content, diag_config);
647        module.source_path = path_str.to_string();
648        modules.push(module);
649    }
650    debug!(
651        target: "mib_rs::load",
652        component = "load",
653        path = %path_display,
654        ir_module_count = modules.len(),
655        "lowered source into IR modules",
656    );
657    modules
658}
659
660/// Check the resolved Mib for diagnostic threshold violations and missing modules.
661fn check_load_result(
662    mib: &Mib,
663    diag_config: &DiagnosticConfig,
664    requested_modules: Option<&[String]>,
665) -> Result<(), LoadError> {
666    // Check for missing requested modules.
667    if let Some(requested) = requested_modules {
668        let mut missing = Vec::new();
669        for name in requested {
670            if mib.module_by_name(name).is_none() {
671                missing.push(name.clone());
672            }
673        }
674        if !missing.is_empty() {
675            warn!(
676                target: "mib_rs::load",
677                component = "load",
678                reason = "missing_requested_modules",
679                missing_module_count = missing.len(),
680                "requested modules not found",
681            );
682            return Err(LoadError::MissingModules(missing));
683        }
684    }
685
686    // Check FailAt threshold.
687    for d in mib.diagnostics() {
688        if diag_config.should_fail(d.severity) {
689            warn!(
690                target: "mib_rs::load",
691                component = "load",
692                reason = "diagnostic_threshold",
693                severity = ?d.severity,
694                code = %d.code,
695                "diagnostic threshold exceeded",
696            );
697            return Err(LoadError::DiagnosticThreshold);
698        }
699    }
700
701    Ok(())
702}