Skip to main content

dpp_plugin_traits/
lib.rs

1//! Host/guest ABI contract for Odal Node sector plugins.
2//!
3//! Plugins implement [`DppSectorPlugin`] and export the three entry points
4//! as `extern "C"` symbols. The host invokes them through the wasmtime
5//! component model or directly via the low-level ABI defined below.
6//!
7//! The interface is intentionally `no_std`-friendly: no heap allocations
8//! are required from the host's perspective. Data is passed as JSON strings
9//! over a shared-memory slice.
10//!
11//! ## Versioning
12//!
13//! Every plugin declares which ABI version and schema versions it supports
14//! via [`PluginCapabilities`]. The host uses this for compatibility checks
15//! before dispatching any calls.
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19// ─── ABI version ────────────────────────────────────────────────────────────
20
21/// Current host ABI version.
22///
23/// Increment the major version for breaking changes to the plugin interface.
24/// Increment the minor version for backward-compatible additions.
25pub const ABI_VERSION_MAJOR: u32 = 1;
26// 1.1: PluginResult gained backward-compatible `violations`/`warnings` finding
27// lists. Older (1.0) plugins omit them (serde defaults to empty) and still load.
28pub const ABI_VERSION_MINOR: u32 = 1;
29
30/// ABI version declared by a plugin.
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "camelCase")]
33pub struct AbiVersion {
34    pub major: u32,
35    pub minor: u32,
36}
37
38impl AbiVersion {
39    pub const fn current() -> Self {
40        Self {
41            major: ABI_VERSION_MAJOR,
42            minor: ABI_VERSION_MINOR,
43        }
44    }
45
46    /// Check if this ABI version is compatible with the host.
47    ///
48    /// Major versions must match exactly. The plugin's minor version must be
49    /// ≤ the host's minor version (the host supports all older minor versions).
50    #[allow(clippy::absurd_extreme_comparisons)] // intentional: works correctly when ABI_VERSION_MINOR > 0
51    pub fn is_compatible_with_host(&self) -> bool {
52        self.major == ABI_VERSION_MAJOR && self.minor <= ABI_VERSION_MINOR
53    }
54}
55
56impl std::fmt::Display for AbiVersion {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(f, "{}.{}", self.major, self.minor)
59    }
60}
61
62// ─── Plugin identity ────────────────────────────────────────────────────────
63
64/// Static metadata returned by a plugin.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct PluginMeta {
68    /// Sector key this plugin handles, e.g. `"textile"`, `"steel"`, `"battery"`.
69    pub sector: String,
70    /// Human-readable plugin name.
71    pub name: String,
72    /// SemVer version string of the plugin itself.
73    pub version: String,
74    /// SPDX license identifier.
75    pub license: String,
76    /// Brief description of what this plugin does.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub description: Option<String>,
79    /// Plugin author or organisation.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub author: Option<String>,
82    /// URL for plugin documentation or source code.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub homepage: Option<String>,
85}
86
87// ─── Capabilities ───────────────────────────────────────────────────────────
88
89/// Schema version range a plugin supports.
90///
91/// A plugin may support multiple schema versions (e.g., it can validate
92/// both v1.0.0 and v1.1.0 textile data). The host uses this to dispatch
93/// data to the correct plugin version.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "camelCase")]
96pub struct SchemaVersionRange {
97    /// Minimum supported schema version (inclusive), e.g. `"1.0.0"`.
98    pub min_version: String,
99    /// Maximum supported schema version (inclusive), e.g. `"1.1.0"`.
100    pub max_version: String,
101}
102
103/// Feature flags a plugin may declare support for.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
105#[serde(rename_all = "snake_case")]
106pub enum PluginCapability {
107    /// Can validate sector-specific data against the schema.
108    Validate,
109    /// Can compute compliance metrics (CO2e, repairability, etc.).
110    ComputeMetrics,
111    /// Can generate a passport-ready data payload.
112    GeneratePassport,
113    /// Can perform SVHC / substance-of-concern screening.
114    SubstanceScreening,
115    /// Can compute lifecycle assessment (LCA) metrics.
116    LifecycleAssessment,
117    /// Can map data to Asset Administration Shell (AAS) submodels.
118    AasMapping,
119    /// Custom capability (plugin-defined extension point).
120    Custom(String),
121}
122
123/// Full capability declaration returned by a plugin during negotiation.
124///
125/// The host calls `capabilities()` before dispatching any work to verify
126/// that the plugin supports the required schema version and features.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct PluginCapabilities {
130    /// The ABI version this plugin was compiled against.
131    pub abi_version: AbiVersion,
132    /// The sector schemas this plugin can handle.
133    pub supported_schemas: Vec<SchemaVersionRange>,
134    /// Feature capabilities this plugin provides.
135    pub capabilities: Vec<PluginCapability>,
136    /// Minimum host ABI version required by this plugin.
137    /// If the host's ABI is below this, the plugin refuses to load.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub min_host_version: Option<AbiVersion>,
140    /// Plugin-declared fuel budget per invocation (host caps at DEFAULT_FUEL).
141    /// Plugins needing less computation can set this lower for tighter sandboxing.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub max_fuel: Option<u64>,
144    /// Plugin-declared memory cap in bytes per invocation (host caps at DEFAULT_MEMORY_CAP_BYTES).
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub max_memory_bytes: Option<u64>,
147}
148
149// ─── Compatibility check result ─────────────────────────────────────────────
150
151/// Result of a compatibility check between host and plugin.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum CompatibilityStatus {
154    /// Fully compatible — all checks pass.
155    Compatible,
156    /// ABI version mismatch — major version differs.
157    AbiIncompatible {
158        host: AbiVersion,
159        plugin: AbiVersion,
160    },
161    /// Plugin requires a newer host than what's running.
162    HostTooOld {
163        required: AbiVersion,
164        actual: AbiVersion,
165    },
166    /// The plugin doesn't support the requested schema version.
167    SchemaUnsupported {
168        requested: String,
169        supported: Vec<SchemaVersionRange>,
170    },
171    /// Missing a required capability.
172    MissingCapability(PluginCapability),
173}
174
175impl CompatibilityStatus {
176    pub fn is_compatible(&self) -> bool {
177        matches!(self, Self::Compatible)
178    }
179}
180
181/// Check if a plugin is compatible with the current host and a requested
182/// schema version.
183pub fn check_compatibility(
184    capabilities: &PluginCapabilities,
185    requested_schema_version: Option<&str>,
186    required_capabilities: &[PluginCapability],
187) -> CompatibilityStatus {
188    // 1. ABI version check
189    if !capabilities.abi_version.is_compatible_with_host() {
190        return CompatibilityStatus::AbiIncompatible {
191            host: AbiVersion::current(),
192            plugin: capabilities.abi_version,
193        };
194    }
195
196    // 2. Min host version check
197    if let Some(ref min_host) = capabilities.min_host_version {
198        let current = AbiVersion::current();
199        if current.major < min_host.major
200            || (current.major == min_host.major && current.minor < min_host.minor)
201        {
202            return CompatibilityStatus::HostTooOld {
203                required: *min_host,
204                actual: current,
205            };
206        }
207    }
208
209    // 3. Schema version check (semantic via semver crate; falls back to lexicographic)
210    if let Some(requested) = requested_schema_version {
211        let req = semver::Version::parse(requested).ok();
212        let supported = capabilities.supported_schemas.iter().any(|range| {
213            let lo = semver::Version::parse(&range.min_version).ok();
214            let hi = semver::Version::parse(&range.max_version).ok();
215            match (req.as_ref(), lo, hi) {
216                (Some(r), Some(l), Some(h)) => r >= &l && r <= &h,
217                _ => {
218                    requested >= range.min_version.as_str()
219                        && requested <= range.max_version.as_str()
220                }
221            }
222        });
223        if !supported {
224            return CompatibilityStatus::SchemaUnsupported {
225                requested: requested.to_owned(),
226                supported: capabilities.supported_schemas.clone(),
227            };
228        }
229    }
230
231    // 4. Capability check
232    for required in required_capabilities {
233        if !capabilities.capabilities.contains(required) {
234            return CompatibilityStatus::MissingCapability(required.clone());
235        }
236    }
237
238    CompatibilityStatus::Compatible
239}
240
241// ─── Plugin input/output ────────────────────────────────────────────────────
242
243/// Raw JSON input passed from the host to a plugin entry point.
244pub type PluginInput = serde_json::Value;
245
246/// Typed compliance determination returned by a plugin.
247///
248/// Mirrors `dpp_domain::ports::compliance::ComplianceStatus` so the host maps
249/// directly from one typed enum to the other rather than parsing a string.
250/// Serialised as SCREAMING_SNAKE_CASE JSON strings for ABI stability.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
253pub enum PluginComplianceStatus {
254    Compliant,
255    NonCompliant,
256    NotAssessed,
257    PassthroughNoValidation,
258    NotImplemented,
259}
260
261/// Well-known metric key for CO₂e score (kg CO₂e per functional unit).
262pub const METRIC_CO2E_SCORE: &str = "co2e_score";
263/// Well-known metric key for the repairability index (0.0–10.0; non-regulatory
264/// heuristic, not EN 45554 / EU 2023/1669).
265pub const METRIC_REPAIRABILITY_INDEX: &str = "repairability_index";
266/// Well-known metric key for recycled content percentage (0.0–100.0).
267pub const METRIC_RECYCLED_CONTENT_PCT: &str = "recycled_content_pct";
268
269/// A single determination finding emitted by a plugin's `calculate_metrics`.
270///
271/// Findings split into [`PluginResult::violations`] (binding — the host blocks
272/// publish for an in-force sector) and [`PluginResult::warnings`]
273/// (advisory/experimental — surfaced, never blocks). The vec encodes severity,
274/// so there is no separate severity field. Maps 1:1 onto the host's
275/// `ComplianceFinding`.
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct PluginFinding {
279    /// Stable machine-readable code, e.g. `"battery.recycled_content.cobalt_below_2031"`.
280    pub code: String,
281    /// JSON-pointer-style field locator (e.g. `"/recycledContentCobaltPct"`), or
282    /// empty when the finding is not tied to a single field.
283    #[serde(default, skip_serializing_if = "String::is_empty")]
284    pub field: String,
285    /// Human-readable explanation.
286    pub message: String,
287}
288
289impl PluginFinding {
290    /// Construct a finding from its code, field locator, and message.
291    pub fn new(
292        code: impl Into<String>,
293        field: impl Into<String>,
294        message: impl Into<String>,
295    ) -> Self {
296        Self {
297            code: code.into(),
298            field: field.into(),
299            message: message.into(),
300        }
301    }
302}
303
304/// Compliance result returned by the plugin.
305///
306/// `metrics` is a sector-extensible map of named numeric values. Use the
307/// `METRIC_*` constants for the three well-known fields; plugins may add
308/// sector-specific keys (`"water_use_litres"`, `"pef_score"`, …).
309///
310/// ## Aligning with `ComplianceResult` on the host
311///
312/// ```text
313/// co2e_score           ← metrics["co2e_score"]
314/// repairability_index  ← metrics["repairability_index"]
315/// recycled_content_pct ← metrics["recycled_content_pct"]
316/// compliance_status    ← PluginComplianceStatus → ComplianceStatus (typed map)
317/// violations/warnings  ← PluginFinding → ComplianceFinding (host blocks on violations)
318/// ```
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct PluginResult {
322    /// Typed compliance determination.
323    pub compliance_status: PluginComplianceStatus,
324    /// Sector-extensible keyed metric map (all values finite f64).
325    #[serde(default)]
326    pub metrics: std::collections::HashMap<String, f64>,
327    /// Non-numeric sector-specific data (free-form; stored verbatim in extra).
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub extra: Option<serde_json::Value>,
330    /// Binding findings — the host blocks publish for an in-force sector. Empty
331    /// for passthrough / not-assessed determinations. (ABI 1.1+)
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub violations: Vec<PluginFinding>,
334    /// Advisory / experimental findings — surfaced but never block publish (e.g.
335    /// recycled-content thresholds that are not yet in force). (ABI 1.1+)
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub warnings: Vec<PluginFinding>,
338}
339
340impl PluginResult {
341    /// Construct a result carrying only a compliance status.
342    pub fn new(status: PluginComplianceStatus) -> Self {
343        Self {
344            compliance_status: status,
345            metrics: std::collections::HashMap::new(),
346            extra: None,
347            violations: Vec::new(),
348            warnings: Vec::new(),
349        }
350    }
351
352    /// Insert a metric unconditionally (non-finite values are silently dropped).
353    pub fn with_metric(mut self, key: &str, value: f64) -> Self {
354        if value.is_finite() {
355            self.metrics.insert(key.to_owned(), value);
356        }
357        self
358    }
359
360    /// Insert a metric only when `value` is `Some` (non-finite values dropped).
361    pub fn maybe_metric(mut self, key: &str, value: Option<f64>) -> Self {
362        if let Some(v) = value
363            && v.is_finite()
364        {
365            self.metrics.insert(key.to_owned(), v);
366        }
367        self
368    }
369
370    /// Attach free-form non-numeric extra data.
371    pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
372        self.extra = Some(extra);
373        self
374    }
375
376    /// Append a binding violation (host blocks publish for an in-force sector).
377    pub fn with_violation(mut self, finding: PluginFinding) -> Self {
378        self.violations.push(finding);
379        self
380    }
381
382    /// Append an advisory warning (surfaced, never blocks publish).
383    pub fn with_warning(mut self, finding: PluginFinding) -> Self {
384        self.warnings.push(finding);
385        self
386    }
387
388    // ── Convenience accessors for the three well-known metrics ──────────────
389
390    pub fn co2e_score(&self) -> Option<f64> {
391        self.metrics.get(METRIC_CO2E_SCORE).copied()
392    }
393
394    pub fn repairability_index(&self) -> Option<f64> {
395        self.metrics.get(METRIC_REPAIRABILITY_INDEX).copied()
396    }
397
398    pub fn recycled_content_pct(&self) -> Option<f64> {
399        self.metrics.get(METRIC_RECYCLED_CONTENT_PCT).copied()
400    }
401}
402
403// ─── ABI response envelope ────────────────────────────────────────────────────
404
405/// JSON envelope wrapping the outcome of a fallible plugin ABI call.
406///
407/// The low-level Wasm exports (`validate`, `calculate_metrics`,
408/// `generate_passport`) cannot return a Rust `Result` across the C ABI, so the
409/// outcome is serialised as this externally-tagged enum. On success, `ok`
410/// carries the method's return value (a [`PluginResult`] for
411/// `calculate_metrics`, the normalised payload for `generate_passport`, or
412/// `null` for `validate`). On failure, `error` carries a structured
413/// [`PluginError`]. The host deserialises this to recover the typed result.
414///
415/// ```json
416/// { "ok": { "co2eScore": 85.4, "complianceStatus": "NOT_ASSESSED", ... } }
417/// { "error": { "ValidationErrors": [ { "field": "/gtin", "code": "missing", ... } ] } }
418/// ```
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum AbiResult {
422    Ok(serde_json::Value),
423    Error(PluginError),
424}
425
426impl AbiResult {
427    /// Build a successful response by serialising `value`.
428    pub fn ok<T: Serialize>(value: &T) -> Self {
429        Self::Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null))
430    }
431
432    /// Returns `true` if this is the success variant.
433    pub fn is_ok(&self) -> bool {
434        matches!(self, Self::Ok(_))
435    }
436}
437
438// ─── Plugin errors ──────────────────────────────────────────────────────────
439
440/// Structured error with field-level detail.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(rename_all = "camelCase")]
443pub struct PluginFieldError {
444    /// JSON pointer to the failing field, e.g. `"/fibreComposition/0/pct"`.
445    pub field: String,
446    /// Error code for programmatic handling (e.g. `"out_of_range"`, `"missing"`).
447    pub code: String,
448    /// Human-readable error message.
449    pub message: String,
450}
451
452#[derive(Debug, Clone, Error, Serialize, Deserialize)]
453pub enum PluginError {
454    #[error("invalid input: {0}")]
455    InvalidInput(String),
456    #[error("validation errors: {0:?}")]
457    ValidationErrors(Vec<PluginFieldError>),
458    #[error("calculation failed: {0}")]
459    Calculation(String),
460    #[error("sector not supported by this plugin: {0}")]
461    UnsupportedSector(String),
462    #[error("schema version not supported: {0}")]
463    UnsupportedSchemaVersion(String),
464    #[error("capability not available: {0}")]
465    CapabilityNotAvailable(String),
466    #[error("internal plugin error: {0}")]
467    Internal(String),
468}
469
470// ─── Host-side trait ────────────────────────────────────────────────────────
471
472/// The entry points every sector plugin must export.
473///
474/// The Wasm host calls these after deserialising JSON sector data from the
475/// passport payload. Implementations must be deterministic and free of I/O.
476pub trait DppSectorPlugin: Send + Sync {
477    /// Returns static metadata about this plugin.
478    fn meta(&self) -> PluginMeta;
479
480    /// Returns the plugin's capability declaration for version negotiation.
481    fn capabilities(&self) -> PluginCapabilities;
482
483    /// Validate the structure and field constraints of the sector input.
484    ///
485    /// Returns `Ok(())` if the input is structurally valid, or a descriptive
486    /// error if a required field is missing or out of range. Prefer
487    /// `PluginError::ValidationErrors` with per-field detail over
488    /// `PluginError::InvalidInput` for better error reporting.
489    fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError>;
490
491    /// Compute compliance metrics from the sector input.
492    ///
493    /// May return `None` for fields that do not apply to this sector.
494    fn calculate_metrics(&self, input: &PluginInput) -> Result<PluginResult, PluginError>;
495
496    /// Generate a passport-ready sector data JSON payload.
497    ///
498    /// Applies any normalisation or enrichment required by the sector schema
499    /// (e.g. rounding, unit conversion). The output is stored verbatim in the DPP.
500    fn generate_passport(&self, input: &PluginInput) -> Result<serde_json::Value, PluginError>;
501}
502
503// ─── Tests ──────────────────────────────────────────────────────────────────
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    fn sample_capabilities() -> PluginCapabilities {
510        PluginCapabilities {
511            abi_version: AbiVersion::current(),
512            supported_schemas: vec![SchemaVersionRange {
513                min_version: "1.0.0".into(),
514                max_version: "1.1.0".into(),
515            }],
516            capabilities: vec![
517                PluginCapability::Validate,
518                PluginCapability::ComputeMetrics,
519                PluginCapability::GeneratePassport,
520            ],
521            min_host_version: None,
522            max_fuel: None,
523            max_memory_bytes: None,
524        }
525    }
526
527    #[test]
528    fn abi_version_current_is_compatible() {
529        let current = AbiVersion::current();
530        assert!(current.is_compatible_with_host());
531    }
532
533    #[test]
534    fn abi_version_major_mismatch_incompatible() {
535        let future = AbiVersion { major: 2, minor: 0 };
536        assert!(!future.is_compatible_with_host());
537    }
538
539    #[test]
540    fn abi_version_minor_ahead_incompatible() {
541        let ahead = AbiVersion {
542            major: ABI_VERSION_MAJOR,
543            minor: ABI_VERSION_MINOR + 1,
544        };
545        assert!(!ahead.is_compatible_with_host());
546    }
547
548    #[test]
549    fn abi_version_display() {
550        let v = AbiVersion { major: 1, minor: 0 };
551        assert_eq!(format!("{v}"), "1.0");
552    }
553
554    #[test]
555    fn compatibility_check_passes() {
556        let caps = sample_capabilities();
557        let result = check_compatibility(&caps, Some("1.0.0"), &[PluginCapability::Validate]);
558        assert!(result.is_compatible());
559    }
560
561    #[test]
562    fn compatibility_check_schema_in_range() {
563        let caps = sample_capabilities();
564        let result = check_compatibility(&caps, Some("1.1.0"), &[]);
565        assert!(result.is_compatible());
566    }
567
568    #[test]
569    fn compatibility_check_schema_out_of_range() {
570        let caps = sample_capabilities();
571        let result = check_compatibility(&caps, Some("2.0.0"), &[]);
572        assert!(matches!(
573            result,
574            CompatibilityStatus::SchemaUnsupported { .. }
575        ));
576    }
577
578    #[test]
579    fn semver_multi_digit_minor_accepted() {
580        // Lexicographic comparison would reject "1.10.0" within ["1.0.0", "1.10.0"]
581        // because "1.10.0" < "1.2.0" as strings. Semantic comparison must handle this.
582        let caps = PluginCapabilities {
583            abi_version: AbiVersion::current(),
584            supported_schemas: vec![SchemaVersionRange {
585                min_version: "1.0.0".into(),
586                max_version: "1.10.0".into(),
587            }],
588            capabilities: vec![],
589            min_host_version: None,
590            max_fuel: None,
591            max_memory_bytes: None,
592        };
593        let result = check_compatibility(&caps, Some("1.10.0"), &[]);
594        assert!(
595            result.is_compatible(),
596            "1.10.0 must be accepted within [1.0.0, 1.10.0]"
597        );
598    }
599
600    #[test]
601    fn semver_multi_digit_minor_rejected_correctly() {
602        // "1.10.0" must be rejected when max is "1.2.0"
603        let caps = PluginCapabilities {
604            abi_version: AbiVersion::current(),
605            supported_schemas: vec![SchemaVersionRange {
606                min_version: "1.0.0".into(),
607                max_version: "1.2.0".into(),
608            }],
609            capabilities: vec![],
610            min_host_version: None,
611            max_fuel: None,
612            max_memory_bytes: None,
613        };
614        let result = check_compatibility(&caps, Some("1.10.0"), &[]);
615        assert!(
616            matches!(result, CompatibilityStatus::SchemaUnsupported { .. }),
617            "1.10.0 must be rejected when max is 1.2.0"
618        );
619    }
620
621    #[test]
622    fn compatibility_check_missing_capability() {
623        let caps = sample_capabilities();
624        let result = check_compatibility(&caps, None, &[PluginCapability::SubstanceScreening]);
625        assert!(matches!(result, CompatibilityStatus::MissingCapability(_)));
626    }
627
628    #[test]
629    fn compatibility_check_abi_mismatch() {
630        let mut caps = sample_capabilities();
631        caps.abi_version = AbiVersion { major: 2, minor: 0 };
632        let result = check_compatibility(&caps, None, &[]);
633        assert!(matches!(
634            result,
635            CompatibilityStatus::AbiIncompatible { .. }
636        ));
637    }
638
639    #[test]
640    fn compatibility_check_host_too_old() {
641        let mut caps = sample_capabilities();
642        caps.min_host_version = Some(AbiVersion {
643            major: ABI_VERSION_MAJOR,
644            minor: ABI_VERSION_MINOR + 5,
645        });
646        let result = check_compatibility(&caps, None, &[]);
647        assert!(matches!(result, CompatibilityStatus::HostTooOld { .. }));
648    }
649
650    #[test]
651    fn compatibility_check_no_schema_constraint() {
652        let caps = sample_capabilities();
653        let result = check_compatibility(&caps, None, &[]);
654        assert!(result.is_compatible());
655    }
656
657    #[test]
658    fn plugin_meta_round_trip() {
659        let meta = PluginMeta {
660            sector: "textile".into(),
661            name: "Textile Compliance Plugin".into(),
662            version: "0.2.0".into(),
663            license: "Apache-2.0".into(),
664            description: Some("Validates textile DPP data".into()),
665            author: Some("Odal Node".into()),
666            homepage: Some("https://github.com/odal-node".into()),
667        };
668        let json = serde_json::to_value(&meta).unwrap();
669        assert_eq!(json["sector"], "textile");
670        assert_eq!(json["description"], "Validates textile DPP data");
671        let back: PluginMeta = serde_json::from_value(json).unwrap();
672        assert_eq!(meta.name, back.name);
673    }
674
675    #[test]
676    fn capabilities_round_trip() {
677        let caps = sample_capabilities();
678        let json = serde_json::to_value(&caps).unwrap();
679        assert!(json["supportedSchemas"].is_array());
680        assert_eq!(json["abiVersion"]["major"], ABI_VERSION_MAJOR);
681        let back: PluginCapabilities = serde_json::from_value(json).unwrap();
682        assert_eq!(caps.abi_version, back.abi_version);
683    }
684
685    #[test]
686    fn plugin_field_error_round_trip() {
687        let err = PluginFieldError {
688            field: "/fibreComposition/0/pct".into(),
689            code: "out_of_range".into(),
690            message: "pct must be 0-100".into(),
691        };
692        let json = serde_json::to_value(&err).unwrap();
693        assert_eq!(json["code"], "out_of_range");
694        let back: PluginFieldError = serde_json::from_value(json).unwrap();
695        assert_eq!(err.field, back.field);
696    }
697
698    #[test]
699    fn custom_capability_round_trip() {
700        let cap = PluginCapability::Custom("carbon_offset_calc".into());
701        let json = serde_json::to_value(&cap).unwrap();
702        let back: PluginCapability = serde_json::from_value(json).unwrap();
703        assert_eq!(cap, back);
704    }
705
706    #[test]
707    fn abi_result_ok_round_trip() {
708        let result = PluginResult::new(PluginComplianceStatus::NotAssessed)
709            .with_metric(METRIC_CO2E_SCORE, 85.4)
710            .with_metric(METRIC_RECYCLED_CONTENT_PCT, 12.5);
711        let envelope = AbiResult::ok(&result);
712        assert!(envelope.is_ok());
713        let json = serde_json::to_value(&envelope).unwrap();
714        assert!(json["ok"].is_object());
715        assert_eq!(json["ok"]["complianceStatus"], "NOT_ASSESSED");
716
717        let back: AbiResult = serde_json::from_value(json).unwrap();
718        match back {
719            AbiResult::Ok(v) => assert_eq!(v["metrics"]["co2e_score"], 85.4),
720            AbiResult::Error(_) => panic!("expected ok variant"),
721        }
722    }
723
724    #[test]
725    fn abi_result_error_round_trip() {
726        let envelope = AbiResult::Error(PluginError::ValidationErrors(vec![PluginFieldError {
727            field: "/gtin".into(),
728            code: "missing".into(),
729            message: "gtin is required".into(),
730        }]));
731        assert!(!envelope.is_ok());
732        let json = serde_json::to_value(&envelope).unwrap();
733        assert!(json.get("error").is_some());
734
735        let back: AbiResult = serde_json::from_value(json).unwrap();
736        assert!(!back.is_ok());
737    }
738}