Skip to main content

fv_compute/
registry.rs

1//! The registry + directory-per-transform loader.
2//!
3//! Two goals drive this module:
4//!  1. **Define a transform in as few places as possible** — one directory (`transform.toml` +
5//!     impl + co-located tests) is the whole definition. The registry discovers and validates it;
6//!     nothing else declares it.
7//!  2. **Registry as customizable as possible** — [`Root`] is a trait (local dir, in-memory, and
8//!     later git/ or a caller's own source all plug in), roots are ordered with configurable
9//!     precedence, and backends are a caller-extensible [`Backends`] map. Any service embeds its
10//!     own [`Registry`] with its own roots + backends.
11//!
12//! Discovery + validation + resolution + a serializable [`RegistryIndex`] (so startup
13//! doesn't re-scan). Backend *execution* is the backends' job; [`Registry::load`] wires the seam and is
14//! testable today with any [`TransformBackend`].
15
16use crate::contract::{Compute, ComputeError, TransformBackend};
17use crate::manifest::{parse_and_validate, ImplKind, ManifestError, TransformManifest};
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23/// A discovered-but-not-yet-validated unit: where it came from + its raw `transform.toml`.
24pub struct RawUnit {
25    pub root: String,
26    pub dir: PathBuf,
27    pub toml: String,
28}
29
30/// A source of transform directories. Implement this to add a new kind of root — a git repo
31/// an HTTP endpoint, an in-browser buffer, or anything else — without touching the
32/// registry core.
33pub trait Root: Send + Sync {
34    /// A stable name for precedence/diagnostics (e.g. "provided", "meridian").
35    fn name(&self) -> &str;
36    /// Enumerate the transform directories this root provides.
37    fn discover(&self) -> Result<Vec<RawUnit>, RegistryError>;
38}
39
40/// A local-directory root: scans `<path>/<name>/transform.toml`. A non-existent path yields no
41/// units (so optional business roots are friendly); a strict caller checks existence itself.
42pub struct DirRoot {
43    name: String,
44    path: PathBuf,
45}
46
47impl DirRoot {
48    pub fn new(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
49        Self {
50            name: name.into(),
51            path: path.into(),
52        }
53    }
54}
55
56impl Root for DirRoot {
57    fn name(&self) -> &str {
58        &self.name
59    }
60
61    fn discover(&self) -> Result<Vec<RawUnit>, RegistryError> {
62        if !self.path.exists() {
63            return Ok(Vec::new());
64        }
65        let mut out = Vec::new();
66        let mut entries: Vec<PathBuf> = std::fs::read_dir(&self.path)
67            .map_err(|e| RegistryError::Io {
68                path: self.path.clone(),
69                msg: e.to_string(),
70            })?
71            .filter_map(|e| e.ok().map(|e| e.path()))
72            .filter(|p| p.is_dir())
73            .collect();
74        entries.sort(); // deterministic discovery order
75        for dir in entries {
76            let manifest_path = dir.join("transform.toml");
77            if !manifest_path.exists() {
78                continue; // a directory without a manifest is not a transform unit
79            }
80            let toml = std::fs::read_to_string(&manifest_path).map_err(|e| RegistryError::Io {
81                path: manifest_path.clone(),
82                msg: e.to_string(),
83            })?;
84            out.push(RawUnit {
85                root: self.name.clone(),
86                dir,
87                toml,
88            });
89        }
90        Ok(out)
91    }
92}
93
94/// An in-memory root — for tests, generated builtins, or the in-browser IDE.
95pub struct MemoryRoot {
96    name: String,
97    units: Vec<(PathBuf, String)>,
98}
99
100impl MemoryRoot {
101    pub fn new(name: impl Into<String>) -> Self {
102        Self {
103            name: name.into(),
104            units: Vec::new(),
105        }
106    }
107    /// Add a `(dir, transform.toml)` unit.
108    pub fn unit(mut self, dir: impl Into<PathBuf>, toml: impl Into<String>) -> Self {
109        self.units.push((dir.into(), toml.into()));
110        self
111    }
112}
113
114impl Root for MemoryRoot {
115    fn name(&self) -> &str {
116        &self.name
117    }
118    fn discover(&self) -> Result<Vec<RawUnit>, RegistryError> {
119        Ok(self
120            .units
121            .iter()
122            .map(|(dir, toml)| RawUnit {
123                root: self.name.clone(),
124                dir: dir.clone(),
125                toml: toml.clone(),
126            })
127            .collect())
128    }
129}
130
131/// A validated, resolvable transform unit.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct RegisteredTransform {
134    pub manifest: TransformManifest,
135    pub dir: PathBuf,
136    /// Which root supplied it (after precedence resolution).
137    pub root: String,
138}
139
140impl RegisteredTransform {
141    /// `id@version` — the registry key.
142    pub fn key(&self) -> String {
143        format!("{}@{}", self.manifest.id, self.manifest.version)
144    }
145}
146
147#[derive(Debug, thiserror::Error)]
148pub enum RegistryError {
149    #[error("I/O error at {path}: {msg}")]
150    Io { path: PathBuf, msg: String },
151    #[error("invalid transform in root `{root}` at {dir}: {source}")]
152    Manifest {
153        root: String,
154        dir: PathBuf,
155        source: Box<ManifestError>,
156    },
157    #[error("duplicate transform `{key}` within root `{root}` ({dir})")]
158    DuplicateInRoot { key: String, root: String, dir: PathBuf },
159    #[error("registry index contract version `{found}` != `{expected}`")]
160    IndexVersion { found: String, expected: String },
161}
162
163/// Build a registry from an ordered list of roots. Later roots OVERRIDE earlier ones for the same
164/// `id@version` (a business's `transforms/` shadows the provided package) — the configurable
165/// precedence. A duplicate `id@version` *within a single root* is an error.
166#[derive(Default)]
167pub struct RegistryBuilder {
168    roots: Vec<Box<dyn Root>>,
169}
170
171impl RegistryBuilder {
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Add a root. Order matters: later = higher precedence.
177    pub fn root(mut self, r: impl Root + 'static) -> Self {
178        self.roots.push(Box::new(r));
179        self
180    }
181
182    pub fn build(self) -> Result<Registry, RegistryError> {
183        let mut by_key: BTreeMap<String, RegisteredTransform> = BTreeMap::new();
184        for root in &self.roots {
185            let mut seen_in_root: BTreeMap<String, PathBuf> = BTreeMap::new();
186            for raw in root.discover()? {
187                let manifest = parse_and_validate(&raw.toml).map_err(|source| RegistryError::Manifest {
188                    root: raw.root.clone(),
189                    dir: raw.dir.clone(),
190                    source: Box::new(source),
191                })?;
192                let reg = RegisteredTransform {
193                    manifest,
194                    dir: raw.dir.clone(),
195                    root: raw.root.clone(),
196                };
197                let key = reg.key();
198                if let Some(prev) = seen_in_root.insert(key.clone(), raw.dir.clone()) {
199                    let _ = prev;
200                    return Err(RegistryError::DuplicateInRoot {
201                        key,
202                        root: raw.root,
203                        dir: raw.dir,
204                    });
205                }
206                by_key.insert(key, reg); // later root overrides earlier
207            }
208        }
209        Ok(Registry { by_key })
210    }
211}
212
213/// The resolved set of transforms, addressable by `id@version`.
214#[derive(Debug, Clone)]
215pub struct Registry {
216    by_key: BTreeMap<String, RegisteredTransform>,
217}
218
219impl Registry {
220    pub fn builder() -> RegistryBuilder {
221        RegistryBuilder::new()
222    }
223
224    /// Exact `id@version` lookup.
225    pub fn get(&self, id: &str, version: &str) -> Option<&RegisteredTransform> {
226        self.by_key.get(&format!("{id}@{version}"))
227    }
228
229    /// Highest semver for an id.
230    pub fn latest(&self, id: &str) -> Option<&RegisteredTransform> {
231        self.by_key.values().filter(|r| r.manifest.id == id).max_by(|a, b| {
232            let va = semver::Version::parse(&a.manifest.version).ok();
233            let vb = semver::Version::parse(&b.manifest.version).ok();
234            va.cmp(&vb)
235        })
236    }
237
238    /// Resolve a selector: `"id"` → latest; `"id@version"` → exact.
239    pub fn resolve(&self, selector: &str) -> Option<&RegisteredTransform> {
240        match selector.split_once('@') {
241            Some((id, version)) => self.get(id, version),
242            None => self.latest(selector),
243        }
244    }
245
246    pub fn iter(&self) -> impl Iterator<Item = &RegisteredTransform> {
247        self.by_key.values()
248    }
249
250    pub fn len(&self) -> usize {
251        self.by_key.len()
252    }
253
254    pub fn is_empty(&self) -> bool {
255        self.by_key.is_empty()
256    }
257
258    /// The derived, serializable index — write it at build/publish time so runtime
259    /// startup loads this instead of re-scanning + re-validating the tree.
260    pub fn to_index(&self) -> RegistryIndex {
261        RegistryIndex {
262            contract_version: crate::CONTRACT_VERSION.to_string(),
263            transforms: self.by_key.values().cloned().collect(),
264        }
265    }
266
267    /// Reconstruct from a derived index (no directory scan). Fails if the index was produced
268    /// against a different contract version.
269    pub fn from_index(index: RegistryIndex) -> Result<Self, RegistryError> {
270        if index.contract_version != crate::CONTRACT_VERSION {
271            return Err(RegistryError::IndexVersion {
272                found: index.contract_version,
273                expected: crate::CONTRACT_VERSION.to_string(),
274            });
275        }
276        let by_key = index.transforms.into_iter().map(|r| (r.key(), r)).collect();
277        Ok(Registry { by_key })
278    }
279
280    /// Resolve a selector and load it via the matching backend — "run transform X@v" as a library
281    /// call. Backend execution is the backend's job; this is the wiring + dispatch.
282    pub fn load(&self, selector: &str, backends: &Backends) -> Result<Box<dyn Compute>, ComputeError> {
283        let reg = self.resolve(selector).ok_or_else(|| ComputeError::Load {
284            id: selector.to_string(),
285            msg: "no such transform in registry".into(),
286        })?;
287        let backend = backends.get(reg.manifest.impl_kind).ok_or(ComputeError::NoBackend {
288            kind: reg.manifest.impl_kind,
289        })?;
290        backend.load(&reg.manifest, &reg.dir)
291    }
292}
293
294/// The serializable, re-scan-free index.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct RegistryIndex {
297    #[serde(rename = "contractVersion")]
298    pub contract_version: String,
299    pub transforms: Vec<RegisteredTransform>,
300}
301
302/// The caller-extensible backend set: `ImplKind → TransformBackend`. A deployment registers its
303/// own backends (even for a new impl kind) without touching the registry core.
304#[derive(Default)]
305pub struct Backends {
306    map: HashMap<ImplKind, Box<dyn TransformBackend>>,
307}
308
309impl Backends {
310    pub fn new() -> Self {
311        Self::default()
312    }
313
314    pub fn register(mut self, backend: impl TransformBackend + 'static) -> Self {
315        self.map.insert(backend.kind(), Box::new(backend));
316        self
317    }
318
319    pub fn get(&self, kind: ImplKind) -> Option<&dyn TransformBackend> {
320        self.map.get(&kind).map(|b| b.as_ref())
321    }
322
323    pub fn kinds(&self) -> impl Iterator<Item = &ImplKind> {
324        self.map.keys()
325    }
326}
327
328/// Convenience: build a registry from the standard three-root layout — the provided package
329/// (`<repo>/transforms`), then each business bundle dir (`<bundle>/transforms`), business winning
330/// on precedence. Missing dirs are simply skipped.
331pub fn standard_registry(provided: impl AsRef<Path>, business_bundles: &[PathBuf]) -> Result<Registry, RegistryError> {
332    let mut b = Registry::builder().root(DirRoot::new("provided", provided.as_ref().to_path_buf()));
333    for bundle in business_bundles {
334        let name = bundle
335            .file_name()
336            .map(|s| s.to_string_lossy().to_string())
337            .unwrap_or_else(|| "business".into());
338        b = b.root(DirRoot::new(name, bundle.join("transforms")));
339    }
340    b.build()
341}