1use std::collections::BTreeMap;
58use std::path::{Path, PathBuf};
59
60use etdl_parser::ast::{BasicEvent, EtlDocument, FaultTree, LibraryDocument, LibraryImport};
61
62pub const STDLIB_SCHEMA: &str = "etdl.stdlib/1.0";
66
67pub const STD_NAMESPACE: &str = "std.";
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LibraryKind {
73 BuiltIn,
76 Optional,
78 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#[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 pub gates: BTreeMap<String, etdl_parser::ast::Gate>,
106 pub depends_on: Vec<LibraryImport>,
107}
108
109impl ResolvedLibrary {
110 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#[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#[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
155fn 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#[derive(Debug, Clone, Default)]
179pub struct LibraryResolver {
180 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 pub fn builtin_names() -> Vec<&'static str> {
197 builtin_sources().iter().map(|(n, _)| *n).collect()
198 }
199
200 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 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
282fn 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
303pub 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
323fn 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 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
383fn 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
398fn 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
414fn 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; }
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; };
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
456pub 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 assert!(!ft.basic_events.contains_key("std.events.ProcessCrash"));
548 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 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 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 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 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 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 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}