use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use etdl_parser::ast::{BasicEvent, EtlDocument, FaultTree, LibraryDocument, LibraryImport};
pub const STDLIB_SCHEMA: &str = "etdl.stdlib/1.0";
pub const STD_NAMESPACE: &str = "std.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LibraryKind {
BuiltIn,
Optional,
User,
}
impl LibraryKind {
pub fn label(self) -> &'static str {
match self {
LibraryKind::BuiltIn => "built-in",
LibraryKind::Optional => "optional",
LibraryKind::User => "user",
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedLibrary {
pub name: String,
pub version: String,
pub kind: LibraryKind,
pub description: Option<String>,
pub basic_events: BTreeMap<String, BasicEvent>,
pub gates: BTreeMap<String, etdl_parser::ast::Gate>,
pub depends_on: Vec<LibraryImport>,
}
impl ResolvedLibrary {
pub fn provenance(&self) -> LibraryProvenance {
LibraryProvenance {
name: self.name.clone(),
version: self.version.clone(),
kind: self.kind.label().to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct LibraryProvenance {
pub name: String,
pub version: String,
pub kind: String,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum LibraryError {
#[error("library '{name}' was not found ({searched})")]
NotFound { name: String, searched: String },
#[error(
"library '{name}': requested version '{requested}' is incompatible with the resolved \
version '{found}' (major version must match)"
)]
IncompatibleVersion {
name: String,
requested: String,
found: String,
},
#[error("cyclic library dependency: {}", chain.join(" -> "))]
Cyclic { chain: Vec<String> },
#[error("library '{name}': {reason}")]
InvalidManifest { name: String, reason: String },
#[error(
"library '{name}' is reserved for the built-in standard library ('{prefix}' prefix) \
and cannot be resolved from an optional or user source"
)]
Shadowing { name: String, prefix: String },
}
fn builtin_sources() -> &'static [(&'static str, &'static str)] {
&[
("std.events", include_str!("../stdlib/events/lib.etdl")),
("std.logic", include_str!("../stdlib/logic/lib.etdl")),
(
"std.probability",
include_str!("../stdlib/probability/lib.etdl"),
),
]
}
#[derive(Debug, Clone, Default)]
pub struct LibraryResolver {
pub search_paths: Vec<PathBuf>,
}
impl LibraryResolver {
pub fn new() -> Self {
LibraryResolver::default()
}
pub fn with_search_path(mut self, path: impl Into<PathBuf>) -> Self {
self.search_paths.push(path.into());
self
}
pub fn builtin_names() -> Vec<&'static str> {
builtin_sources().iter().map(|(n, _)| *n).collect()
}
fn load(&self, name: &str, base_dir: &Path) -> Result<(LibraryKind, LibraryDocument), LibraryError> {
if let Some((_, src)) = builtin_sources().iter().find(|(n, _)| *n == name) {
return Ok((LibraryKind::BuiltIn, parse_library(name, src)?));
}
let user_path = base_dir.join("lib").join(name).join("lib.etdl");
let is_reserved = name.starts_with(STD_NAMESPACE);
if is_reserved {
let shadow_found = self
.search_paths
.iter()
.any(|d| d.join(name).join("lib.etdl").exists())
|| user_path.exists();
if shadow_found {
return Err(LibraryError::Shadowing {
name: name.to_string(),
prefix: STD_NAMESPACE.to_string(),
});
}
return Err(LibraryError::NotFound {
name: name.to_string(),
searched: "the built-in standard library registry (reserved namespace: never \
searched elsewhere)"
.to_string(),
});
}
for search_dir in &self.search_paths {
let path = search_dir.join(name).join("lib.etdl");
if path.exists() {
let content = read_library_file(&path, name)?;
return Ok((LibraryKind::Optional, parse_library(name, &content)?));
}
}
if user_path.exists() {
let content = read_library_file(&user_path, name)?;
return Ok((LibraryKind::User, parse_library(name, &content)?));
}
Err(LibraryError::NotFound {
name: name.to_string(),
searched: format!(
"built-in registry, {} search path(s), and '{}'",
self.search_paths.len(),
user_path.display()
),
})
}
}
fn read_library_file(path: &Path, name: &str) -> Result<String, LibraryError> {
std::fs::read_to_string(path).map_err(|e| LibraryError::InvalidManifest {
name: name.to_string(),
reason: format!("cannot read library file: {e}"),
})
}
fn parse_library(expected_name: &str, content: &str) -> Result<LibraryDocument, LibraryError> {
let doc = etdl_parser::parse_library_document(content).map_err(|e| LibraryError::InvalidManifest {
name: expected_name.to_string(),
reason: e,
})?;
if doc.library.name != expected_name {
return Err(LibraryError::InvalidManifest {
name: expected_name.to_string(),
reason: format!(
"declares name '{}' but was resolved as '{}'",
doc.library.name, expected_name
),
});
}
Ok(doc)
}
fn major_version(version: &str) -> Option<u64> {
let trimmed = version.trim();
if trimmed.is_empty() {
return None;
}
trimmed.split(['.', '+']).next()?.trim().parse().ok()
}
fn check_version_compatible(name: &str, requested: &str, found: &str) -> Result<(), LibraryError> {
match (major_version(requested), major_version(found)) {
(Some(r), Some(f)) if r == f => Ok(()),
_ => Err(LibraryError::IncompatibleVersion {
name: name.to_string(),
requested: requested.to_string(),
found: found.to_string(),
}),
}
}
pub fn list_builtin() -> Vec<Result<ResolvedLibrary, LibraryError>> {
builtin_sources()
.iter()
.map(|(name, src)| {
parse_library(name, src).map(|doc| ResolvedLibrary {
name: (*name).to_string(),
version: doc.library.version.clone(),
kind: LibraryKind::BuiltIn,
description: doc.library.description.clone(),
basic_events: doc.components.basic_events.clone().unwrap_or_default(),
gates: doc.components.gates.clone().unwrap_or_default(),
depends_on: doc.library.depends_on.clone(),
})
})
.collect()
}
fn resolve_transitively(
name: &str,
requested_version: &str,
base_dir: &Path,
resolver: &LibraryResolver,
resolved: &mut BTreeMap<String, ResolvedLibrary>,
stack: &mut Vec<String>,
errors: &mut Vec<LibraryError>,
) {
if stack.iter().any(|n| n == name) {
let mut chain = stack.clone();
chain.push(name.to_string());
errors.push(LibraryError::Cyclic { chain });
return;
}
if let Some(existing) = resolved.get(name) {
if let Err(e) = check_version_compatible(name, requested_version, &existing.version) {
errors.push(e);
}
return;
}
stack.push(name.to_string());
match resolver.load(name, base_dir) {
Ok((kind, lib_doc)) => {
if let Err(e) = check_version_compatible(name, requested_version, &lib_doc.library.version) {
errors.push(e);
}
let depends_on = lib_doc.library.depends_on.clone();
let basic_events = lib_doc.components.basic_events.clone().unwrap_or_default();
let gates = lib_doc.components.gates.clone().unwrap_or_default();
resolved.insert(
name.to_string(),
ResolvedLibrary {
name: name.to_string(),
version: lib_doc.library.version.clone(),
kind,
description: lib_doc.library.description.clone(),
basic_events,
gates,
depends_on: depends_on.clone(),
},
);
for dep in &depends_on {
resolve_transitively(&dep.name, &dep.version, base_dir, resolver, resolved, stack, errors);
}
}
Err(e) => errors.push(e),
}
stack.pop();
}
fn referenced_ids(ft: &FaultTree) -> Vec<String> {
let mut ids = Vec::new();
ids.push(ft.top_event.root_cause.clone());
if let Some(gates) = &ft.gates {
for gate in gates.values() {
ids.extend(gate.inputs.iter().cloned());
}
}
ids
}
fn lookup_qualified<'a, 'b>(
qualified_id: &'b str,
resolved: &'a BTreeMap<String, ResolvedLibrary>,
) -> Option<(&'a ResolvedLibrary, &'b str)> {
for lib in resolved.values() {
let prefix = format!("{}.", lib.name);
if let Some(short_name) = qualified_id.strip_prefix(&prefix) {
return Some((lib, short_name));
}
}
None
}
fn splice_referenced_definitions(ft: &mut FaultTree, resolved: &BTreeMap<String, ResolvedLibrary>) {
let mut worklist: Vec<String> = referenced_ids(ft);
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
while let Some(qualified_id) = worklist.pop() {
if !seen.insert(qualified_id.clone()) {
continue; }
if ft.basic_events.contains_key(&qualified_id) {
continue;
}
if ft.gates.as_ref().is_some_and(|g| g.contains_key(&qualified_id)) {
continue;
}
let Some((lib, short_name)) = lookup_qualified(&qualified_id, resolved) else {
continue; };
if let Some(gate) = lib.gates.get(short_name) {
worklist.extend(gate.inputs.iter().cloned());
ft.gates
.get_or_insert_with(BTreeMap::new)
.insert(qualified_id, gate.clone());
} else if let Some(be) = lib.basic_events.get(short_name) {
ft.basic_events.insert(qualified_id, be.clone());
}
}
}
pub fn expand_libraries(
doc: &EtlDocument,
base_dir: &Path,
resolver: &LibraryResolver,
) -> (EtlDocument, Vec<ResolvedLibrary>, Vec<LibraryError>) {
let mut errors = Vec::new();
let mut resolved: BTreeMap<String, ResolvedLibrary> = BTreeMap::new();
let mut stack: Vec<String> = Vec::new();
for import in &doc.libraries {
resolve_transitively(
&import.name,
&import.version,
base_dir,
resolver,
&mut resolved,
&mut stack,
&mut errors,
);
}
let mut expanded = doc.clone();
if let Some(fault_trees) = &mut expanded.fault_trees {
for ft in fault_trees.values_mut() {
splice_referenced_definitions(ft, &resolved);
}
}
(expanded, resolved.into_values().collect(), errors)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn doc_importing(libraries: Vec<LibraryImport>, inputs: Vec<&str>) -> EtlDocument {
let yaml = format!(
r#"
etdl: "1.0.0"
info: {{ title: "T", version: "1.0.0", domain: "D" }}
eventTrees:
T:
initiatingEvent: {{ id: I, message: "a#/m", next: C }}
nodes:
C: {{ type: consequence, operation: terminate }}
faultTrees:
FT:
topEvent: {{ id: Top, description: "t", rootCause: G }}
gates:
G: {{ type: OR, inputs: [{}] }}
basicEvents: {{}}
"#,
inputs.iter().map(|i| format!("\"{i}\"")).collect::<Vec<_>>().join(", ")
);
let mut doc = etdl_parser::parse_document(&yaml).expect("valid doc");
doc.libraries = libraries;
doc
}
#[test]
fn resolves_builtin_and_splices_referenced_basic_event() {
let doc = doc_importing(
vec![LibraryImport {
name: "std.events".to_string(),
version: "1.0".to_string(),
required: true,
}],
vec!["std.events.NetworkTimeout", "LocalThing"],
);
let resolver = LibraryResolver::new();
let (expanded, resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].kind, LibraryKind::BuiltIn);
let ft = &expanded.fault_trees.as_ref().unwrap()["FT"];
let be = ft
.basic_events
.get("std.events.NetworkTimeout")
.expect("spliced in");
assert!((be.probability.unwrap() - 0.001).abs() < 1e-12);
assert!(!ft.basic_events.contains_key("std.events.ProcessCrash"));
assert!(!doc.fault_trees.as_ref().unwrap()["FT"]
.basic_events
.contains_key("std.events.NetworkTimeout"));
}
#[test]
fn splices_a_library_gate_and_transitively_its_own_inputs() {
let dir = std::env::temp_dir().join(format!(
"etdl-stdlib-gate-splice-test-{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("test.logic")).unwrap();
std::fs::write(
dir.join("test.logic").join("lib.etdl"),
r#"
etdl: "1.0.0"
library:
name: test.logic
version: "1.0"
components:
basic_events:
InputA:
description: "placeholder input A"
InputB:
description: "placeholder input B"
gates:
AnyOf:
type: OR
inputs: ["test.logic.InputA", "test.logic.InputB"]
"#,
)
.unwrap();
let doc = doc_importing(
vec![LibraryImport {
name: "test.logic".to_string(),
version: "1.0".to_string(),
required: true,
}],
vec!["test.logic.AnyOf", "LocalThing"],
);
let resolver = LibraryResolver::new().with_search_path(&dir);
let (expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
let ft = &expanded.fault_trees.as_ref().unwrap()["FT"];
let gate = ft
.gates
.as_ref()
.and_then(|g| g.get("test.logic.AnyOf"))
.expect("gate spliced in");
assert_eq!(gate.inputs, vec!["test.logic.InputA", "test.logic.InputB"]);
assert!(ft.basic_events.contains_key("test.logic.InputA"));
assert!(ft.basic_events.contains_key("test.logic.InputB"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn overriding_a_library_gates_placeholder_input_flows_through() {
let dir = std::env::temp_dir().join(format!(
"etdl-stdlib-gate-override-test-{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("test.logic")).unwrap();
std::fs::write(
dir.join("test.logic").join("lib.etdl"),
r#"
etdl: "1.0.0"
library:
name: test.logic
version: "1.0"
components:
basic_events:
InputA:
description: "placeholder input A"
InputB:
description: "placeholder input B"
gates:
AnyOf:
type: OR
inputs: ["test.logic.InputA", "test.logic.InputB"]
"#,
)
.unwrap();
let mut doc = doc_importing(
vec![LibraryImport {
name: "test.logic".to_string(),
version: "1.0".to_string(),
required: true,
}],
vec!["test.logic.AnyOf", "LocalThing"],
);
doc.fault_trees.as_mut().unwrap().get_mut("FT").unwrap().basic_events.insert(
"test.logic.InputA".to_string(),
etdl_parser::ast::BasicEvent {
description: "overridden".to_string(),
probability: Some(0.42),
failure_rate: None,
mission_time: None,
undeveloped: None,
event_type: None,
message: None,
extensions: BTreeMap::new(),
},
);
let resolver = LibraryResolver::new().with_search_path(&dir);
let (expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
let ft = &expanded.fault_trees.as_ref().unwrap()["FT"];
assert_eq!(
ft.basic_events["test.logic.InputA"].probability,
Some(0.42)
);
assert!(ft.basic_events["test.logic.InputB"].probability.is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn local_declaration_overrides_library_default() {
let mut doc = doc_importing(
vec![LibraryImport {
name: "std.events".to_string(),
version: "1.0".to_string(),
required: true,
}],
vec!["std.events.NetworkTimeout"],
);
doc.fault_trees.as_mut().unwrap().get_mut("FT").unwrap().basic_events.insert(
"std.events.NetworkTimeout".to_string(),
etdl_parser::ast::BasicEvent {
description: "overridden".to_string(),
probability: Some(0.5),
failure_rate: None,
mission_time: None,
undeveloped: None,
event_type: None,
message: None,
extensions: BTreeMap::new(),
},
);
let resolver = LibraryResolver::new();
let (expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
assert!(errors.is_empty());
let ft = &expanded.fault_trees.as_ref().unwrap()["FT"];
assert_eq!(ft.basic_events["std.events.NetworkTimeout"].probability, Some(0.5));
}
#[test]
fn missing_library_is_reported_not_silently_skipped() {
let doc = doc_importing(
vec![LibraryImport {
name: "std.nonexistent".to_string(),
version: "1.0".to_string(),
required: true,
}],
vec![],
);
let resolver = LibraryResolver::new();
let (_expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
assert_eq!(errors.len(), 1);
assert!(matches!(errors[0], LibraryError::NotFound { .. }));
}
#[test]
fn optional_library_cannot_shadow_std_namespace() {
let dir = std::env::temp_dir().join(format!(
"etdl-stdlib-shadow-test-{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("std.events")).unwrap();
std::fs::write(
dir.join("std.events").join("lib.etdl"),
"etdl: \"1.0.0\"\nlibrary: { name: std.events, version: \"99.0\" }\ncomponents: {}\n",
)
.unwrap();
let doc = doc_importing(
vec![LibraryImport {
name: "std.events".to_string(),
version: "1.0".to_string(),
required: true,
}],
vec![],
);
let resolver = LibraryResolver::new().with_search_path(&dir);
let (_expanded, resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(resolved[0].version, "1.0");
assert_eq!(resolved[0].kind, LibraryKind::BuiltIn);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cyclic_dependency_is_detected_not_infinitely_recursed() {
let dir = std::env::temp_dir().join(format!(
"etdl-stdlib-cycle-test-{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("a")).unwrap();
std::fs::create_dir_all(dir.join("b")).unwrap();
std::fs::write(
dir.join("a").join("lib.etdl"),
"etdl: \"1.0.0\"\nlibrary: { name: a, version: \"1.0\", dependsOn: [{ name: b, version: \"1.0\" }] }\ncomponents: {}\n",
)
.unwrap();
std::fs::write(
dir.join("b").join("lib.etdl"),
"etdl: \"1.0.0\"\nlibrary: { name: b, version: \"1.0\", dependsOn: [{ name: a, version: \"1.0\" }] }\ncomponents: {}\n",
)
.unwrap();
let doc = doc_importing(
vec![LibraryImport {
name: "a".to_string(),
version: "1.0".to_string(),
required: true,
}],
vec![],
);
let resolver = LibraryResolver::new().with_search_path(&dir);
let (_expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
assert!(errors.iter().any(|e| matches!(e, LibraryError::Cyclic { .. })));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn incompatible_major_version_is_rejected() {
let doc = doc_importing(
vec![LibraryImport {
name: "std.events".to_string(),
version: "2.0".to_string(),
required: true,
}],
vec![],
);
let resolver = LibraryResolver::new();
let (_expanded, _resolved, errors) = expand_libraries(&doc, Path::new("."), &resolver);
assert!(errors
.iter()
.any(|e| matches!(e, LibraryError::IncompatibleVersion { .. })));
}
#[test]
fn resolution_is_deterministic_across_repeated_runs() {
let doc = doc_importing(
vec![LibraryImport {
name: "std.events".to_string(),
version: "1.0".to_string(),
required: true,
}],
vec!["std.events.NetworkTimeout", "std.events.ProcessCrash"],
);
let resolver = LibraryResolver::new();
let (expanded_a, _, errors_a) = expand_libraries(&doc, Path::new("."), &resolver);
let (expanded_b, _, errors_b) = expand_libraries(&doc, Path::new("."), &resolver);
assert!(errors_a.is_empty() && errors_b.is_empty());
let ft_a = &expanded_a.fault_trees.as_ref().unwrap()["FT"];
let ft_b = &expanded_b.fault_trees.as_ref().unwrap()["FT"];
assert_eq!(ft_a.basic_events.len(), ft_b.basic_events.len());
for (k, v) in &ft_a.basic_events {
assert_eq!(ft_b.basic_events.get(k).map(|b| b.probability), Some(v.probability));
}
}
#[test]
fn builtin_events_library_parses_and_is_source_only() {
let (_, src) = builtin_sources()[0];
let doc = etdl_parser::parse_library_document(src).expect("std.events parses");
assert_eq!(doc.library.name, "std.events");
assert!(!doc.components.basic_events.unwrap_or_default().is_empty());
}
}