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)]
156    pub scope: TransformScope,
157    #[serde(default = "default_phase")]
158    pub suggested_phase: String,
159    /// Artifact formats the node must have (any one suffices).
160    /// Empty means no artifact filter.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub match_artifacts: Vec<ArtifactFormat>,
163    /// Dispatch filter on node shape. `Container` matches only nodes
164    /// with children; `Leaf` matches only childless nodes; `Any` (the
165    /// default) is unconstrained.
166    #[serde(default)]
167    pub node_shape: NodeShapeFilter,
168}
169
170fn default_phase() -> String {
171    "default".into()
172}
173
174impl TransformerDescriptor {
175    pub fn new(name: impl Into<String>) -> Self {
176        Self {
177            sdk_version: SDK_VERSION.into(),
178            name: name.into(),
179            match_types: Vec::new(),
180            match_tags: Vec::new(),
181            match_actions: Vec::new(),
182            scope: TransformScope::Node,
183            suggested_phase: "default".into(),
184            match_artifacts: Vec::new(),
185            node_shape: NodeShapeFilter::Any,
186        }
187    }
188
189    pub fn with_match_types(mut self, types: Vec<String>) -> Self {
190        self.match_types = types;
191        self
192    }
193
194    pub fn with_match_tags(mut self, tags: Vec<String>) -> Self {
195        self.match_tags = tags;
196        self
197    }
198
199    pub fn with_match_actions(mut self, actions: Vec<String>) -> Self {
200        self.match_actions = actions;
201        self
202    }
203
204    pub fn with_scope(mut self, scope: TransformScope) -> Self {
205        self.scope = scope;
206        self
207    }
208
209    pub fn with_match_artifacts(mut self, formats: Vec<ArtifactFormat>) -> Self {
210        self.match_artifacts = formats;
211        self
212    }
213
214    pub fn with_node_shape(mut self, shape: NodeShapeFilter) -> Self {
215        self.node_shape = shape;
216        self
217    }
218}
219
220/// Static metadata for a renderer plugin.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[non_exhaustive]
223pub struct RendererDescriptor {
224    pub sdk_version: String,
225    pub name: String,
226    pub file_extension: String,
227}
228
229impl RendererDescriptor {
230    pub fn new(name: impl Into<String>, file_extension: impl Into<String>) -> Self {
231        Self {
232            sdk_version: SDK_VERSION.into(),
233            name: name.into(),
234            file_extension: file_extension.into(),
235        }
236    }
237}
238
239// ── DataAccess ──────────────────────────────────────────────────────
240
241/// Mediates all data I/O for plugins. Replaces direct filesystem access
242/// (`Item.physical_path`) and shared mutable context (`CompareContext`).
243///
244/// In-process: backed by the local filesystem + temp dirs.
245/// Cross-ABI: backed by a shared `data_root` directory so host and plugin
246/// can exchange artifacts via `publish_artifact()`/`get_artifact()`.
247pub trait DataAccess: Send + Sync {
248    /// Read the full contents of an item as bytes.
249    fn read_bytes(&self, item: &ItemRef) -> BinocResult<Vec<u8>>;
250
251    /// Open a streaming reader for an item.
252    fn open_read(&self, item: &ItemRef) -> BinocResult<Box<dyn std::io::Read + Send>>;
253
254    /// Get a local filesystem path for tools that require one (e.g. SQLite).
255    /// Not available on all backends — prefer read_bytes/open_read.
256    fn local_path(&self, item: &ItemRef) -> BinocResult<PathBuf>;
257
258    /// Make new data available as an item (for container expansion).
259    /// Returns an ItemRef usable in child ItemPairs.
260    fn provide(&self, logical_path: &str, content: &[u8]) -> BinocResult<ItemRef>;
261
262    /// Get a fresh writable workspace directory.
263    /// Managed by the DataAccess — cleaned up when the diff operation completes.
264    fn workspace(&self) -> BinocResult<PathBuf>;
265
266    /// Register a local filesystem path as a known item.
267    /// Returns an ItemRef that can be used in child ItemPairs.
268    fn register_local(&self, physical: &Path, logical: &str) -> BinocResult<ItemRef>;
269
270    /// Publish an artifact: store opaque bytes and return a descriptor.
271    ///
272    /// Artifacts are the unified mechanism for both private reuse and
273    /// cross-plugin composition. A comparator or transformer publishes
274    /// zero or more artifacts per node; downstream plugins retrieve them
275    /// by format and subject.
276    ///
277    /// `format` is a structured (package, name, version) tuple — see
278    /// [`ArtifactFormat`]. `subject` indicates which side of the
279    /// comparison the artifact describes. `producer` is the plugin name
280    /// for provenance. The returned `ArtifactDescriptor` should be
281    /// attached to the node via `DiffNode.artifacts`.
282    fn publish_artifact(
283        &self,
284        format: &ArtifactFormat,
285        subject: ArtifactSubject,
286        producer: &str,
287        data: &[u8],
288    ) -> BinocResult<ArtifactDescriptor>;
289
290    /// Retrieve the bytes for a previously published artifact.
291    fn get_artifact(&self, descriptor: &ArtifactDescriptor) -> BinocResult<Option<Vec<u8>>>;
292
293    /// Session-level root directory shared between host and plugins.
294    /// Artifact files live under `<data_root>/.artifacts/`. ABI requests
295    /// carry this path so native plugins can construct a `LocalDataAccess`
296    /// that reads from the same artifact store.
297    fn data_root(&self) -> BinocResult<PathBuf>;
298}
299
300// ── Plugin traits ───────────────────────────────────────────────────
301
302/// A plugin that claims an item pair and either emits a leaf diff or
303/// expands the pair into child items for further processing.
304///
305/// Routing is fully declarative via [`ComparatorDescriptor`]. If the
306/// descriptor matches but the comparator discovers at compare-time that
307/// it cannot handle the item, it returns [`CompareResult::Skip`].
308pub trait Comparator: Send + Sync {
309    fn descriptor(&self) -> ComparatorDescriptor;
310
311    fn compare(&self, pair: &ItemPair, data: &dyn DataAccess) -> BinocResult<CompareResult>;
312
313    /// Reconstruct physical access to a child item without re-diffing.
314    /// Container comparators (zip, directory, tar) override this to
315    /// extract or resolve a child path within the container, returning
316    /// an `ItemPair` that downstream comparators can work with.
317    ///
318    /// Used by the extract chain: the controller walks ancestor nodes
319    /// calling `reopen()` to progressively reconstruct the scratchpad.
320    fn reopen(
321        &self,
322        _pair: &ItemPair,
323        _child_path: &str,
324        _data: &dyn DataAccess,
325    ) -> BinocResult<ItemPair> {
326        Err(BinocError::Extract(format!(
327            "{} does not support reopen",
328            self.descriptor().name
329        )))
330    }
331
332    /// Extract user-facing data from a node this comparator produced.
333    fn extract(
334        &self,
335        _node: &DiffNode,
336        _aspect: &str,
337        _data: &dyn DataAccess,
338    ) -> Option<ExtractResult> {
339        None
340    }
341}
342
343/// A plugin that rewrites the completed diff tree.
344///
345/// Matching is declarative via [`TransformerDescriptor`]. If a matched
346/// node should not be transformed, return [`TransformResult::Unchanged`].
347pub trait Transformer: Send + Sync {
348    fn descriptor(&self) -> TransformerDescriptor;
349
350    fn transform(&self, node: DiffNode, data: &dyn DataAccess) -> TransformResult;
351
352    /// Extract user-facing data from a node this transformer modified.
353    fn extract(
354        &self,
355        _node: &DiffNode,
356        _aspect: &str,
357        _data: &dyn DataAccess,
358    ) -> Option<ExtractResult> {
359        None
360    }
361}
362
363/// A plugin that renders changesets into a human-readable format.
364pub trait Renderer: Send + Sync {
365    fn descriptor(&self) -> RendererDescriptor;
366
367    fn render(&self, changesets: &[Changeset], config: &serde_json::Value) -> BinocResult<String>;
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn same_version_is_compatible() {
376        assert!(check_sdk_compatibility("test", SDK_VERSION).is_ok());
377    }
378
379    #[test]
380    fn patch_difference_is_compatible() {
381        let host = parse_semver(SDK_VERSION).unwrap();
382        let tweaked = format!("{}.{}.99", host.0, host.1);
383        assert!(check_sdk_compatibility("test", &tweaked).is_ok());
384    }
385
386    #[test]
387    fn older_minor_within_floor_is_compatible() {
388        let host = parse_semver(SDK_VERSION).unwrap();
389        if host.0 != 0 || host.1 < MIN_COMPATIBLE_MINOR {
390            return;
391        }
392        let oldest_ok = format!("0.{}.0", MIN_COMPATIBLE_MINOR);
393        assert!(check_sdk_compatibility("test", &oldest_ok).is_ok());
394    }
395
396    #[test]
397    fn older_minor_below_floor_rejected() {
398        if MIN_COMPATIBLE_MINOR == 0 {
399            return; // no floor to test
400        }
401        let too_old = format!("0.{}.0", MIN_COMPATIBLE_MINOR - 1);
402        assert!(check_sdk_compatibility("test", &too_old).is_err());
403    }
404
405    #[test]
406    fn newer_minor_rejected_during_0x() {
407        let host = parse_semver(SDK_VERSION).unwrap();
408        if host.0 != 0 {
409            return;
410        }
411        let tweaked = format!("0.{}.0", host.1 + 1);
412        assert!(check_sdk_compatibility("test", &tweaked).is_err());
413    }
414
415    #[test]
416    fn garbage_version_rejected() {
417        assert!(check_sdk_compatibility("test", "not-a-version").is_err());
418    }
419}