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::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::{Arc, Mutex, OnceLock};
10
11use tracing::{debug, debug_span, info, info_span, warn};
12
13use crate::error::LoadError;
14use crate::ir;
15use crate::lower;
16use crate::mib::Mib;
17use crate::parser;
18use crate::scan;
19use crate::searchpath;
20use crate::source::{CandidateId, Source, SourceCandidate, SourceDocument, SourceSet};
21use crate::types::{DiagnosticConfig, ResolverStrictness};
22
23/// Builder for loading and resolving MIB modules.
24///
25/// Typical usage starts with [`Loader::new`], adds one or more [`Source`]s,
26/// optionally restricts the requested modules, and finishes with
27/// [`Loader::load`].
28///
29/// If no module list is provided, all modules visible from the configured
30/// sources are loaded.
31///
32/// # Examples
33///
34/// Load a specific module from a directory:
35///
36/// ```no_run
37/// use mib_rs::Loader;
38///
39/// let mib = Loader::new()
40///     .source(mib_rs::source::dir("/usr/share/snmp/mibs").unwrap())
41///     .modules(["IF-MIB"])
42///     .load()
43///     .expect("load failed");
44/// ```
45///
46/// Load from an in-memory source:
47///
48/// ```no_run
49/// use mib_rs::Loader;
50///
51/// let src = mib_rs::source::memory("MY-MIB", b"MY-MIB DEFINITIONS ::= BEGIN END".as_slice());
52/// let mib = Loader::new()
53///     .source(src)
54///     .load()
55///     .expect("load failed");
56/// ```
57pub struct Loader {
58    sources: Vec<Box<dyn Source>>,
59    modules: Option<Vec<String>>,
60    resolver_strictness: ResolverStrictness,
61    diag_config: DiagnosticConfig,
62    system_paths: bool,
63    parallelism: Option<usize>,
64}
65
66impl Default for Loader {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl Loader {
73    /// Create a new loader with no sources.
74    ///
75    /// Uses [`ResolverStrictness::Normal`] and the default [`DiagnosticConfig`].
76    pub fn new() -> Self {
77        Loader {
78            sources: Vec::new(),
79            modules: None,
80            resolver_strictness: ResolverStrictness::Normal,
81            diag_config: DiagnosticConfig::default(),
82            system_paths: false,
83            parallelism: None,
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 diagnostic collection,
114    /// severity overrides, and the [`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    /// Set the number of threads used when loading all discoverable modules.
137    ///
138    /// Defaults to the number of available logical CPUs. Set to `1` to
139    /// disable parallel loading. Loading a selected module list with
140    /// [`Loader::modules`] and resolving parsed modules are sequential.
141    pub fn parallelism(mut self, threads: usize) -> Self {
142        self.parallelism = Some(threads.max(1));
143        self
144    }
145}
146
147/// Load MIB modules from configured sources and resolve them.
148///
149/// This is the free-function form of [`Loader::load`]. It consumes the
150/// [`Loader`] builder, runs the full pipeline (scan, parse, lower, resolve),
151/// and returns the resolved [`Mib`] or a [`LoadError`].
152///
153/// Embedded foundation modules (SNMPv2-SMI, SNMPv2-TC, etc.) are used as
154/// lowest-priority fallbacks when configured sources do not provide them.
155///
156/// # Errors
157///
158/// See [`Loader::load`] for the full list of error conditions.
159pub fn load(options: Loader) -> Result<Mib, LoadError> {
160    let requested_module_count = options.modules.as_ref().map_or(0, Vec::len);
161    let load_mode = if options.modules.is_some() {
162        "modules"
163    } else {
164        "all"
165    };
166    let span = info_span!(
167        target: "mib_rs::load",
168        "load",
169        component = "load",
170        mode = load_mode,
171        explicit_source_count = options.sources.len(),
172        requested_module_count = requested_module_count,
173        system_paths = options.system_paths,
174        strictness = ?options.resolver_strictness,
175        reporting = ?options.diag_config.reporting,
176    );
177    let _guard = span.enter();
178
179    let has_explicit_sources = !options.sources.is_empty();
180    let has_requested_modules = options.modules.is_some();
181    let mut sources = options.sources;
182
183    if options.system_paths {
184        debug!(
185            target: "mib_rs::load",
186            component = "load",
187            phase = "source_discovery",
188            "discovering system sources",
189        );
190        sources.extend(searchpath::discover_system_sources());
191    }
192    if !has_explicit_sources && !options.system_paths && !has_requested_modules {
193        return Err(LoadError::NoSources);
194    }
195    sources.push(Box::new(crate::source::EmbeddedSource));
196
197    let strictness = options.resolver_strictness;
198    let diag_config = options.diag_config;
199
200    let (loaded, requested_names) = if let Some(names) = options.modules {
201        let loaded = load_modules_by_name(&sources, &names, &diag_config)?;
202        (loaded, Some(names))
203    } else {
204        let loaded = load_all_modules(&sources, &diag_config, options.parallelism)?;
205        (loaded, None)
206    };
207
208    debug!(
209        target: "mib_rs::load",
210        component = "load",
211        module_count = loaded.modules.len(),
212        phase = "resolve",
213        "load pipeline complete, starting resolver",
214    );
215    let mib =
216        crate::mib::resolver::resolve(loaded.modules, loaded.sources, strictness, &diag_config);
217
218    let mib = check_load_result(mib, &diag_config, requested_names.as_deref())?;
219
220    info!(
221        target: "mib_rs::load",
222        component = "load",
223        module_count = mib.modules_slice().len(),
224        type_count = mib.types_slice().len(),
225        node_count = mib.tree().len(),
226        diagnostic_count = mib.diagnostics().len(),
227        "load complete",
228    );
229    Ok(mib)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::source::{SourceOrigin, SourceRange};
236    use crate::types::{DiagCode, Severity};
237    use std::sync::atomic::{AtomicUsize, Ordering};
238    use std::sync::{Barrier, Condvar, mpsc};
239    use std::time::Duration;
240
241    #[derive(Clone)]
242    struct SharedDocumentSource {
243        names: Vec<String>,
244        candidate: SourceCandidate,
245    }
246
247    impl SharedDocumentSource {
248        fn new(
249            names: impl IntoIterator<Item = impl Into<String>>,
250            identity: &str,
251            origin: SourceOrigin,
252            label: &str,
253            bytes: Arc<[u8]>,
254        ) -> Self {
255            Self {
256                names: names.into_iter().map(Into::into).collect(),
257                candidate: SourceCandidate::new(identity, origin, label, bytes),
258            }
259        }
260    }
261
262    impl Source for SharedDocumentSource {
263        fn find(&self, name: &str) -> std::io::Result<Option<SourceCandidate>> {
264            Ok(self
265                .names
266                .iter()
267                .any(|candidate| candidate == name)
268                .then(|| self.candidate.clone()))
269        }
270
271        fn list_modules(&self) -> std::io::Result<Vec<String>> {
272            Ok(self.names.clone())
273        }
274    }
275
276    fn module_document<'a>(mib: &'a Mib, name: &str) -> &'a SourceDocument {
277        let module = mib
278            .module(name)
279            .unwrap_or_else(|| panic!("missing module {name}"));
280        module
281            .source()
282            .unwrap_or_else(|| panic!("module {name} has no source"))
283    }
284
285    fn assert_retained_range(mib: &Mib, range: SourceRange) {
286        let source = mib
287            .source(range.source())
288            .unwrap_or_else(|| panic!("source {} is not retained", range.source()));
289        source
290            .slice(range)
291            .unwrap_or_else(|error| panic!("invalid retained range {range:?}: {error}"));
292    }
293
294    fn assert_optional_range(mib: &Mib, range: Option<SourceRange>) {
295        if let Some(range) = range {
296            assert_retained_range(mib, range);
297        }
298    }
299
300    fn assert_constraint_ranges(mib: &Mib, ranges: &[crate::mib::Range]) {
301        for range in ranges {
302            assert_optional_range(mib, range.range);
303        }
304    }
305
306    fn assert_named_value_ranges(mib: &Mib, values: &[crate::mib::NamedValue]) {
307        for value in values {
308            assert_retained_range(mib, value.range);
309        }
310    }
311
312    fn assert_syntax_constraint_ranges(mib: &Mib, syntax: &crate::mib::SyntaxConstraints) {
313        assert_constraint_ranges(mib, &syntax.sizes);
314        assert_constraint_ranges(mib, &syntax.declared_sizes);
315        assert_constraint_ranges(mib, &syntax.ranges);
316        assert_constraint_ranges(mib, &syntax.declared_ranges);
317        assert_named_value_ranges(mib, &syntax.enums);
318        assert_named_value_ranges(mib, &syntax.bits);
319    }
320
321    fn assert_entity_ranges(mib: &Mib, entity: &crate::mib::object::EntityData) {
322        assert_optional_range(mib, entity.range);
323        assert_optional_range(mib, entity.status_range);
324        assert_optional_range(mib, entity.description_range);
325        assert_optional_range(mib, entity.reference_range);
326        for oid_ref in &entity.oid_refs {
327            assert_retained_range(mib, oid_ref.range);
328        }
329    }
330
331    fn assert_all_resolved_ranges_are_retained(mib: &Mib) {
332        for module in &mib.modules {
333            if let Some(source_id) = module.source_id {
334                assert!(mib.source(source_id).is_some());
335            }
336            for revision in &module.revisions {
337                assert_retained_range(mib, revision.range);
338            }
339            for import in &module.imports {
340                for symbol in &import.symbols {
341                    assert_retained_range(mib, symbol.range);
342                }
343            }
344        }
345
346        assert_optional_range(mib, mib.tree.get(mib.tree.root()).range);
347        for node_id in mib.tree.all_nodes() {
348            assert_optional_range(mib, mib.tree.get(node_id).range);
349        }
350
351        for object in &mib.objects {
352            assert_entity_ranges(mib, &object.entity);
353            for range in [
354                object.syntax_range,
355                object.access_range,
356                object.units_range,
357                object.augments_range,
358                object.default_value_range,
359            ] {
360                assert_optional_range(mib, range);
361            }
362            for index in &object.index {
363                assert_retained_range(mib, index.range);
364            }
365            assert_constraint_ranges(mib, &object.sizes);
366            assert_constraint_ranges(mib, &object.ranges);
367            assert_constraint_ranges(mib, &object.declared_sizes);
368            assert_constraint_ranges(mib, &object.declared_ranges);
369            assert_named_value_ranges(mib, &object.enums);
370            assert_named_value_ranges(mib, &object.bits);
371        }
372
373        for typ in &mib.types {
374            assert_optional_range(mib, typ.range);
375            assert_optional_range(mib, typ.syntax_range);
376            assert_constraint_ranges(mib, &typ.sizes);
377            assert_constraint_ranges(mib, &typ.ranges);
378            assert_constraint_ranges(mib, &typ.effective_sizes);
379            assert_constraint_ranges(mib, &typ.effective_ranges);
380            assert_named_value_ranges(mib, &typ.enums);
381            assert_named_value_ranges(mib, &typ.bits);
382        }
383
384        for notification in &mib.notifications {
385            assert_entity_ranges(mib, &notification.entity);
386        }
387        for group in &mib.groups {
388            assert_entity_ranges(mib, &group.entity);
389        }
390        for compliance in &mib.compliances {
391            assert_entity_ranges(mib, &compliance.entity);
392            for module in &compliance.modules {
393                assert_retained_range(mib, module.range);
394                for group in &module.groups {
395                    assert_retained_range(mib, group.range);
396                }
397                for object in &module.objects {
398                    assert_retained_range(mib, object.range);
399                    if let Some(syntax) = &object.syntax {
400                        assert_syntax_constraint_ranges(mib, syntax);
401                    }
402                    if let Some(syntax) = &object.write_syntax {
403                        assert_syntax_constraint_ranges(mib, syntax);
404                    }
405                }
406            }
407        }
408        for capability in &mib.capabilities {
409            assert_entity_ranges(mib, &capability.entity);
410            for module in &capability.supports {
411                assert_retained_range(mib, module.range);
412                for variation in &module.object_variations {
413                    assert_retained_range(mib, variation.range);
414                    if let Some(syntax) = &variation.syntax {
415                        assert_syntax_constraint_ranges(mib, syntax);
416                    }
417                    if let Some(syntax) = &variation.write_syntax {
418                        assert_syntax_constraint_ranges(mib, syntax);
419                    }
420                }
421                for variation in &module.notification_variations {
422                    assert_retained_range(mib, variation.range);
423                }
424            }
425        }
426    }
427
428    fn cache_key(index: usize) -> CandidateKey {
429        (0, CandidateId::new(format!("TEST-{index}")))
430    }
431
432    #[test]
433    fn cache_initializes_distinct_entries_concurrently() {
434        let cache = Arc::new(Mutex::new(HashMap::new()));
435        let gate = Arc::new((Mutex::new(false), Condvar::new()));
436        let (started_tx, started_rx) = mpsc::channel();
437        let mut handles = Vec::new();
438
439        for index in 0..2 {
440            let cache = Arc::clone(&cache);
441            let gate = Arc::clone(&gate);
442            let started_tx = started_tx.clone();
443            handles.push(std::thread::spawn(move || {
444                cached_modules(&cache, cache_key(index), || {
445                    started_tx.send(index).unwrap();
446                    let (released, wake) = &*gate;
447                    let mut released = released.lock().unwrap();
448                    while !*released {
449                        released = wake.wait(released).unwrap();
450                    }
451                    Vec::new()
452                })
453            }));
454        }
455        drop(started_tx);
456
457        let first_started = started_rx.recv_timeout(Duration::from_secs(5));
458        let second_started = started_rx.recv_timeout(Duration::from_secs(5));
459
460        let (released, wake) = &*gate;
461        *released.lock().unwrap() = true;
462        wake.notify_all();
463        for handle in handles {
464            handle.join().unwrap();
465        }
466
467        assert!(first_started.is_ok(), "no cache initializer started");
468        assert!(
469            second_started.is_ok(),
470            "distinct cache entry was blocked by another entry's initializer"
471        );
472    }
473
474    #[test]
475    fn cache_initializes_shared_entry_once() {
476        let cache = Arc::new(Mutex::new(HashMap::new()));
477        let start = Arc::new(Barrier::new(3));
478        let initialization_count = Arc::new(AtomicUsize::new(0));
479        let mut handles = Vec::new();
480
481        for _ in 0..2 {
482            let cache = Arc::clone(&cache);
483            let start = Arc::clone(&start);
484            let initialization_count = Arc::clone(&initialization_count);
485            handles.push(std::thread::spawn(move || {
486                start.wait();
487                cached_modules(&cache, cache_key(0), || {
488                    initialization_count.fetch_add(1, Ordering::Relaxed);
489                    Vec::new()
490                })
491            }));
492        }
493        start.wait();
494
495        let first = handles.remove(0).join().unwrap();
496        let second = handles.remove(0).join().unwrap();
497        assert_eq!(initialization_count.load(Ordering::Relaxed), 1);
498        assert!(Arc::ptr_eq(&first, &second));
499    }
500
501    #[test]
502    fn source_registry_interns_provider_candidate_once() {
503        let bytes: Arc<[u8]> = Arc::from(&b"A-MIB DEFINITIONS ::= BEGIN END"[..]);
504        let candidate = SourceCandidate::new(
505            "document-7",
506            crate::source::SourceOrigin::memory("buffer-7"),
507            "untitled MIB",
508            Arc::clone(&bytes),
509        );
510        let mut registry = SourceRegistry::default();
511
512        let (first_key, first) = registry.intern(3, &candidate).unwrap();
513        let (second_key, second) = registry.intern(3, &candidate).unwrap();
514
515        assert_eq!(first_key, second_key);
516        assert!(Arc::ptr_eq(&first, &second));
517        assert_eq!(registry.sources.len(), 1);
518        assert_eq!(first.bytes().as_ptr(), bytes.as_ptr());
519    }
520
521    #[test]
522    fn candidate_identity_is_scoped_by_provider() {
523        let bytes: Arc<[u8]> = Arc::from(&b"contents"[..]);
524        let candidate = SourceCandidate::new(
525            "shared-id",
526            crate::source::SourceOrigin::memory("shared-id"),
527            "shared",
528            bytes,
529        );
530        let mut registry = SourceRegistry::default();
531
532        let (_, first) = registry.intern(0, &candidate).unwrap();
533        let (_, second) = registry.intern(1, &candidate).unwrap();
534
535        assert_ne!(first.id(), second.id());
536        assert_eq!(registry.sources.len(), 2);
537    }
538
539    #[test]
540    fn modules_from_one_document_share_source_id_in_parallel_loading() {
541        let content: Arc<[u8]> = Arc::from(
542            &br#"
543FIRST-MIB DEFINITIONS ::= BEGIN END
544SECOND-MIB DEFINITIONS ::= BEGIN END
545"#[..],
546        );
547        let source = SharedDocumentSource::new(
548            ["FIRST-MIB", "SECOND-MIB"],
549            "both-modules",
550            SourceOrigin::custom("test", "both-modules"),
551            "two modules",
552            content,
553        );
554
555        let mib = Loader::new()
556            .source(Box::new(source))
557            .parallelism(4)
558            .load()
559            .expect("multi-module document should load");
560
561        let first = module_document(&mib, "FIRST-MIB");
562        let second = module_document(&mib, "SECOND-MIB");
563        assert_eq!(first.id(), second.id());
564        assert!(std::ptr::eq(first, second));
565    }
566
567    #[test]
568    fn every_resolved_source_range_is_retained_and_in_bounds() {
569        const CAPABILITIES: &[u8] = br#"
570CAP-RANGE-MIB DEFINITIONS ::= BEGIN
571IMPORTS
572    AGENT-CAPABILITIES FROM SNMPv2-CONF
573    enterprises FROM SNMPv2-SMI;
574
575capRange AGENT-CAPABILITIES
576    PRODUCT-RELEASE "1.0"
577    STATUS current
578    DESCRIPTION "Range invariant capability."
579    SUPPORTS EXAMPLE-FULL-MIB
580        INCLUDES { exScalarGroup }
581        VARIATION exDeviceName
582            ACCESS read-only
583            DESCRIPTION "Read-only."
584    ::= { enterprises 99990 }
585
586END
587"#;
588        let source = crate::source::memory_modules([
589            (
590                "EXAMPLE-FULL-MIB",
591                include_bytes!("../tests/data/example-full-mib.txt").as_slice(),
592            ),
593            ("CAP-RANGE-MIB", CAPABILITIES),
594        ]);
595        let mut diagnostics = DiagnosticConfig::verbose();
596        diagnostics.fail_at = Severity::Fatal;
597        let mib = Loader::new()
598            .source(source)
599            .modules(["EXAMPLE-FULL-MIB", "CAP-RANGE-MIB"])
600            .diagnostic_config(diagnostics)
601            .load()
602            .expect("range invariant fixture should load");
603
604        assert!(!mib.objects.is_empty());
605        assert!(!mib.compliances.is_empty());
606        assert!(!mib.capabilities.is_empty());
607        assert_all_resolved_ranges_are_retained(&mib);
608    }
609
610    #[test]
611    fn distinct_documents_have_distinct_source_ids() {
612        let mib = Loader::new()
613            .source(crate::source::memory_modules([
614                (
615                    "FIRST-MIB",
616                    b"FIRST-MIB DEFINITIONS ::= BEGIN END".as_slice(),
617                ),
618                (
619                    "SECOND-MIB",
620                    b"SECOND-MIB DEFINITIONS ::= BEGIN END".as_slice(),
621                ),
622            ]))
623            .modules(["FIRST-MIB", "SECOND-MIB"])
624            .load()
625            .expect("separate memory documents should load");
626
627        assert_ne!(
628            module_document(&mib, "FIRST-MIB").id(),
629            module_document(&mib, "SECOND-MIB").id()
630        );
631    }
632
633    #[test]
634    fn mib_retains_document_and_original_bytes_after_registry_drops() {
635        let bytes: Arc<[u8]> = Arc::from(&b"RETAINED-MIB DEFINITIONS ::= BEGIN END"[..]);
636        let weak_bytes = Arc::downgrade(&bytes);
637        let expected_pointer = bytes.as_ptr();
638        let source = SharedDocumentSource::new(
639            ["RETAINED-MIB"],
640            "retained",
641            SourceOrigin::custom("test", "retained"),
642            "retained source",
643            Arc::clone(&bytes),
644        );
645        drop(bytes);
646
647        let mib = Loader::new()
648            .source(Box::new(source))
649            .modules(["RETAINED-MIB"])
650            .load()
651            .expect("retained source should load");
652
653        let document = module_document(&mib, "RETAINED-MIB");
654        assert_eq!(document.bytes().as_ptr(), expected_pointer);
655        assert_eq!(
656            weak_bytes
657                .upgrade()
658                .expect("Mib should retain source bytes")
659                .as_ptr(),
660            expected_pointer
661        );
662
663        drop(mib);
664        assert!(weak_bytes.upgrade().is_none());
665    }
666
667    #[test]
668    fn resolved_modules_reach_all_source_origin_kinds() {
669        static TEMP_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
670
671        let file_path = std::env::temp_dir().join(format!(
672            "mib-rs-source-origin-{}-{}.mib",
673            std::process::id(),
674            TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed)
675        ));
676        std::fs::write(&file_path, b"FILE-ORIGIN-MIB DEFINITIONS ::= BEGIN END")
677            .expect("write temporary MIB");
678        let file_source = crate::source::file(&file_path).expect("create file source");
679
680        let custom = SharedDocumentSource::new(
681            ["CUSTOM-ORIGIN-MIB"],
682            "custom-origin",
683            SourceOrigin::custom("database", "record-7"),
684            "database record 7",
685            Arc::from(&b"CUSTOM-ORIGIN-MIB DEFINITIONS ::= BEGIN END"[..]),
686        );
687        let result = Loader::new()
688            .source(file_source)
689            .source(crate::source::memory(
690                "MEMORY-ORIGIN-MIB",
691                b"MEMORY-ORIGIN-MIB DEFINITIONS ::= BEGIN END".as_slice(),
692            ))
693            .source(Box::new(custom))
694            .modules([
695                "FILE-ORIGIN-MIB",
696                "MEMORY-ORIGIN-MIB",
697                "CUSTOM-ORIGIN-MIB",
698                "SNMPv2-SMI",
699            ])
700            .load();
701        std::fs::remove_file(&file_path).expect("remove temporary MIB");
702        let mib = result.expect("all source origin kinds should load");
703
704        assert!(matches!(
705            module_document(&mib, "FILE-ORIGIN-MIB").origin(),
706            SourceOrigin::File { path } if path == &file_path
707        ));
708        assert_eq!(
709            module_document(&mib, "MEMORY-ORIGIN-MIB").origin(),
710            &SourceOrigin::memory("MEMORY-ORIGIN-MIB")
711        );
712        assert_eq!(
713            module_document(&mib, "CUSTOM-ORIGIN-MIB").origin(),
714            &SourceOrigin::custom("database", "record-7")
715        );
716        assert_eq!(
717            module_document(&mib, "SNMPv2-SMI").origin(),
718            &SourceOrigin::embedded("SNMPv2-SMI")
719        );
720    }
721
722    #[test]
723    fn generated_records_have_no_source_id() {
724        let module = ir::Module::new("GENERATED-MIB".to_string(), None);
725        assert!(module.source_id.is_none());
726
727        let resolved = crate::mib::module::ModuleData::new("GENERATED-MIB".to_string());
728        assert!(resolved.source_id.is_none());
729
730        let mib = Mib::new();
731        assert!(mib.sources.is_empty());
732        assert!(mib.tree.get(mib.tree.root()).module.is_none());
733
734        let mib = Loader::new()
735            .modules(["SNMPv2-SMI"])
736            .load()
737            .expect("embedded foundation should load");
738        assert_eq!(
739            module_document(&mib, "SNMPv2-SMI").origin(),
740            &SourceOrigin::embedded("SNMPv2-SMI")
741        );
742        let generated_types: Vec<_> = mib.types.iter().filter(|typ| typ.range.is_none()).collect();
743        assert!(!generated_types.is_empty());
744        for typ in generated_types {
745            assert!(typ.range.is_none());
746            assert!(typ.syntax_range.is_none());
747            assert!(typ.sizes.iter().all(|range| range.range.is_none()));
748            assert!(typ.ranges.iter().all(|range| range.range.is_none()));
749        }
750        assert!(mib.root_node().range().is_none());
751    }
752
753    #[test]
754    fn threshold_report_retains_sources_after_failed_mib_is_dropped() {
755        let bytes: Arc<[u8]> = Arc::from(
756            &br#"LEAK-CHECK-MIB { 01 } DEFINITIONS ::= BEGIN
757badName OBJECT IDENTIFIER ::= { iso 99999 }
758END
759"#[..],
760        );
761        let weak_bytes = Arc::downgrade(&bytes);
762        let source = SharedDocumentSource::new(
763            ["LEAK-CHECK-MIB"],
764            "leak-check",
765            SourceOrigin::memory("leak-check"),
766            "leak check",
767            Arc::clone(&bytes),
768        );
769        drop(bytes);
770        let mut diagnostics = DiagnosticConfig::default();
771        diagnostics
772            .overrides
773            .insert(DiagCode::NumberLeadingZero, Severity::Severe);
774
775        let result = Loader::new()
776            .source(Box::new(source))
777            .modules(["LEAK-CHECK-MIB"])
778            .diagnostic_config(diagnostics)
779            .load();
780
781        let Err(LoadError::DiagnosticThreshold { report }) = result else {
782            panic!("expected diagnostic threshold report");
783        };
784        assert!(weak_bytes.upgrade().is_some());
785        let entry = report
786            .iter()
787            .find(|entry| entry.diagnostic().code == DiagCode::NumberLeadingZero)
788            .expect("expected threshold-triggering diagnostic");
789        assert_eq!(entry.slice().unwrap(), Some(&b"01"[..]));
790        assert!(entry.render().unwrap().contains(":1:18-1:20"));
791        drop(report);
792        assert!(weak_bytes.upgrade().is_none());
793    }
794}
795
796impl Loader {
797    /// Execute the full load pipeline and return the resolved [`Mib`].
798    ///
799    /// Runs source discovery, parallel parsing, lowering, and resolution.
800    ///
801    /// # Errors
802    ///
803    /// Returns [`LoadError::NoSources`] if neither sources, system paths, nor
804    /// an explicit module selection are configured,
805    /// [`LoadError::MissingModules`] if explicitly requested modules cannot
806    /// be found, [`LoadError::DiagnosticThreshold`] if any diagnostic
807    /// exceeds the configured severity threshold, or [`LoadError::Io`] on
808    /// file read failures.
809    pub fn load(self) -> Result<Mib, LoadError> {
810        load(self)
811    }
812}
813
814type CandidateKey = (usize, CandidateId);
815type ModuleCacheEntry = Arc<OnceLock<Arc<Vec<ir::Module>>>>;
816type SharedModuleCache = Mutex<HashMap<CandidateKey, ModuleCacheEntry>>;
817
818#[derive(Debug, Default)]
819struct SourceRegistry {
820    sources: SourceSet,
821    documents: HashMap<CandidateKey, Arc<SourceDocument>>,
822}
823
824impl SourceRegistry {
825    fn intern(
826        &mut self,
827        provider_index: usize,
828        candidate: &SourceCandidate,
829    ) -> Result<(CandidateKey, Arc<SourceDocument>), LoadError> {
830        let key = (provider_index, candidate.identity().clone());
831        if let Some(document) = self.documents.get(&key) {
832            return Ok((key, Arc::clone(document)));
833        }
834
835        let document = self
836            .sources
837            .insert_shared(
838                candidate.origin().clone(),
839                candidate.label(),
840                Arc::clone(candidate.shared_bytes()),
841            )
842            .map_err(LoadError::from_source)?;
843        self.documents.insert(key.clone(), Arc::clone(&document));
844        Ok((key, document))
845    }
846
847    fn into_sources(self) -> SourceSet {
848        self.sources
849    }
850}
851
852struct LoadedModules {
853    modules: Vec<ir::Module>,
854    sources: SourceSet,
855}
856
857fn cached_modules(
858    cache: &SharedModuleCache,
859    key: CandidateKey,
860    decode: impl FnOnce() -> Vec<ir::Module>,
861) -> Arc<Vec<ir::Module>> {
862    let entry = {
863        let mut cache = cache.lock().unwrap();
864        cache
865            .entry(key)
866            .or_insert_with(|| Arc::new(OnceLock::new()))
867            .clone()
868    };
869
870    entry.get_or_init(|| Arc::new(decode())).clone()
871}
872
873#[derive(Debug)]
874struct ModuleCandidate {
875    source_index: usize,
876    name: String,
877}
878
879/// Load all modules from all sources in parallel.
880fn load_all_modules(
881    sources: &[Box<dyn Source>],
882    diag_config: &DiagnosticConfig,
883    parallelism: Option<usize>,
884) -> Result<LoadedModules, LoadError> {
885    // Keep every source advertising a name until decoding confirms which
886    // candidate actually contains the module. Only then is precedence fixed.
887    let mut module_indexes = HashMap::<String, usize>::new();
888    let mut all_modules: Vec<(String, Vec<ModuleCandidate>)> = Vec::new();
889    for (source_index, source) in sources.iter().enumerate() {
890        let names = source.list_modules().map_err(LoadError::Io)?;
891        let mut seen_in_source = HashSet::new();
892        for name in names {
893            if !seen_in_source.insert(name.clone()) {
894                continue;
895            }
896            let candidate = ModuleCandidate {
897                source_index,
898                name: name.clone(),
899            };
900            if let Some(&index) = module_indexes.get(&name) {
901                all_modules[index].1.push(candidate);
902            } else {
903                module_indexes.insert(name.clone(), all_modules.len());
904                all_modules.push((name, vec![candidate]));
905            }
906        }
907    }
908
909    info!(
910        target: "mib_rs::load",
911        component = "load",
912        phase = "parallel_decode",
913        module_count = all_modules.len(),
914        "parallel loading",
915    );
916
917    // Cache each provider-scoped physical candidate independently of the
918    // module name through which it was discovered.
919    let document_cache: SharedModuleCache = Mutex::new(HashMap::new());
920    let source_registry = Mutex::new(SourceRegistry::default());
921
922    // Parallel load using std::thread::scope with an atomic work queue.
923    let thread_count =
924        parallelism.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get()));
925    let next_idx = AtomicUsize::new(0);
926    let error: Mutex<Option<LoadError>> = Mutex::new(None);
927    let collected: Mutex<HashMap<String, ir::Module>> = Mutex::new(HashMap::new());
928
929    std::thread::scope(|s| {
930        for _ in 0..thread_count {
931            s.spawn(|| {
932                let mut local_modules: Vec<(String, ir::Module)> = Vec::new();
933                loop {
934                    let idx = next_idx.fetch_add(1, Ordering::Relaxed);
935                    if idx >= all_modules.len() {
936                        break;
937                    }
938                    // Check if another thread hit an error.
939                    if error.lock().unwrap().is_some() {
940                        break;
941                    }
942
943                    let (name, candidates) = &all_modules[idx];
944                    let span = debug_span!(
945                        target: "mib_rs::load",
946                        "load_module",
947                        component = "load",
948                        module = %name,
949                    );
950                    let _guard = span.enter();
951
952                    'sources: for candidate in candidates {
953                        let src = &sources[candidate.source_index];
954                        for result in src.find_candidates(&candidate.name) {
955                            let result = match result {
956                                Ok(result) => result,
957                                Err(error_value) => {
958                                    *error.lock().unwrap() = Some(LoadError::Io(error_value));
959                                    break 'sources;
960                                }
961                            };
962                            let (key, document) = match source_registry
963                                .lock()
964                                .unwrap()
965                                .intern(candidate.source_index, &result)
966                            {
967                                Ok(interned) => interned,
968                                Err(error_value) => {
969                                    *error.lock().unwrap() = Some(error_value);
970                                    break 'sources;
971                                }
972                            };
973                            let cached = cached_modules(&document_cache, key, || {
974                                decode_modules(&document, diag_config)
975                            });
976
977                            // A source advertisement is only a candidate until
978                            // its decoded content contains the requested module.
979                            if let Some(target) = cached.iter().find(|m| m.name == *name) {
980                                local_modules.push((name.clone(), target.clone()));
981                                break 'sources;
982                            }
983                            debug!(
984                                target: "mib_rs::load",
985                                component = "load",
986                                module = %name,
987                                source_index = candidate.source_index,
988                                source = result.label(),
989                                reason = "decoded_module_missing",
990                                "candidate did not contain advertised module",
991                            );
992                        }
993                    }
994                }
995                // Merge local results.
996                let mut map = collected.lock().unwrap();
997                for (name, module) in local_modules {
998                    map.entry(name).or_insert(module);
999                }
1000            });
1001        }
1002    });
1003
1004    if let Some(e) = error.into_inner().unwrap() {
1005        return Err(e);
1006    }
1007    let modules = collected.into_inner().unwrap();
1008
1009    info!(
1010        target: "mib_rs::load",
1011        component = "load",
1012        phase = "parallel_decode",
1013        module_count = modules.len(),
1014        "parallel loading complete",
1015    );
1016
1017    let registry = source_registry.into_inner().unwrap();
1018    Ok(LoadedModules {
1019        modules: collect_modules(modules),
1020        sources: registry.into_sources(),
1021    })
1022}
1023
1024/// Load specific modules and their dependencies sequentially.
1025fn load_modules_by_name(
1026    sources: &[Box<dyn Source>],
1027    names: &[String],
1028    diag_config: &DiagnosticConfig,
1029) -> Result<LoadedModules, LoadError> {
1030    let mut modules: HashMap<String, ir::Module> = HashMap::new();
1031    let mut document_cache: HashMap<CandidateKey, Vec<ir::Module>> = HashMap::new();
1032    let mut source_registry = SourceRegistry::default();
1033
1034    fn load_one(
1035        name: &str,
1036        sources: &[Box<dyn Source>],
1037        modules: &mut HashMap<String, ir::Module>,
1038        document_cache: &mut HashMap<CandidateKey, Vec<ir::Module>>,
1039        source_registry: &mut SourceRegistry,
1040        diag_config: &DiagnosticConfig,
1041    ) -> Result<(), LoadError> {
1042        if modules.contains_key(name) {
1043            return Ok(());
1044        }
1045
1046        // A Source::find result is only a candidate until decoding confirms
1047        // that its content contains the requested module. Phantom source
1048        // advertisements must not shadow valid modules in later sources.
1049        let mut target = None;
1050        'sources: for (source_index, source) in sources.iter().enumerate() {
1051            for result in source.find_candidates(name) {
1052                let result = result.map_err(LoadError::Io)?;
1053                let (key, document) = source_registry.intern(source_index, &result)?;
1054                let mods = document_cache
1055                    .entry(key)
1056                    .or_insert_with(|| decode_modules(&document, diag_config));
1057                if let Some(module) = mods.iter().find(|module| module.name == name) {
1058                    target = Some(module.clone());
1059                    break 'sources;
1060                }
1061
1062                debug!(
1063                    target: "mib_rs::load",
1064                    component = "load",
1065                    module = %name,
1066                    source = result.label(),
1067                    reason = "decoded_module_missing",
1068                    "candidate did not contain advertised module",
1069                );
1070            }
1071        }
1072
1073        let target = match target {
1074            Some(target) => target,
1075            None => {
1076                debug!(
1077                    target: "mib_rs::load",
1078                    component = "load",
1079                    module = %name,
1080                    reason = "not_found",
1081                    "module not found",
1082                );
1083                return Ok(());
1084            }
1085        };
1086
1087        // Collect import module names before inserting.
1088        let import_modules: Vec<String> = target
1089            .imports
1090            .iter()
1091            .map(|imp| imp.module.clone())
1092            .collect::<HashSet<_>>()
1093            .into_iter()
1094            .collect();
1095
1096        modules.insert(name.to_string(), target);
1097
1098        // Recursively load dependencies.
1099        for dep in import_modules {
1100            load_one(
1101                &dep,
1102                sources,
1103                modules,
1104                document_cache,
1105                source_registry,
1106                diag_config,
1107            )?;
1108        }
1109
1110        Ok(())
1111    }
1112
1113    for name in names {
1114        load_one(
1115            name,
1116            sources,
1117            &mut modules,
1118            &mut document_cache,
1119            &mut source_registry,
1120            diag_config,
1121        )?;
1122    }
1123
1124    // Foundation modules are always present, with configured sources taking
1125    // precedence over the embedded fallback on a per-module basis.
1126    for name in lower::base_modules::base_module_names() {
1127        load_one(
1128            name,
1129            sources,
1130            &mut modules,
1131            &mut document_cache,
1132            &mut source_registry,
1133            diag_config,
1134        )?;
1135    }
1136
1137    Ok(LoadedModules {
1138        modules: collect_modules(modules),
1139        sources: source_registry.into_sources(),
1140    })
1141}
1142
1143/// Return modules sorted by name.
1144fn collect_modules(modules: HashMap<String, ir::Module>) -> Vec<ir::Module> {
1145    let mut mods: Vec<ir::Module> = modules.into_values().collect();
1146    mods.sort_by(|a, b| a.name.cmp(&b.name));
1147    mods
1148}
1149
1150/// Run the heuristic/parse/lower pipeline on raw MIB content.
1151fn decode_modules(document: &SourceDocument, diag_config: &DiagnosticConfig) -> Vec<ir::Module> {
1152    let content = document.bytes();
1153    let source_label = document.label();
1154    let span = debug_span!(
1155        target: "mib_rs::load",
1156        "decode_modules",
1157        component = "load",
1158        source = source_label,
1159        byte_count = content.len(),
1160    );
1161    let _guard = span.enter();
1162
1163    if !scan::looks_like_mib_content(content) {
1164        debug!(
1165            target: "mib_rs::load",
1166            component = "load",
1167            source = source_label,
1168            reason = "heuristic_rejected",
1169            "content rejected by heuristic",
1170        );
1171        return Vec::new();
1172    }
1173
1174    let ast_modules = parser::parse(document, diag_config);
1175    debug!(
1176        target: "mib_rs::load",
1177        component = "load",
1178        source = source_label,
1179        ast_module_count = ast_modules.len(),
1180        "parsed source into AST modules",
1181    );
1182
1183    let mut modules = Vec::new();
1184    for am in ast_modules {
1185        modules.push(lower::lower(am, document, diag_config));
1186    }
1187    debug!(
1188        target: "mib_rs::load",
1189        component = "load",
1190        source = source_label,
1191        ir_module_count = modules.len(),
1192        "lowered source into IR modules",
1193    );
1194    modules
1195}
1196
1197/// Check the resolved Mib for diagnostic threshold violations and missing modules.
1198fn check_load_result(
1199    mib: Mib,
1200    diag_config: &DiagnosticConfig,
1201    requested_modules: Option<&[String]>,
1202) -> Result<Mib, LoadError> {
1203    // Check for missing requested modules.
1204    if let Some(requested) = requested_modules {
1205        let mut missing = Vec::new();
1206        for name in requested {
1207            if mib.module_by_name(name).is_none() {
1208                missing.push(name.clone());
1209            }
1210        }
1211        if !missing.is_empty() {
1212            warn!(
1213                target: "mib_rs::load",
1214                component = "load",
1215                reason = "missing_requested_modules",
1216                missing_module_count = missing.len(),
1217                "requested modules not found",
1218            );
1219            return Err(LoadError::MissingModules(missing));
1220        }
1221    }
1222
1223    // Check FailAt threshold.
1224    if let Some(diagnostic) = mib
1225        .diagnostics()
1226        .iter()
1227        .find(|diagnostic| diag_config.should_fail(diagnostic.severity))
1228    {
1229        warn!(
1230            target: "mib_rs::load",
1231            component = "load",
1232            reason = "diagnostic_threshold",
1233            severity = ?diagnostic.severity,
1234            code = %diagnostic.code,
1235            "diagnostic threshold exceeded",
1236        );
1237
1238        let report = mib.into_diagnostic_report();
1239        return Err(LoadError::DiagnosticThreshold { report });
1240    }
1241
1242    Ok(mib)
1243}