Skip to main content

etdl_compiler/
stdlib.rs

1//! ETDL Standard Library resolution and library expansion.
2//!
3//! A **library** is a reusable component catalog written in ordinary ETDL
4//! (see [`etdl_parser::ast::LibraryDocument`]). A document declares which
5//! libraries it uses in `libraries:` (see [`LibraryImport`]) and references
6//! their contents by **qualified id**: `<library-name>.<short-name>`, e.g.
7//! `std.events.NetworkTimeout` used as a gate input exactly like any other
8//! basic-event id.
9//!
10//! [`expand_libraries`] is the *only* new compiler primitive this requires:
11//! given a parsed document, it resolves every declared library
12//! (transitively, with cycle detection) and returns a **new** document with
13//! each referenced qualified id spliced into the fault tree that references
14//! it, as an ordinary `BasicEvent`. Everything downstream — type checking,
15//! fault-tree evaluation, code generation — is completely unaware libraries
16//! exist; a qualified id is just another map key to them. This is
17//! deliberate: the standard library is a source-expansion concern, not a
18//! collection of compiler special cases.
19//!
20//! ## Layers
21//!
22//! ```text
23//! ETDL Core               language, compiler, runtime primitives
24//!    |
25//! ETDL Standard Library   built-in, embedded, `std.*` (this module's BuiltIn kind)
26//!    |
27//! ETDL Domain Libraries   e.g. the reliability supplement (a *separate*,
28//!    |                    already-existing mechanism: compiled-in Rust via
29//!    |                    `supplements:` + `extension::EtdlExtension` — not
30//!    |                    reimplemented or replaced by this module)
31//! ETDL Optional Libraries installed separately, resolved from a search path
32//!    |                    (this module's Optional kind)
33//! User Libraries          project-local, resolved relative to the document
34//!                         (this module's User kind)
35//! ```
36//!
37//! ## What resolves where
38//!
39//! - Names starting with `std.` **only** resolve from the embedded built-in
40//!   registry. This is the whole of the anti-shadowing rule: it is not a
41//!   precedence order that optional/user libraries could win by being
42//!   listed first, it is a hard partition. See [`LibraryError::Shadowing`].
43//! - Everything else is searched, in order: [`LibraryResolver::search_paths`]
44//!   (optional libraries), then `<base_dir>/lib/<name>/lib.etdl` (a user
45//!   library local to the importing document, mirroring how
46//!   `asyncapi_imports` resolves relative paths against `base_dir`).
47//!
48//! ## Reliability compatibility
49//!
50//! This module does not depend on `etdl-reliability-core`, and nothing in
51//! the existing reliability pipeline (evidence, estimation, artifacts,
52//! calibration, observation, dependency/CCF analysis) depends on this
53//! module either. `expand_libraries` runs as an independent step alongside
54//! (not instead of) `reliability::resolve_reliability`; a future domain
55//! library MAY depend on `std.*` without requiring any reliability change.
56
57use std::collections::BTreeMap;
58use std::path::{Path, PathBuf};
59
60use etdl_parser::ast::{BasicEvent, EtlDocument, FaultTree, LibraryDocument, LibraryImport};
61
62/// Schema identity for the built-in standard library package (distinct from
63/// `doc.etdl`, from crate versions, and from `ARTIFACT_SCHEMA` — see
64/// `docs/reference/standard-library.md` for the full versioning-axis table).
65pub const STDLIB_SCHEMA: &str = "etdl.stdlib/1.0";
66
67/// The reserved namespace prefix for the built-in standard library.
68pub const STD_NAMESPACE: &str = "std.";
69
70/// Where a resolved library's definitions came from.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LibraryKind {
73    /// Embedded in the compiler binary; available without installing
74    /// anything (`std.*`).
75    BuiltIn,
76    /// Found on a configured library search path.
77    Optional,
78    /// Found relative to the importing document (`<base_dir>/lib/<name>/`).
79    User,
80}
81
82impl LibraryKind {
83    pub fn label(self) -> &'static str {
84        match self {
85            LibraryKind::BuiltIn => "built-in",
86            LibraryKind::Optional => "optional",
87            LibraryKind::User => "user",
88        }
89    }
90}
91
92/// One resolved library: its identity plus the basic-event definitions it
93/// provides, keyed by short (unqualified) name.
94#[derive(Debug, Clone)]
95pub struct ResolvedLibrary {
96    pub name: String,
97    pub version: String,
98    pub kind: LibraryKind,
99    pub description: Option<String>,
100    pub basic_events: BTreeMap<String, BasicEvent>,
101    /// Named, reusable boolean combinators built from the same core
102    /// `GateType` a document's own `gates:` already uses (see
103    /// `splice_referenced_definitions`). A gate's own `inputs` may
104    /// reference further qualified ids, resolved transitively.
105    pub gates: BTreeMap<String, etdl_parser::ast::Gate>,
106    pub depends_on: Vec<LibraryImport>,
107}
108
109impl ResolvedLibrary {
110    /// A lightweight, serializable identity summary for build provenance —
111    /// deliberately without the resolved component definitions themselves.
112    pub fn provenance(&self) -> LibraryProvenance {
113        LibraryProvenance {
114            name: self.name.clone(),
115            version: self.version.clone(),
116            kind: self.kind.label().to_string(),
117        }
118    }
119}
120
121/// Identity of one resolved library, for build-manifest provenance.
122#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
123pub struct LibraryProvenance {
124    pub name: String,
125    pub version: String,
126    pub kind: String,
127}
128
129/// A problem resolving a declared library. Never silently substitutes
130/// absence or a different library; every failure is reported.
131#[derive(Debug, Clone, thiserror::Error)]
132pub enum LibraryError {
133    #[error("library '{name}' was not found ({searched})")]
134    NotFound { name: String, searched: String },
135    #[error(
136        "library '{name}': requested version '{requested}' is incompatible with the resolved \
137         version '{found}' (major version must match)"
138    )]
139    IncompatibleVersion {
140        name: String,
141        requested: String,
142        found: String,
143    },
144    #[error("cyclic library dependency: {}", chain.join(" -> "))]
145    Cyclic { chain: Vec<String> },
146    #[error("library '{name}': {reason}")]
147    InvalidManifest { name: String, reason: String },
148    #[error(
149        "library '{name}' is reserved for the built-in standard library ('{prefix}' prefix) \
150         and cannot be resolved from an optional or user source"
151    )]
152    Shadowing { name: String, prefix: String },
153}
154
155/// The built-in standard library's embedded source, `(name, raw ETDL)`.
156/// Growing the standard library means adding an entry here and a file under
157/// `etdl-compiler/stdlib/`; nothing else in the compiler needs to change.
158///
159/// These live under `etdl-compiler/stdlib/` (a sibling of `src/`), not
160/// `etdl-compiler/src/`, so a reader can find `std.events` etc. as ordinary
161/// ETDL source without digging into compiler internals. They can't live at
162/// the repository root: `cargo package`/`cargo publish` only includes files
163/// within the crate's own directory, and `include_str!` of a path outside it
164/// silently works in a workspace checkout but fails when the crate is
165/// packaged standalone for crates.io.
166fn builtin_sources() -> &'static [(&'static str, &'static str)] {
167    &[
168        ("std.events", include_str!("../stdlib/events/lib.etdl")),
169        ("std.logic", include_str!("../stdlib/logic/lib.etdl")),
170        (
171            "std.probability",
172            include_str!("../stdlib/probability/lib.etdl"),
173        ),
174    ]
175}
176
177/// Resolves declared libraries to parsed [`LibraryDocument`]s.
178#[derive(Debug, Clone, Default)]
179pub struct LibraryResolver {
180    /// Optional-library search directories, checked in this order. Each may
181    /// contain `<library-name>/lib.etdl`. Never consulted for `std.*`.
182    pub search_paths: Vec<PathBuf>,
183}
184
185impl LibraryResolver {
186    pub fn new() -> Self {
187        LibraryResolver::default()
188    }
189
190    pub fn with_search_path(mut self, path: impl Into<PathBuf>) -> Self {
191        self.search_paths.push(path.into());
192        self
193    }
194
195    /// Names of every built-in standard library module.
196    pub fn builtin_names() -> Vec<&'static str> {
197        builtin_sources().iter().map(|(n, _)| *n).collect()
198    }
199
200    /// Load and parse one library by name, without resolving its
201    /// dependencies. `base_dir` is the importing document's directory, used
202    /// only for user-library resolution (mirrors `asyncapi_imports`).
203    fn load(&self, name: &str, base_dir: &Path) -> Result<(LibraryKind, LibraryDocument), LibraryError> {
204        if let Some((_, src)) = builtin_sources().iter().find(|(n, _)| *n == name) {
205            return Ok((LibraryKind::BuiltIn, parse_library(name, src)?));
206        }
207
208        let user_path = base_dir.join("lib").join(name).join("lib.etdl");
209        let is_reserved = name.starts_with(STD_NAMESPACE);
210
211        if is_reserved {
212            // `std.*` never resolves from a search path or user directory,
213            // even if one happens to exist under this name — it is shadowed,
214            // not used, so the reserved namespace stays protected.
215            let shadow_found = self
216                .search_paths
217                .iter()
218                .any(|d| d.join(name).join("lib.etdl").exists())
219                || user_path.exists();
220            if shadow_found {
221                return Err(LibraryError::Shadowing {
222                    name: name.to_string(),
223                    prefix: STD_NAMESPACE.to_string(),
224                });
225            }
226            return Err(LibraryError::NotFound {
227                name: name.to_string(),
228                searched: "the built-in standard library registry (reserved namespace: never \
229                           searched elsewhere)"
230                    .to_string(),
231            });
232        }
233
234        for search_dir in &self.search_paths {
235            let path = search_dir.join(name).join("lib.etdl");
236            if path.exists() {
237                let content = read_library_file(&path, name)?;
238                return Ok((LibraryKind::Optional, parse_library(name, &content)?));
239            }
240        }
241
242        if user_path.exists() {
243            let content = read_library_file(&user_path, name)?;
244            return Ok((LibraryKind::User, parse_library(name, &content)?));
245        }
246
247        Err(LibraryError::NotFound {
248            name: name.to_string(),
249            searched: format!(
250                "built-in registry, {} search path(s), and '{}'",
251                self.search_paths.len(),
252                user_path.display()
253            ),
254        })
255    }
256}
257
258fn read_library_file(path: &Path, name: &str) -> Result<String, LibraryError> {
259    std::fs::read_to_string(path).map_err(|e| LibraryError::InvalidManifest {
260        name: name.to_string(),
261        reason: format!("cannot read library file: {e}"),
262    })
263}
264
265fn parse_library(expected_name: &str, content: &str) -> Result<LibraryDocument, LibraryError> {
266    let doc = etdl_parser::parse_library_document(content).map_err(|e| LibraryError::InvalidManifest {
267        name: expected_name.to_string(),
268        reason: e,
269    })?;
270    if doc.library.name != expected_name {
271        return Err(LibraryError::InvalidManifest {
272            name: expected_name.to_string(),
273            reason: format!(
274                "declares name '{}' but was resolved as '{}'",
275                doc.library.name, expected_name
276            ),
277        });
278    }
279    Ok(doc)
280}
281
282/// The major component of a dotted version string (`"1.2"` -> `Some(1)`),
283/// the same rule already used for `doc.etdl` and `Supplement::version`.
284fn major_version(version: &str) -> Option<u64> {
285    let trimmed = version.trim();
286    if trimmed.is_empty() {
287        return None;
288    }
289    trimmed.split(['.', '+']).next()?.trim().parse().ok()
290}
291
292fn check_version_compatible(name: &str, requested: &str, found: &str) -> Result<(), LibraryError> {
293    match (major_version(requested), major_version(found)) {
294        (Some(r), Some(f)) if r == f => Ok(()),
295        _ => Err(LibraryError::IncompatibleVersion {
296            name: name.to_string(),
297            requested: requested.to_string(),
298            found: found.to_string(),
299        }),
300    }
301}
302
303/// Every built-in standard library module, resolved. For introspection
304/// (`etdl library list`) — the compile path resolves lazily and only
305/// resolves what a document actually declares.
306pub fn list_builtin() -> Vec<Result<ResolvedLibrary, LibraryError>> {
307    builtin_sources()
308        .iter()
309        .map(|(name, src)| {
310            parse_library(name, src).map(|doc| ResolvedLibrary {
311                name: (*name).to_string(),
312                version: doc.library.version.clone(),
313                kind: LibraryKind::BuiltIn,
314                description: doc.library.description.clone(),
315                basic_events: doc.components.basic_events.clone().unwrap_or_default(),
316                gates: doc.components.gates.clone().unwrap_or_default(),
317                depends_on: doc.library.depends_on.clone(),
318            })
319        })
320        .collect()
321}
322
323/// Resolve `import` and everything it transitively depends on into
324/// `resolved`, detecting cycles via `stack` (the names currently being
325/// resolved, in resolution order). Errors are collected, not short-circuited,
326/// so a caller sees every problem in one pass.
327fn resolve_transitively(
328    name: &str,
329    requested_version: &str,
330    base_dir: &Path,
331    resolver: &LibraryResolver,
332    resolved: &mut BTreeMap<String, ResolvedLibrary>,
333    stack: &mut Vec<String>,
334    errors: &mut Vec<LibraryError>,
335) {
336    // Check the in-progress stack BEFORE the resolved cache: a library is
337    // inserted into `resolved` before its own dependencies are walked (see
338    // below), so if it is re-encountered while still on the stack, that is
339    // exactly a cycle, not "already resolved".
340    if stack.iter().any(|n| n == name) {
341        let mut chain = stack.clone();
342        chain.push(name.to_string());
343        errors.push(LibraryError::Cyclic { chain });
344        return;
345    }
346    if let Some(existing) = resolved.get(name) {
347        if let Err(e) = check_version_compatible(name, requested_version, &existing.version) {
348            errors.push(e);
349        }
350        return;
351    }
352
353    stack.push(name.to_string());
354    match resolver.load(name, base_dir) {
355        Ok((kind, lib_doc)) => {
356            if let Err(e) = check_version_compatible(name, requested_version, &lib_doc.library.version) {
357                errors.push(e);
358            }
359            let depends_on = lib_doc.library.depends_on.clone();
360            let basic_events = lib_doc.components.basic_events.clone().unwrap_or_default();
361            let gates = lib_doc.components.gates.clone().unwrap_or_default();
362            resolved.insert(
363                name.to_string(),
364                ResolvedLibrary {
365                    name: name.to_string(),
366                    version: lib_doc.library.version.clone(),
367                    kind,
368                    description: lib_doc.library.description.clone(),
369                    basic_events,
370                    gates,
371                    depends_on: depends_on.clone(),
372                },
373            );
374            for dep in &depends_on {
375                resolve_transitively(&dep.name, &dep.version, base_dir, resolver, resolved, stack, errors);
376            }
377        }
378        Err(e) => errors.push(e),
379    }
380    stack.pop();
381}
382
383/// Every id referenced directly in `ft` that could plausibly be a qualified
384/// library reference (gate inputs and the top event's root cause). Does not
385/// look inside already-resolved library gates — see
386/// [`splice_referenced_definitions`], which walks those separately.
387fn referenced_ids(ft: &FaultTree) -> Vec<String> {
388    let mut ids = Vec::new();
389    ids.push(ft.top_event.root_cause.clone());
390    if let Some(gates) = &ft.gates {
391        for gate in gates.values() {
392            ids.extend(gate.inputs.iter().cloned());
393        }
394    }
395    ids
396}
397
398/// What a qualified id resolves to in a library: a reusable named boolean
399/// combinator (built from the same core `GateType` values a document's own
400/// `gates:` already uses) or a reusable named basic-event definition.
401fn lookup_qualified<'a, 'b>(
402    qualified_id: &'b str,
403    resolved: &'a BTreeMap<String, ResolvedLibrary>,
404) -> Option<(&'a ResolvedLibrary, &'b str)> {
405    for lib in resolved.values() {
406        let prefix = format!("{}.", lib.name);
407        if let Some(short_name) = qualified_id.strip_prefix(&prefix) {
408            return Some((lib, short_name));
409        }
410    }
411    None
412}
413
414/// Splice every library-provided gate or basic event `ft` transitively
415/// references into `ft`, under its qualified id — unless `ft` already
416/// declares that id itself (locally, or from an earlier splice pass), in
417/// which case the local declaration wins: a document can always override a
418/// library default by declaring the same qualified id itself, and a
419/// library-provided gate that references a qualified id the importer has
420/// overridden picks up the override, not the library's own placeholder.
421///
422/// This is a fixpoint over a worklist, not a single scan, because a
423/// library-provided *gate* may itself reference further qualified ids
424/// (its own placeholder inputs, or another library) that also need
425/// resolving before the fault tree is structurally complete.
426fn splice_referenced_definitions(ft: &mut FaultTree, resolved: &BTreeMap<String, ResolvedLibrary>) {
427    let mut worklist: Vec<String> = referenced_ids(ft);
428    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
429
430    while let Some(qualified_id) = worklist.pop() {
431        if !seen.insert(qualified_id.clone()) {
432            continue; // already processed this run (also guards a malformed
433                       // library gate that references itself).
434        }
435        if ft.basic_events.contains_key(&qualified_id) {
436            continue;
437        }
438        if ft.gates.as_ref().is_some_and(|g| g.contains_key(&qualified_id)) {
439            continue;
440        }
441        let Some((lib, short_name)) = lookup_qualified(&qualified_id, resolved) else {
442            continue; // not a library reference; left for ordinary
443                       // undefined-reference validation to report.
444        };
445        if let Some(gate) = lib.gates.get(short_name) {
446            worklist.extend(gate.inputs.iter().cloned());
447            ft.gates
448                .get_or_insert_with(BTreeMap::new)
449                .insert(qualified_id, gate.clone());
450        } else if let Some(be) = lib.basic_events.get(short_name) {
451            ft.basic_events.insert(qualified_id, be.clone());
452        }
453    }
454}
455
456/// Resolve every library `doc` declares (transitively, with cycle
457/// detection) and return a **new** document with library-provided basic
458/// events spliced into whichever fault trees reference them by qualified
459/// id. `doc` itself is never mutated. `required: true` (the default) means
460/// resolution failure is reported here as an error the caller should treat
461/// as fatal; `required: false` failures are also returned (the caller
462/// decides whether to downgrade them to a warning — see
463/// `validate::validate_libraries`, which applies exactly that policy).
464pub fn expand_libraries(
465    doc: &EtlDocument,
466    base_dir: &Path,
467    resolver: &LibraryResolver,
468) -> (EtlDocument, Vec<ResolvedLibrary>, Vec<LibraryError>) {
469    let mut errors = Vec::new();
470    let mut resolved: BTreeMap<String, ResolvedLibrary> = BTreeMap::new();
471    let mut stack: Vec<String> = Vec::new();
472
473    for import in &doc.libraries {
474        resolve_transitively(
475            &import.name,
476            &import.version,
477            base_dir,
478            resolver,
479            &mut resolved,
480            &mut stack,
481            &mut errors,
482        );
483    }
484
485    let mut expanded = doc.clone();
486    if let Some(fault_trees) = &mut expanded.fault_trees {
487        for ft in fault_trees.values_mut() {
488            splice_referenced_definitions(ft, &resolved);
489        }
490    }
491
492    (expanded, resolved.into_values().collect(), errors)
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use std::collections::BTreeMap;
499
500    fn doc_importing(libraries: Vec<LibraryImport>, inputs: Vec<&str>) -> EtlDocument {
501        let yaml = format!(
502            r#"
503etdl: "1.0.0"
504info: {{ title: "T", version: "1.0.0", domain: "D" }}
505eventTrees:
506  T:
507    initiatingEvent: {{ id: I, message: "a#/m", next: C }}
508    nodes:
509      C: {{ type: consequence, operation: terminate }}
510faultTrees:
511  FT:
512    topEvent: {{ id: Top, description: "t", rootCause: G }}
513    gates:
514      G: {{ type: OR, inputs: [{}] }}
515    basicEvents: {{}}
516"#,
517            inputs.iter().map(|i| format!("\"{i}\"")).collect::<Vec<_>>().join(", ")
518        );
519        let mut doc = etdl_parser::parse_document(&yaml).expect("valid doc");
520        doc.libraries = libraries;
521        doc
522    }
523
524    #[test]
525    fn resolves_builtin_and_splices_referenced_basic_event() {
526        let doc = doc_importing(
527            vec![LibraryImport {
528                name: "std.events".to_string(),
529                version: "1.0".to_string(),
530                required: true,
531            }],
532            vec!["std.events.NetworkTimeout", "LocalThing"],
533        );
534        let resolver = LibraryResolver::new();
535        let (expanded, resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
536        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
537        assert_eq!(resolved.len(), 1);
538        assert_eq!(resolved[0].kind, LibraryKind::BuiltIn);
539
540        let ft = &expanded.fault_trees.as_ref().unwrap()["FT"];
541        let be = ft
542            .basic_events
543            .get("std.events.NetworkTimeout")
544            .expect("spliced in");
545        assert!((be.probability.unwrap() - 0.001).abs() < 1e-12);
546        // Unreferenced library entries are not spliced in.
547        assert!(!ft.basic_events.contains_key("std.events.ProcessCrash"));
548        // The original document is untouched.
549        assert!(!doc.fault_trees.as_ref().unwrap()["FT"]
550            .basic_events
551            .contains_key("std.events.NetworkTimeout"));
552    }
553
554    #[test]
555    fn splices_a_library_gate_and_transitively_its_own_inputs() {
556        let dir = std::env::temp_dir().join(format!(
557            "etdl-stdlib-gate-splice-test-{:x}",
558            std::time::SystemTime::now()
559                .duration_since(std::time::UNIX_EPOCH)
560                .unwrap()
561                .as_nanos()
562        ));
563        std::fs::create_dir_all(dir.join("test.logic")).unwrap();
564        std::fs::write(
565            dir.join("test.logic").join("lib.etdl"),
566            r#"
567etdl: "1.0.0"
568library:
569  name: test.logic
570  version: "1.0"
571components:
572  basic_events:
573    InputA:
574      description: "placeholder input A"
575    InputB:
576      description: "placeholder input B"
577  gates:
578    AnyOf:
579      type: OR
580      inputs: ["test.logic.InputA", "test.logic.InputB"]
581"#,
582        )
583        .unwrap();
584
585        let doc = doc_importing(
586            vec![LibraryImport {
587                name: "test.logic".to_string(),
588                version: "1.0".to_string(),
589                required: true,
590            }],
591            vec!["test.logic.AnyOf", "LocalThing"],
592        );
593        let resolver = LibraryResolver::new().with_search_path(&dir);
594        let (expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
595        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
596
597        let ft = &expanded.fault_trees.as_ref().unwrap()["FT"];
598        // The gate itself was spliced in under its qualified id.
599        let gate = ft
600            .gates
601            .as_ref()
602            .and_then(|g| g.get("test.logic.AnyOf"))
603            .expect("gate spliced in");
604        assert_eq!(gate.inputs, vec!["test.logic.InputA", "test.logic.InputB"]);
605        // ...and its own (also-qualified) inputs were transitively resolved
606        // into basic events, not left dangling.
607        assert!(ft.basic_events.contains_key("test.logic.InputA"));
608        assert!(ft.basic_events.contains_key("test.logic.InputB"));
609
610        std::fs::remove_dir_all(&dir).ok();
611    }
612
613    #[test]
614    fn overriding_a_library_gates_placeholder_input_flows_through() {
615        // A document can override just ONE of a library gate's placeholder
616        // inputs by declaring that qualified id itself; the spliced gate
617        // then evaluates against the override, not the library default —
618        // the documented substitute for true template parameterization.
619        let dir = std::env::temp_dir().join(format!(
620            "etdl-stdlib-gate-override-test-{:x}",
621            std::time::SystemTime::now()
622                .duration_since(std::time::UNIX_EPOCH)
623                .unwrap()
624                .as_nanos()
625        ));
626        std::fs::create_dir_all(dir.join("test.logic")).unwrap();
627        std::fs::write(
628            dir.join("test.logic").join("lib.etdl"),
629            r#"
630etdl: "1.0.0"
631library:
632  name: test.logic
633  version: "1.0"
634components:
635  basic_events:
636    InputA:
637      description: "placeholder input A"
638    InputB:
639      description: "placeholder input B"
640  gates:
641    AnyOf:
642      type: OR
643      inputs: ["test.logic.InputA", "test.logic.InputB"]
644"#,
645        )
646        .unwrap();
647
648        let mut doc = doc_importing(
649            vec![LibraryImport {
650                name: "test.logic".to_string(),
651                version: "1.0".to_string(),
652                required: true,
653            }],
654            vec!["test.logic.AnyOf", "LocalThing"],
655        );
656        doc.fault_trees.as_mut().unwrap().get_mut("FT").unwrap().basic_events.insert(
657            "test.logic.InputA".to_string(),
658            etdl_parser::ast::BasicEvent {
659                description: "overridden".to_string(),
660                probability: Some(0.42),
661                failure_rate: None,
662                mission_time: None,
663                undeveloped: None,
664                event_type: None,
665                message: None,
666                extensions: BTreeMap::new(),
667            },
668        );
669        let resolver = LibraryResolver::new().with_search_path(&dir);
670        let (expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
671        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
672
673        let ft = &expanded.fault_trees.as_ref().unwrap()["FT"];
674        assert_eq!(
675            ft.basic_events["test.logic.InputA"].probability,
676            Some(0.42)
677        );
678        // The library's own InputB default is still used.
679        assert!(ft.basic_events["test.logic.InputB"].probability.is_none());
680
681        std::fs::remove_dir_all(&dir).ok();
682    }
683
684    #[test]
685    fn local_declaration_overrides_library_default() {
686        let mut doc = doc_importing(
687            vec![LibraryImport {
688                name: "std.events".to_string(),
689                version: "1.0".to_string(),
690                required: true,
691            }],
692            vec!["std.events.NetworkTimeout"],
693        );
694        doc.fault_trees.as_mut().unwrap().get_mut("FT").unwrap().basic_events.insert(
695            "std.events.NetworkTimeout".to_string(),
696            etdl_parser::ast::BasicEvent {
697                description: "overridden".to_string(),
698                probability: Some(0.5),
699                failure_rate: None,
700                mission_time: None,
701                undeveloped: None,
702                event_type: None,
703                message: None,
704                extensions: BTreeMap::new(),
705            },
706        );
707        let resolver = LibraryResolver::new();
708        let (expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
709        assert!(errors.is_empty());
710        let ft = &expanded.fault_trees.as_ref().unwrap()["FT"];
711        assert_eq!(ft.basic_events["std.events.NetworkTimeout"].probability, Some(0.5));
712    }
713
714    #[test]
715    fn missing_library_is_reported_not_silently_skipped() {
716        let doc = doc_importing(
717            vec![LibraryImport {
718                name: "std.nonexistent".to_string(),
719                version: "1.0".to_string(),
720                required: true,
721            }],
722            vec![],
723        );
724        let resolver = LibraryResolver::new();
725        let (_expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
726        assert_eq!(errors.len(), 1);
727        assert!(matches!(errors[0], LibraryError::NotFound { .. }));
728    }
729
730    #[test]
731    fn optional_library_cannot_shadow_std_namespace() {
732        let dir = std::env::temp_dir().join(format!(
733            "etdl-stdlib-shadow-test-{:x}",
734            std::time::SystemTime::now()
735                .duration_since(std::time::UNIX_EPOCH)
736                .unwrap()
737                .as_nanos()
738        ));
739        std::fs::create_dir_all(dir.join("std.events")).unwrap();
740        std::fs::write(
741            dir.join("std.events").join("lib.etdl"),
742            "etdl: \"1.0.0\"\nlibrary: { name: std.events, version: \"99.0\" }\ncomponents: {}\n",
743        )
744        .unwrap();
745
746        let doc = doc_importing(
747            vec![LibraryImport {
748                name: "std.events".to_string(),
749                version: "1.0".to_string(),
750                required: true,
751            }],
752            vec![],
753        );
754        let resolver = LibraryResolver::new().with_search_path(&dir);
755        let (_expanded, resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
756        // Must resolve to the built-in (version 1.0), never the planted
757        // "shadow" copy (version 99.0) on the search path.
758        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
759        assert_eq!(resolved[0].version, "1.0");
760        assert_eq!(resolved[0].kind, LibraryKind::BuiltIn);
761
762        std::fs::remove_dir_all(&dir).ok();
763    }
764
765    #[test]
766    fn cyclic_dependency_is_detected_not_infinitely_recursed() {
767        let dir = std::env::temp_dir().join(format!(
768            "etdl-stdlib-cycle-test-{:x}",
769            std::time::SystemTime::now()
770                .duration_since(std::time::UNIX_EPOCH)
771                .unwrap()
772                .as_nanos()
773        ));
774        std::fs::create_dir_all(dir.join("a")).unwrap();
775        std::fs::create_dir_all(dir.join("b")).unwrap();
776        std::fs::write(
777            dir.join("a").join("lib.etdl"),
778            "etdl: \"1.0.0\"\nlibrary: { name: a, version: \"1.0\", dependsOn: [{ name: b, version: \"1.0\" }] }\ncomponents: {}\n",
779        )
780        .unwrap();
781        std::fs::write(
782            dir.join("b").join("lib.etdl"),
783            "etdl: \"1.0.0\"\nlibrary: { name: b, version: \"1.0\", dependsOn: [{ name: a, version: \"1.0\" }] }\ncomponents: {}\n",
784        )
785        .unwrap();
786
787        let doc = doc_importing(
788            vec![LibraryImport {
789                name: "a".to_string(),
790                version: "1.0".to_string(),
791                required: true,
792            }],
793            vec![],
794        );
795        let resolver = LibraryResolver::new().with_search_path(&dir);
796        let (_expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
797        assert!(errors.iter().any(|e| matches!(e, LibraryError::Cyclic { .. })));
798
799        std::fs::remove_dir_all(&dir).ok();
800    }
801
802    #[test]
803    fn incompatible_major_version_is_rejected() {
804        let doc = doc_importing(
805            vec![LibraryImport {
806                name: "std.events".to_string(),
807                version: "2.0".to_string(),
808                required: true,
809            }],
810            vec![],
811        );
812        let resolver = LibraryResolver::new();
813        let (_expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
814        assert!(errors
815            .iter()
816            .any(|e| matches!(e, LibraryError::IncompatibleVersion { .. })));
817    }
818
819    #[test]
820    fn resolution_is_deterministic_across_repeated_runs() {
821        let doc = doc_importing(
822            vec![LibraryImport {
823                name: "std.events".to_string(),
824                version: "1.0".to_string(),
825                required: true,
826            }],
827            vec!["std.events.NetworkTimeout", "std.events.ProcessCrash"],
828        );
829        let resolver = LibraryResolver::new();
830        let (expanded_a, _, errors_a) = expand_libraries(&doc, Path::new("."), &resolver);
831        let (expanded_b, _, errors_b) = expand_libraries(&doc, Path::new("."), &resolver);
832        assert!(errors_a.is_empty() && errors_b.is_empty());
833        let ft_a = &expanded_a.fault_trees.as_ref().unwrap()["FT"];
834        let ft_b = &expanded_b.fault_trees.as_ref().unwrap()["FT"];
835        assert_eq!(ft_a.basic_events.len(), ft_b.basic_events.len());
836        for (k, v) in &ft_a.basic_events {
837            assert_eq!(ft_b.basic_events.get(k).map(|b| b.probability), Some(v.probability));
838        }
839    }
840
841    #[test]
842    fn builtin_events_library_parses_and_is_source_only() {
843        // "A library containing only *.etdl must be valid" — demonstrated
844        // directly: std.events has no native component, just this file.
845        let (_, src) = builtin_sources()[0];
846        let doc = etdl_parser::parse_library_document(src).expect("std.events parses");
847        assert_eq!(doc.library.name, "std.events");
848        assert!(!doc.components.basic_events.unwrap_or_default().is_empty());
849    }
850}