Skip to main content

binoc_sdk/
traits.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::ir::{Changeset, DiffNode};
6use crate::types::*;
7
8pub type BinocResult<T> = Result<T, BinocError>;
9
10#[derive(Debug, thiserror::Error)]
11pub enum BinocError {
12    #[error("IO error: {0}")]
13    Io(#[from] std::io::Error),
14    #[error("config error: {0}")]
15    Config(String),
16    #[error("comparator error in {comparator}: {message}")]
17    Comparator { comparator: String, message: String },
18    #[error("no comparator found for item: {0}")]
19    NoComparator(String),
20    #[error("csv error: {0}")]
21    Csv(String),
22    #[error("zip error: {0}")]
23    Zip(String),
24    #[error("tar error: {0}")]
25    Tar(String),
26    #[error("extract error: {0}")]
27    Extract(String),
28    #[error("path policy: {0}")]
29    PathPolicy(String),
30    #[error(
31        "SDK version mismatch: {plugin} (plugin '{name}') is not compatible with host SDK {host}"
32    )]
33    SdkVersion {
34        name: String,
35        plugin: String,
36        host: String,
37    },
38    #[error("{0}")]
39    Other(String),
40}
41
42// ── Descriptors ─────────────────────────────────────────────────────
43
44pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
45
46/// Oldest SDK minor version that this host can still accept.
47/// Bump this when a protocol change makes older plugins incompatible.
48/// Leave it alone when only adding new `#[serde(default)]` fields.
49const MIN_COMPATIBLE_MINOR: u64 = 1;
50
51/// Check whether a plugin's SDK version is compatible with this host's SDK.
52///
53/// During 0.x: plugin minor version must be in `[MIN_COMPATIBLE_MINOR, host_minor]`
54/// (same major, patch may differ).
55/// After 1.0: plugin major must equal host major, plugin minor <= host minor
56/// (standard semver — host is backward-compatible within a major).
57pub fn check_sdk_compatibility(plugin_name: &str, plugin_version: &str) -> BinocResult<()> {
58    let host = parse_semver(SDK_VERSION);
59    let plugin = parse_semver(plugin_version);
60
61    let compatible = match (host, plugin) {
62        (Some((hm, hi, _)), Some((pm, pi, _))) if hm == 0 => {
63            hm == pm && pi >= MIN_COMPATIBLE_MINOR && pi <= hi
64        }
65        (Some((hm, hi, _)), Some((pm, pi, _))) => hm == pm && pi <= hi,
66        _ => false,
67    };
68
69    if compatible {
70        Ok(())
71    } else {
72        Err(BinocError::SdkVersion {
73            name: plugin_name.to_string(),
74            plugin: plugin_version.to_string(),
75            host: SDK_VERSION.to_string(),
76        })
77    }
78}
79
80fn parse_semver(v: &str) -> Option<(u64, u64, u64)> {
81    let mut parts = v.split('.');
82    let major = parts.next()?.parse().ok()?;
83    let minor = parts.next()?.parse().ok()?;
84    let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
85    Some((major, minor, patch))
86}
87
88/// Static metadata for a comparator plugin. Serializable — can be sent as
89/// a message, embedded in WASM custom sections, or written to a manifest.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[non_exhaustive]
92pub struct ComparatorDescriptor {
93    pub sdk_version: String,
94    pub name: String,
95    #[serde(default)]
96    pub extensions: Vec<String>,
97    #[serde(default)]
98    pub media_types: Vec<String>,
99    #[serde(default)]
100    pub scope: ItemScope,
101    #[serde(default)]
102    pub handles_identical: bool,
103}
104
105impl ComparatorDescriptor {
106    pub fn new(name: impl Into<String>) -> Self {
107        Self {
108            sdk_version: SDK_VERSION.into(),
109            name: name.into(),
110            extensions: Vec::new(),
111            media_types: Vec::new(),
112            scope: ItemScope::Files,
113            handles_identical: false,
114        }
115    }
116
117    pub fn with_extensions(mut self, exts: Vec<String>) -> Self {
118        self.extensions = exts;
119        self
120    }
121
122    pub fn with_media_types(mut self, types: Vec<String>) -> Self {
123        self.media_types = types;
124        self
125    }
126
127    pub fn with_scope(mut self, scope: ItemScope) -> Self {
128        self.scope = scope;
129        self
130    }
131
132    pub fn with_handles_identical(mut self, handles: bool) -> Self {
133        self.handles_identical = handles;
134        self
135    }
136}
137
138/// Static metadata for a transformer plugin.
139///
140/// Dispatch uses AND-of-ORs: all non-empty fields must pass (AND),
141/// and within each list field any single value satisfying it is enough
142/// (OR). Empty/default fields are unconstrained. A descriptor with
143/// every field empty/default matches nothing.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[non_exhaustive]
146pub struct TransformerDescriptor {
147    pub sdk_version: String,
148    pub name: String,
149    #[serde(default)]
150    pub match_types: Vec<String>,
151    #[serde(default)]
152    pub match_tags: Vec<String>,
153    #[serde(default)]
154    pub match_actions: Vec<String>,
155    #[serde(default = "default_phase")]
156    pub suggested_phase: String,
157    /// Artifact formats the node must have (any one suffices).
158    /// Empty means no artifact filter.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub match_artifacts: Vec<ArtifactFormat>,
161    /// Dispatch filter on node shape. `Container` matches only nodes
162    /// with children; `Leaf` matches only childless nodes; `Any` (the
163    /// default) is unconstrained.
164    #[serde(default)]
165    pub node_shape: NodeShapeFilter,
166}
167
168fn default_phase() -> String {
169    "default".into()
170}
171
172impl TransformerDescriptor {
173    pub fn new(name: impl Into<String>) -> Self {
174        Self {
175            sdk_version: SDK_VERSION.into(),
176            name: name.into(),
177            match_types: Vec::new(),
178            match_tags: Vec::new(),
179            match_actions: Vec::new(),
180            suggested_phase: "default".into(),
181            match_artifacts: Vec::new(),
182            node_shape: NodeShapeFilter::Any,
183        }
184    }
185
186    pub fn with_match_types(mut self, types: Vec<String>) -> Self {
187        self.match_types = types;
188        self
189    }
190
191    pub fn with_match_tags(mut self, tags: Vec<String>) -> Self {
192        self.match_tags = tags;
193        self
194    }
195
196    pub fn with_match_actions(mut self, actions: Vec<String>) -> Self {
197        self.match_actions = actions;
198        self
199    }
200
201    pub fn with_match_artifacts(mut self, formats: Vec<ArtifactFormat>) -> Self {
202        self.match_artifacts = formats;
203        self
204    }
205
206    pub fn with_node_shape(mut self, shape: NodeShapeFilter) -> Self {
207        self.node_shape = shape;
208        self
209    }
210}
211
212/// Static metadata for a renderer plugin.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[non_exhaustive]
215pub struct RendererDescriptor {
216    pub sdk_version: String,
217    pub name: String,
218    pub file_extension: String,
219}
220
221impl RendererDescriptor {
222    pub fn new(name: impl Into<String>, file_extension: impl Into<String>) -> Self {
223        Self {
224            sdk_version: SDK_VERSION.into(),
225            name: name.into(),
226            file_extension: file_extension.into(),
227        }
228    }
229}
230
231// ── DataAccess ──────────────────────────────────────────────────────
232
233/// Mediates all data I/O for plugins. Replaces direct filesystem access
234/// (`Item.physical_path`) and shared mutable context (`CompareContext`).
235///
236/// In-process: backed by the local filesystem + temp dirs.
237/// Cross-ABI: backed by a shared `data_root` directory so host and plugin
238/// can exchange artifacts via `publish_artifact()`/`get_artifact()`.
239pub trait DataAccess: Send + Sync {
240    /// Read the full contents of an item as bytes.
241    fn read_bytes(&self, item: &ItemRef) -> BinocResult<Vec<u8>>;
242
243    /// Open a streaming reader for an item.
244    fn open_read(&self, item: &ItemRef) -> BinocResult<Box<dyn std::io::Read + Send>>;
245
246    /// Get a local filesystem path for tools that require one (e.g. SQLite).
247    /// Not available on all backends — prefer read_bytes/open_read.
248    fn local_path(&self, item: &ItemRef) -> BinocResult<PathBuf>;
249
250    /// Make new data available as an item (for container expansion).
251    /// Returns an ItemRef usable in child ItemPairs.
252    fn provide(&self, logical_path: &str, content: &[u8]) -> BinocResult<ItemRef>;
253
254    /// Get a fresh writable workspace directory.
255    /// Managed by the DataAccess — cleaned up when the diff operation completes.
256    fn workspace(&self) -> BinocResult<PathBuf>;
257
258    /// Register a local filesystem path as a known item.
259    /// Returns an ItemRef that can be used in child ItemPairs.
260    fn register_local(&self, physical: &Path, logical: &str) -> BinocResult<ItemRef>;
261
262    /// Publish an artifact: store opaque bytes and return a descriptor.
263    ///
264    /// Artifacts are the unified mechanism for both private reuse and
265    /// cross-plugin composition. A comparator or transformer publishes
266    /// zero or more artifacts per node; downstream plugins retrieve them
267    /// by format and subject.
268    ///
269    /// `format` is a structured (package, name, version) tuple — see
270    /// [`ArtifactFormat`]. `subject` indicates which side of the
271    /// comparison the artifact describes. `producer` is the plugin name
272    /// for provenance. The returned `ArtifactDescriptor` should be
273    /// attached to the node via `DiffNode.artifacts`.
274    fn publish_artifact(
275        &self,
276        format: &ArtifactFormat,
277        subject: ArtifactSubject,
278        producer: &str,
279        data: &[u8],
280    ) -> BinocResult<ArtifactDescriptor>;
281
282    /// Retrieve the bytes for a previously published artifact.
283    fn get_artifact(&self, descriptor: &ArtifactDescriptor) -> BinocResult<Option<Vec<u8>>>;
284
285    /// Session-level root directory shared between host and plugins.
286    /// Artifact files live under `<data_root>/.artifacts/`. ABI requests
287    /// carry this path so native plugins can construct a `LocalDataAccess`
288    /// that reads from the same artifact store.
289    fn data_root(&self) -> BinocResult<PathBuf>;
290}
291
292// ── Plugin traits ───────────────────────────────────────────────────
293
294/// A plugin that claims an item pair and either emits a leaf diff or
295/// expands the pair into child items for further processing.
296///
297/// Routing is fully declarative via [`ComparatorDescriptor`]. If the
298/// descriptor matches but the comparator discovers at compare-time that
299/// it cannot handle the item, it returns [`CompareResult::Skip`].
300pub trait Comparator: Send + Sync {
301    fn descriptor(&self) -> ComparatorDescriptor;
302
303    fn compare(&self, pair: &ItemPair, data: &dyn DataAccess) -> BinocResult<CompareResult>;
304
305    /// Reconstruct physical access to a child item without re-diffing.
306    /// Container comparators (zip, directory, tar) override this to
307    /// extract or resolve a child path within the container, returning
308    /// an `ItemPair` that downstream comparators can work with.
309    ///
310    /// Used by the extract chain: the controller walks ancestor nodes
311    /// calling `reopen()` to progressively reconstruct the scratchpad.
312    fn reopen(
313        &self,
314        _pair: &ItemPair,
315        _child_path: &str,
316        _data: &dyn DataAccess,
317    ) -> BinocResult<ItemPair> {
318        Err(BinocError::Extract(format!(
319            "{} does not support reopen",
320            self.descriptor().name
321        )))
322    }
323
324    /// Extract user-facing data from a node this comparator produced.
325    fn extract(
326        &self,
327        _node: &DiffNode,
328        _aspect: &str,
329        _data: &dyn DataAccess,
330    ) -> Option<ExtractResult> {
331        None
332    }
333}
334
335/// A plugin that rewrites the completed diff tree.
336///
337/// Matching is declarative via [`TransformerDescriptor`]. If a matched
338/// node should not be transformed, return [`TransformResult::Unchanged`].
339///
340/// `config` is the per-transformer JSON value resolved from the host's
341/// `DatasetConfig.transformer_config` map (keyed by transformer name).
342/// Plugins that don't need configuration can ignore it; the default
343/// value for unconfigured transformers is [`serde_json::Value::Null`].
344pub trait Transformer: Send + Sync {
345    fn descriptor(&self) -> TransformerDescriptor;
346
347    fn transform(
348        &self,
349        node: DiffNode,
350        data: &dyn DataAccess,
351        config: &serde_json::Value,
352    ) -> TransformResult;
353
354    /// Extract user-facing data from a node this transformer modified.
355    fn extract(
356        &self,
357        _node: &DiffNode,
358        _aspect: &str,
359        _data: &dyn DataAccess,
360    ) -> Option<ExtractResult> {
361        None
362    }
363}
364
365/// A plugin that renders changesets into a human-readable format.
366pub trait Renderer: Send + Sync {
367    fn descriptor(&self) -> RendererDescriptor;
368
369    fn render(&self, changesets: &[Changeset], config: &serde_json::Value) -> BinocResult<String>;
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn same_version_is_compatible() {
378        assert!(check_sdk_compatibility("test", SDK_VERSION).is_ok());
379    }
380
381    #[test]
382    fn patch_difference_is_compatible() {
383        let host = parse_semver(SDK_VERSION).unwrap();
384        let tweaked = format!("{}.{}.99", host.0, host.1);
385        assert!(check_sdk_compatibility("test", &tweaked).is_ok());
386    }
387
388    #[test]
389    fn older_minor_within_floor_is_compatible() {
390        let host = parse_semver(SDK_VERSION).unwrap();
391        if host.0 != 0 || host.1 < MIN_COMPATIBLE_MINOR {
392            return;
393        }
394        let oldest_ok = format!("0.{}.0", MIN_COMPATIBLE_MINOR);
395        assert!(check_sdk_compatibility("test", &oldest_ok).is_ok());
396    }
397
398    #[test]
399    fn older_minor_below_floor_rejected() {
400        if MIN_COMPATIBLE_MINOR == 0 {
401            return; // no floor to test
402        }
403        let too_old = format!("0.{}.0", MIN_COMPATIBLE_MINOR - 1);
404        assert!(check_sdk_compatibility("test", &too_old).is_err());
405    }
406
407    #[test]
408    fn newer_minor_rejected_during_0x() {
409        let host = parse_semver(SDK_VERSION).unwrap();
410        if host.0 != 0 {
411            return;
412        }
413        let tweaked = format!("0.{}.0", host.1 + 1);
414        assert!(check_sdk_compatibility("test", &tweaked).is_err());
415    }
416
417    #[test]
418    fn garbage_version_rejected() {
419        assert!(check_sdk_compatibility("test", "not-a-version").is_err());
420    }
421}