Skip to main content

chematic_core/
extension.rs

1//! Stable, host-agnostic extension points for molecule consumers.
2//!
3//! Extensions are deliberately small: they receive an immutable [`Molecule`]
4//! and return one of the serialisable result shapes understood by the higher
5//! level bindings.  Keeping this contract in `chematic-core` makes extensions
6//! usable by native Rust and WASM callers without pulling in a descriptor or
7//! I/O crate.
8
9use crate::Molecule;
10
11/// Result values produced by a [`MoleculeExtension`].
12#[derive(Debug, Clone, PartialEq)]
13#[non_exhaustive]
14pub enum ExtensionValue {
15    /// One numeric result, such as a score or count.
16    Scalar(f64),
17    /// A fixed-order numeric vector, such as a fingerprint summary.
18    Vector(Vec<f64>),
19    /// A small textual result, such as a canonical identifier.
20    Text(String),
21}
22
23/// Failure returned by an extension.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum ExtensionError {
27    /// The molecule cannot be processed under the extension's contract.
28    InvalidInput(String),
29    /// The extension failed while calculating its result.
30    Failed(String),
31}
32
33impl core::fmt::Display for ExtensionError {
34    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
35        match self {
36            Self::InvalidInput(message) => write!(f, "invalid extension input: {message}"),
37            Self::Failed(message) => write!(f, "extension failed: {message}"),
38        }
39    }
40}
41
42impl std::error::Error for ExtensionError {}
43
44/// A named, read-only operation supplied by an ecosystem consumer.
45pub trait MoleculeExtension: Send + Sync {
46    /// Stable machine-readable identifier, for example `vendor.property.v1`.
47    fn id(&self) -> &str;
48
49    /// Extension contract version. Increment it when the result semantics change.
50    fn version(&self) -> u32;
51
52    /// Evaluate this extension for one molecule.
53    fn run(&self, molecule: &Molecule) -> Result<ExtensionValue, ExtensionError>;
54}
55
56/// Deterministic in-process registry for user-supplied extensions.
57#[derive(Default)]
58pub struct ExtensionRegistry {
59    extensions: Vec<Box<dyn MoleculeExtension>>,
60}
61
62impl ExtensionRegistry {
63    /// Create an empty registry.
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Register an extension, rejecting a duplicate identifier.
69    pub fn register<E>(&mut self, extension: E) -> Result<(), ExtensionError>
70    where
71        E: MoleculeExtension + 'static,
72    {
73        if self
74            .extensions
75            .iter()
76            .any(|existing| existing.id() == extension.id())
77        {
78            return Err(ExtensionError::Failed(format!(
79                "duplicate extension id: {}",
80                extension.id()
81            )));
82        }
83        self.extensions.push(Box::new(extension));
84        Ok(())
85    }
86
87    /// Return registered extensions in registration order.
88    pub fn extensions(&self) -> impl Iterator<Item = &dyn MoleculeExtension> {
89        self.extensions.iter().map(Box::as_ref)
90    }
91
92    /// Run one extension by ID.
93    pub fn run(&self, id: &str, molecule: &Molecule) -> Result<ExtensionValue, ExtensionError> {
94        self.extensions
95            .iter()
96            .find(|extension| extension.id() == id)
97            .ok_or_else(|| ExtensionError::Failed(format!("unknown extension id: {id}")))?
98            .run(molecule)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::{Atom, Element, MoleculeBuilder};
106
107    struct AtomCount;
108
109    impl MoleculeExtension for AtomCount {
110        fn id(&self) -> &str {
111            "test.atom-count.v1"
112        }
113
114        fn version(&self) -> u32 {
115            1
116        }
117
118        fn run(&self, molecule: &Molecule) -> Result<ExtensionValue, ExtensionError> {
119            Ok(ExtensionValue::Scalar(molecule.atom_count() as f64))
120        }
121    }
122
123    fn ethanol() -> Molecule {
124        let mut builder = MoleculeBuilder::new();
125        let c1 = builder.add_atom(Atom::new(Element::C));
126        let c2 = builder.add_atom(Atom::new(Element::C));
127        builder.add_bond(c1, c2, crate::BondOrder::Single).unwrap();
128        builder.build()
129    }
130
131    #[test]
132    fn registry_runs_extension_and_preserves_order() {
133        let mut registry = ExtensionRegistry::new();
134        registry.register(AtomCount).unwrap();
135        assert_eq!(
136            registry
137                .extensions()
138                .map(|extension| extension.id())
139                .collect::<Vec<_>>(),
140            ["test.atom-count.v1"]
141        );
142        assert_eq!(
143            registry.run("test.atom-count.v1", &ethanol()),
144            Ok(ExtensionValue::Scalar(2.0))
145        );
146    }
147
148    #[test]
149    fn registry_rejects_duplicate_and_unknown_ids() {
150        let mut registry = ExtensionRegistry::new();
151        registry.register(AtomCount).unwrap();
152        assert!(matches!(
153            registry.register(AtomCount),
154            Err(ExtensionError::Failed(message)) if message.contains("duplicate")
155        ));
156        assert!(matches!(
157            registry.run("missing.v1", &ethanol()),
158            Err(ExtensionError::Failed(message)) if message.contains("unknown")
159        ));
160    }
161}