Skip to main content

binoc_sdk/
traits.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::ir::{Changeset, Diagnostic};
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("csv error: {0}")]
17    Csv(String),
18    #[error("zip error: {0}")]
19    Zip(String),
20    #[error("tar error: {0}")]
21    Tar(String),
22    #[error("gzip error: {0}")]
23    Gzip(String),
24    #[error("extract error: {0}")]
25    Extract(String),
26    #[error("path policy: {0}")]
27    PathPolicy(String),
28    #[error(
29        "SDK version mismatch: {plugin} (plugin '{name}') is not compatible with host SDK {host}"
30    )]
31    SdkVersion {
32        name: String,
33        plugin: String,
34        host: String,
35    },
36    #[error("{0}")]
37    Other(String),
38}
39
40// ── Descriptors ─────────────────────────────────────────────────────
41
42pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
43
44/// Oldest SDK minor version that this host can still accept.
45/// Bump this when a protocol change makes older plugins incompatible.
46/// Leave it alone when only adding new `#[serde(default)]` fields.
47const MIN_COMPATIBLE_MINOR: u64 = 1;
48
49/// Check whether a plugin's SDK version is compatible with this host's SDK.
50///
51/// During 0.x: plugin minor version must be in `[MIN_COMPATIBLE_MINOR, host_minor]`
52/// (same major, patch may differ).
53/// After 1.0: plugin major must equal host major, plugin minor <= host minor
54/// (standard semver — host is backward-compatible within a major).
55pub fn check_sdk_compatibility(plugin_name: &str, plugin_version: &str) -> BinocResult<()> {
56    let host = parse_semver(SDK_VERSION);
57    let plugin = parse_semver(plugin_version);
58
59    let compatible = match (host, plugin) {
60        (Some((hm, hi, _)), Some((pm, pi, _))) if hm == 0 => {
61            hm == pm && pi >= MIN_COMPATIBLE_MINOR && pi <= hi
62        }
63        (Some((hm, hi, _)), Some((pm, pi, _))) => hm == pm && pi <= hi,
64        _ => false,
65    };
66
67    if compatible {
68        Ok(())
69    } else {
70        Err(BinocError::SdkVersion {
71            name: plugin_name.to_string(),
72            plugin: plugin_version.to_string(),
73            host: SDK_VERSION.to_string(),
74        })
75    }
76}
77
78fn parse_semver(v: &str) -> Option<(u64, u64, u64)> {
79    let mut parts = v.split('.');
80    let major = parts.next()?.parse().ok()?;
81    let minor = parts.next()?.parse().ok()?;
82    let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
83    Some((major, minor, patch))
84}
85
86/// Static metadata for a renderer plugin.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[non_exhaustive]
89pub struct RendererDescriptor {
90    pub sdk_version: String,
91    pub name: String,
92    pub file_extension: String,
93}
94
95impl RendererDescriptor {
96    pub fn new(name: impl Into<String>, file_extension: impl Into<String>) -> Self {
97        Self {
98            sdk_version: SDK_VERSION.into(),
99            name: name.into(),
100            file_extension: file_extension.into(),
101        }
102    }
103}
104
105// ── DataAccess ──────────────────────────────────────────────────────
106
107/// Mediates all data I/O for plugins. Replaces direct filesystem access
108/// (`Item.physical_path`) and shared mutable context (`CompareContext`).
109///
110/// In-process: backed by the local filesystem + temp dirs.
111/// Cross-ABI: backed by a shared `data_root` directory so host and plugin
112/// can exchange artifacts via `publish_artifact()`/`get_artifact()`.
113pub trait DataAccess: Send + Sync {
114    /// Read the full contents of an item as bytes.
115    fn read_bytes(&self, item: &ItemRef) -> BinocResult<Vec<u8>>;
116
117    /// Open a streaming reader for an item.
118    fn open_read(&self, item: &ItemRef) -> BinocResult<Box<dyn std::io::Read + Send>>;
119
120    /// Get a local filesystem path for tools that require one (e.g. SQLite).
121    /// Not available on all backends — prefer read_bytes/open_read.
122    fn local_path(&self, item: &ItemRef) -> BinocResult<PathBuf>;
123
124    /// Make new data available as an item (for container expansion).
125    /// Returns an ItemRef usable in child ItemPairs.
126    fn provide(&self, logical_path: &str, content: &[u8]) -> BinocResult<ItemRef>;
127
128    /// Get a fresh writable workspace directory.
129    /// Managed by the DataAccess — cleaned up when the diff operation completes.
130    fn workspace(&self) -> BinocResult<PathBuf>;
131
132    /// Register a local filesystem path as a known item.
133    /// Returns an ItemRef that can be used in child ItemPairs.
134    fn register_local(&self, physical: &Path, logical: &str) -> BinocResult<ItemRef>;
135
136    /// Publish an artifact: store opaque bytes and return a descriptor.
137    ///
138    /// Artifacts are the unified mechanism for both private reuse and
139    /// cross-plugin composition. Parse rules publish artifacts; downstream
140    /// rules retrieve them by format and subject.
141    ///
142    /// `format` is a structured (package, name, version) tuple — see
143    /// [`ArtifactFormat`]. `subject` indicates which side of the
144    /// comparison the artifact describes. `producer` is the plugin name
145    /// for provenance. The returned `ArtifactDescriptor` should be
146    /// carried by the session's artifact store.
147    fn publish_artifact(
148        &self,
149        format: &ArtifactFormat,
150        subject: ArtifactSubject,
151        producer: &str,
152        data: &[u8],
153    ) -> BinocResult<ArtifactDescriptor>;
154
155    /// Retrieve the bytes for a previously published artifact.
156    fn get_artifact(&self, descriptor: &ArtifactDescriptor) -> BinocResult<Option<Vec<u8>>>;
157
158    /// Session-level root directory shared between host and plugins.
159    /// Artifact files live under `<data_root>/.artifacts/`. ABI requests
160    /// carry this path so native plugins can construct a `LocalDataAccess`
161    /// that reads from the same artifact store.
162    fn data_root(&self) -> BinocResult<PathBuf>;
163}
164
165/// A plugin that renders changesets into a human-readable format.
166pub trait Renderer: Send + Sync {
167    fn descriptor(&self) -> RendererDescriptor;
168
169    fn render(&self, changesets: &[Changeset], config: &serde_json::Value) -> BinocResult<String>;
170
171    fn diagnostics(&self, _changeset: &Changeset, _config: &serde_json::Value) -> Vec<Diagnostic> {
172        Vec::new()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn same_version_is_compatible() {
182        assert!(check_sdk_compatibility("test", SDK_VERSION).is_ok());
183    }
184
185    #[test]
186    fn patch_difference_is_compatible() {
187        let host = parse_semver(SDK_VERSION).unwrap();
188        let tweaked = format!("{}.{}.99", host.0, host.1);
189        assert!(check_sdk_compatibility("test", &tweaked).is_ok());
190    }
191
192    #[test]
193    fn older_minor_within_floor_is_compatible() {
194        let host = parse_semver(SDK_VERSION).unwrap();
195        if host.0 != 0 || host.1 < MIN_COMPATIBLE_MINOR {
196            return;
197        }
198        let oldest_ok = format!("0.{}.0", MIN_COMPATIBLE_MINOR);
199        assert!(check_sdk_compatibility("test", &oldest_ok).is_ok());
200    }
201
202    #[test]
203    fn older_minor_below_floor_rejected() {
204        if MIN_COMPATIBLE_MINOR == 0 {
205            return; // no floor to test
206        }
207        let too_old = format!("0.{}.0", MIN_COMPATIBLE_MINOR - 1);
208        assert!(check_sdk_compatibility("test", &too_old).is_err());
209    }
210
211    #[test]
212    fn newer_minor_rejected_during_0x() {
213        let host = parse_semver(SDK_VERSION).unwrap();
214        if host.0 != 0 {
215            return;
216        }
217        let tweaked = format!("0.{}.0", host.1 + 1);
218        assert!(check_sdk_compatibility("test", &tweaked).is_err());
219    }
220
221    #[test]
222    fn garbage_version_rejected() {
223        assert!(check_sdk_compatibility("test", "not-a-version").is_err());
224    }
225}