Skip to main content

etdl_compiler/
extension.rs

1//! Generic semantic-extension mechanism for the ETDL compiler.
2//!
3//! This is the reusable extension seam the Reliability Supplement and any
4//! future tree-event/domain supplement plug into. The core compiler only knows
5//! "a document may declare supplements; a registered extension may add
6//! validation and semantic processing." It does not embed reliability-specific
7//! logic here.
8//!
9//! The lifecycle (adapted to the existing pipeline):
10//!
11//! ```text
12//! parse
13//!   -> core validation
14//!   -> extension discovery (supplement declarations -> registry lookup)
15//!   -> extension validation
16//!   -> extension semantic processing (e.g. external probability resolution)
17//!   -> core compilation
18//!   -> code generation
19//! ```
20
21use etdl_parser::ast::EtlDocument;
22use std::collections::BTreeMap;
23
24use crate::validate::Diagnostic;
25
26/// The context an extension receives while processing a document.
27#[derive(Debug, Clone)]
28pub struct ExtensionContext<'a> {
29    pub doc: &'a EtlDocument,
30    /// Base directory for resolving relative artifact paths.
31    pub base_dir: &'a std::path::Path,
32    /// Free-form configuration for the extension (e.g. from `x-reliability`).
33    pub config: BTreeMap<String, serde_yaml::Value>,
34}
35
36impl<'a> ExtensionContext<'a> {
37    pub fn new(doc: &'a EtlDocument, base_dir: &'a std::path::Path) -> Self {
38        ExtensionContext {
39            doc,
40            base_dir,
41            config: BTreeMap::new(),
42        }
43    }
44}
45
46/// Self-reported metadata about what a supplement validates and declares —
47/// colocated in the same module as the `parse_and_validate_*`/`validate()`
48/// logic that actually produces `diagnostic_codes`, so a change to one is a
49/// scroll away from the other, not a separate hand-maintained copy in a
50/// different crate. `etdl capabilities`/`etdl supplement list` read this
51/// generically (`ExtensionRegistry::list`/`lookup` + `descriptor()`)
52/// instead of each caller hard-coding a per-supplement summary — see
53/// `crate::performance`'s `descriptor()` for the reference shape every
54/// built-in supplement follows.
55#[derive(Debug, Clone, Copy, Default)]
56pub struct SupplementDescriptor {
57    /// One-line human summary of what this supplement validates/declares.
58    /// Empty (the default) for an extension that doesn't override
59    /// `EtdlExtension::descriptor` — e.g. a dynamically loaded `.wasm`
60    /// plugin, whose wire ABI (`docs/reference/supplement-plugins.md`)
61    /// carries no description field to report here.
62    pub summary: &'static str,
63    /// The schema/version string this supplement's `x-*` field is
64    /// versioned against (e.g. `"etdl.performance/1.0"`), if it has one
65    /// distinct from `EtdlExtension::version()`.
66    pub schema: Option<&'static str>,
67    /// Every diagnostic code this supplement's own validation can produce.
68    pub diagnostic_codes: &'static [&'static str],
69    /// Other supplement ids this one has a real dependency on, if any (e.g.
70    /// `etdl.security` on `etdl.tree-event` — see `crate::security`'s
71    /// module docs for what "dependency" means in practice here).
72    pub requires: &'static [&'static str],
73}
74
75/// A semantic extension that plugs into the ETDL compiler.
76///
77/// Implementations SHOULD be lightweight and deterministic. An extension must
78/// not silently change core ETDL semantics; it adds validation and semantic
79/// processing only.
80pub trait EtdlExtension: Send + Sync {
81    /// The namespaced extension id, e.g. `etdl.reliability`.
82    fn id(&self) -> &str;
83
84    /// The extension version.
85    fn version(&self) -> &str;
86
87    /// Self-reported description of this supplement, for `etdl
88    /// capabilities`/`etdl supplement list` to surface generically.
89    /// Default: empty (an extension with nothing distinctive to report,
90    /// e.g. a third-party `Compiler::with_extension` caller in a test, or a
91    /// dynamically loaded `.wasm` plugin — see [`SupplementDescriptor`]).
92    fn descriptor(&self) -> SupplementDescriptor {
93        SupplementDescriptor::default()
94    }
95
96    /// Validate the document's use of this extension. Called after core
97    /// validation; diagnostics are appended to `diagnostics`.
98    fn validate(
99        &self,
100        doc: &EtlDocument,
101        context: &ExtensionContext<'_>,
102        diagnostics: &mut Vec<Diagnostic>,
103    );
104
105    /// Optional semantic processing step, run before fault-tree evaluation.
106    /// Returns diagnostics. Implementations that resolve external values (e.g.
107    /// probabilities) surface them here.
108    fn process(
109        &self,
110        _doc: &EtlDocument,
111        _context: &ExtensionContext<'_>,
112        _diagnostics: &mut Vec<Diagnostic>,
113    ) -> Box<dyn ExtensionResult + '_> {
114        Box::new(NoopExtensionResult)
115    }
116}
117
118/// Result of an extension's semantic processing step. The reliability extension
119/// returns resolved external probabilities through this; future extensions may
120/// return their own typed results.
121pub trait ExtensionResult {
122    /// The extension id that produced this result.
123    fn extension_id(&self) -> &str;
124
125    /// Basic-event probability overrides this extension's processing step
126    /// resolved, as `(override_key, value)` pairs — the same shape
127    /// `fault_tree::BasicEventOverrides` already consumes. Default: none.
128    /// An extension that resolves external values into fault-tree
129    /// probabilities (as the reliability extension does today, via its own
130    /// dedicated, hard-coded path in `Compiler::run_extensions`) overrides
131    /// this so a *generically registered* extension (`Compiler::
132    /// with_extension`) can contribute overrides the same way, without
133    /// `run_extensions` needing to know the extension's concrete result
134    /// type.
135    fn basic_event_overrides(&self) -> Vec<(String, f64)> {
136        Vec::new()
137    }
138}
139
140/// A no-op result (extensions that do no semantic processing).
141pub struct NoopExtensionResult;
142
143impl ExtensionResult for NoopExtensionResult {
144    fn extension_id(&self) -> &str {
145        ""
146    }
147}
148
149/// A deterministic registry of registered extensions.
150#[derive(Default)]
151pub struct ExtensionRegistry {
152    extensions: BTreeMap<String, Box<dyn EtdlExtension>>,
153}
154
155impl ExtensionRegistry {
156    pub fn new() -> Self {
157        ExtensionRegistry::default()
158    }
159
160    /// Register an extension. Registering a duplicate id replaces the previous
161    /// entry (deterministic last-write-wins).
162    pub fn register<E: EtdlExtension + 'static>(&mut self, extension: E) {
163        self.extensions
164            .insert(extension.id().to_string(), Box::new(extension));
165    }
166
167    pub fn lookup(&self, id: &str) -> Option<&dyn EtdlExtension> {
168        self.extensions.get(id).map(|b| b.as_ref())
169    }
170
171    pub fn contains(&self, id: &str) -> bool {
172        self.extensions.contains_key(id)
173    }
174
175    /// Registered extension ids, sorted (deterministic).
176    pub fn list(&self) -> Vec<&str> {
177        self.extensions.keys().map(|s| s.as_str()).collect()
178    }
179}
180
181/// Built-in extensions shipped with the compiler. This registry is
182/// discoverability/support-checking only (`etdl capabilities`, `etdl
183/// supplement list`, the E-108/W-407 "is this supplement supported" check
184/// in `validate::supplement_is_supported`) — it does **not** by itself make
185/// an extension's `validate`/`process` run during `Compiler::validate`/
186/// `compile`. Tree Event and Reliability each additionally have their own
187/// special-cased call elsewhere in the pipeline; Performance instead relies
188/// on `Compiler::new()` also seeding `Compiler::extensions` with it, so it
189/// executes through the same generic path a third-party `with_extension`
190/// supplement uses — see `crate::performance`'s module docs for why that's
191/// the preferred shape for a new supplement going forward.
192pub fn builtin_registry() -> ExtensionRegistry {
193    let mut registry = ExtensionRegistry::new();
194    // Domain-neutral, always compiled in — not gated behind the
195    // `reliability` feature.
196    registry.register(crate::tree_event::TreeEventExtension::new());
197    registry.register(crate::performance::PerformanceExtension::new());
198    registry.register(crate::safety::SafetyExtension::new());
199    registry.register(crate::diagnostics::DiagnosticsExtension::new());
200    registry.register(crate::security::SecurityExtension::new());
201    #[cfg(feature = "reliability")]
202    {
203        registry.register(crate::reliability::ReliabilityExtension::new());
204    }
205    registry
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    struct TestExt;
213
214    impl EtdlExtension for TestExt {
215        fn id(&self) -> &str {
216            "etdl.test"
217        }
218        fn version(&self) -> &str {
219            "1.0"
220        }
221        fn validate(
222            &self,
223            _doc: &EtlDocument,
224            _context: &ExtensionContext<'_>,
225            _diagnostics: &mut Vec<Diagnostic>,
226        ) {
227        }
228    }
229
230    #[test]
231    fn registry_is_deterministic() {
232        let mut r = ExtensionRegistry::new();
233        r.register(TestExt);
234        r.register(TestExt);
235        assert!(r.contains("etdl.test"));
236        assert!(r.lookup("etdl.test").is_some());
237        assert_eq!(r.list(), vec!["etdl.test"]);
238    }
239
240    #[test]
241    fn lookup_missing_is_none() {
242        let r = ExtensionRegistry::new();
243        assert!(r.lookup("etdl.nope").is_none());
244    }
245
246    /// `etdl capabilities`/`etdl supplement list` read every built-in
247    /// extension's `descriptor()` generically (see `docs/CLI.md`'s `etdl
248    /// capabilities` section) — an extension registered here with the
249    /// trait's silent default (empty `summary`, empty `diagnostic_codes`)
250    /// would print as a blank line with no way for a caller to notice.
251    /// This guards that every built-in actually overrides `descriptor()`,
252    /// so a future supplement added to `builtin_registry()` without one
253    /// fails a test instead of shipping silently undocumented.
254    #[test]
255    fn every_built_in_extension_has_a_non_empty_descriptor() {
256        let registry = builtin_registry();
257        for id in registry.list() {
258            let ext = registry.lookup(id).expect("listed");
259            let d = ext.descriptor();
260            assert!(
261                !d.summary.is_empty(),
262                "{id}: EtdlExtension::descriptor() left `summary` at the trait default (empty) — implement it"
263            );
264            assert!(
265                !d.diagnostic_codes.is_empty(),
266                "{id}: EtdlExtension::descriptor() left `diagnostic_codes` at the trait default (empty) — implement it"
267            );
268        }
269    }
270}