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 EN 45554 repairability index (0.0–10.0).
264pub const METRIC_REPAIRABILITY_INDEX: &str = "repairability_index";
265/// Well-known metric key for recycled content percentage (0.0–100.0).
266pub const METRIC_RECYCLED_CONTENT_PCT: &str = "recycled_content_pct";
267
268/// A single determination finding emitted by a plugin's `calculate_metrics`.
269///
270/// Findings split into [`PluginResult::violations`] (binding — the host blocks
271/// publish for an in-force sector) and [`PluginResult::warnings`]
272/// (advisory/experimental — surfaced, never blocks). The vec encodes severity,
273/// so there is no separate severity field. Maps 1:1 onto the host's
274/// `ComplianceFinding`.
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct PluginFinding {
278    /// Stable machine-readable code, e.g. `"battery.recycled_content.cobalt_below_2031"`.
279    pub code: String,
280    /// JSON-pointer-style field locator (e.g. `"/recycledContentCobaltPct"`), or
281    /// empty when the finding is not tied to a single field.
282    #[serde(default, skip_serializing_if = "String::is_empty")]
283    pub field: String,
284    /// Human-readable explanation.
285    pub message: String,
286}
287
288impl PluginFinding {
289    /// Construct a finding from its code, field locator, and message.
290    pub fn new(
291        code: impl Into<String>,
292        field: impl Into<String>,
293        message: impl Into<String>,
294    ) -> Self {
295        Self {
296            code: code.into(),
297            field: field.into(),
298            message: message.into(),
299        }
300    }
301}
302
303/// Compliance result returned by the plugin.
304///
305/// `metrics` is a sector-extensible map of named numeric values. Use the
306/// `METRIC_*` constants for the three well-known fields; plugins may add
307/// sector-specific keys (`"water_use_litres"`, `"pef_score"`, …).
308///
309/// ## Aligning with `ComplianceResult` on the host
310///
311/// ```text
312/// co2e_score           ← metrics["co2e_score"]
313/// repairability_index  ← metrics["repairability_index"]
314/// recycled_content_pct ← metrics["recycled_content_pct"]
315/// compliance_status    ← PluginComplianceStatus → ComplianceStatus (typed map)
316/// violations/warnings  ← PluginFinding → ComplianceFinding (host blocks on violations)
317/// ```
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320pub struct PluginResult {
321    /// Typed compliance determination.
322    pub compliance_status: PluginComplianceStatus,
323    /// Sector-extensible keyed metric map (all values finite f64).
324    #[serde(default)]
325    pub metrics: std::collections::HashMap<String, f64>,
326    /// Non-numeric sector-specific data (free-form; stored verbatim in extra).
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub extra: Option<serde_json::Value>,
329    /// Binding findings — the host blocks publish for an in-force sector. Empty
330    /// for passthrough / not-assessed determinations. (ABI 1.1+)
331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
332    pub violations: Vec<PluginFinding>,
333    /// Advisory / experimental findings — surfaced but never block publish (e.g.
334    /// recycled-content thresholds that are not yet in force). (ABI 1.1+)
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub warnings: Vec<PluginFinding>,
337}
338
339impl PluginResult {
340    /// Construct a result carrying only a compliance status.
341    pub fn new(status: PluginComplianceStatus) -> Self {
342        Self {
343            compliance_status: status,
344            metrics: std::collections::HashMap::new(),
345            extra: None,
346            violations: Vec::new(),
347            warnings: Vec::new(),
348        }
349    }
350
351    /// Insert a metric unconditionally (non-finite values are silently dropped).
352    pub fn with_metric(mut self, key: &str, value: f64) -> Self {
353        if value.is_finite() {
354            self.metrics.insert(key.to_owned(), value);
355        }
356        self
357    }
358
359    /// Insert a metric only when `value` is `Some` (non-finite values dropped).
360    pub fn maybe_metric(mut self, key: &str, value: Option<f64>) -> Self {
361        if let Some(v) = value
362            && v.is_finite()
363        {
364            self.metrics.insert(key.to_owned(), v);
365        }
366        self
367    }
368
369    /// Attach free-form non-numeric extra data.
370    pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
371        self.extra = Some(extra);
372        self
373    }
374
375    /// Append a binding violation (host blocks publish for an in-force sector).
376    pub fn with_violation(mut self, finding: PluginFinding) -> Self {
377        self.violations.push(finding);
378        self
379    }
380
381    /// Append an advisory warning (surfaced, never blocks publish).
382    pub fn with_warning(mut self, finding: PluginFinding) -> Self {
383        self.warnings.push(finding);
384        self
385    }
386
387    // ── Convenience accessors for the three well-known metrics ──────────────
388
389    pub fn co2e_score(&self) -> Option<f64> {
390        self.metrics.get(METRIC_CO2E_SCORE).copied()
391    }
392
393    pub fn repairability_index(&self) -> Option<f64> {
394        self.metrics.get(METRIC_REPAIRABILITY_INDEX).copied()
395    }
396
397    pub fn recycled_content_pct(&self) -> Option<f64> {
398        self.metrics.get(METRIC_RECYCLED_CONTENT_PCT).copied()
399    }
400}
401
402// ─── ABI response envelope ────────────────────────────────────────────────────
403
404/// JSON envelope wrapping the outcome of a fallible plugin ABI call.
405///
406/// The low-level Wasm exports (`validate`, `calculate_metrics`,
407/// `generate_passport`) cannot return a Rust `Result` across the C ABI, so the
408/// outcome is serialised as this externally-tagged enum. On success, `ok`
409/// carries the method's return value (a [`PluginResult`] for
410/// `calculate_metrics`, the normalised payload for `generate_passport`, or
411/// `null` for `validate`). On failure, `error` carries a structured
412/// [`PluginError`]. The host deserialises this to recover the typed result.
413///
414/// ```json
415/// { "ok": { "co2eScore": 85.4, "complianceStatus": "NOT_ASSESSED", ... } }
416/// { "error": { "ValidationErrors": [ { "field": "/gtin", "code": "missing", ... } ] } }
417/// ```
418#[derive(Debug, Clone, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum AbiResult {
421    Ok(serde_json::Value),
422    Error(PluginError),
423}
424
425impl AbiResult {
426    /// Build a successful response by serialising `value`.
427    pub fn ok<T: Serialize>(value: &T) -> Self {
428        Self::Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null))
429    }
430
431    /// Returns `true` if this is the success variant.
432    pub fn is_ok(&self) -> bool {
433        matches!(self, Self::Ok(_))
434    }
435}
436
437// ─── Plugin errors ──────────────────────────────────────────────────────────
438
439/// Structured error with field-level detail.
440#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub struct PluginFieldError {
443    /// JSON pointer to the failing field, e.g. `"/fibreComposition/0/pct"`.
444    pub field: String,
445    /// Error code for programmatic handling (e.g. `"out_of_range"`, `"missing"`).
446    pub code: String,
447    /// Human-readable error message.
448    pub message: String,
449}
450
451#[derive(Debug, Clone, Error, Serialize, Deserialize)]
452pub enum PluginError {
453    #[error("invalid input: {0}")]
454    InvalidInput(String),
455    #[error("validation errors: {0:?}")]
456    ValidationErrors(Vec<PluginFieldError>),
457    #[error("calculation failed: {0}")]
458    Calculation(String),
459    #[error("sector not supported by this plugin: {0}")]
460    UnsupportedSector(String),
461    #[error("schema version not supported: {0}")]
462    UnsupportedSchemaVersion(String),
463    #[error("capability not available: {0}")]
464    CapabilityNotAvailable(String),
465    #[error("internal plugin error: {0}")]
466    Internal(String),
467}
468
469// ─── Host-side trait ────────────────────────────────────────────────────────
470
471/// The entry points every sector plugin must export.
472///
473/// The Wasm host calls these after deserialising JSON sector data from the
474/// passport payload. Implementations must be deterministic and free of I/O.
475pub trait DppSectorPlugin: Send + Sync {
476    /// Returns static metadata about this plugin.
477    fn meta(&self) -> PluginMeta;
478
479    /// Returns the plugin's capability declaration for version negotiation.
480    fn capabilities(&self) -> PluginCapabilities;
481
482    /// Validate the structure and field constraints of the sector input.
483    ///
484    /// Returns `Ok(())` if the input is structurally valid, or a descriptive
485    /// error if a required field is missing or out of range. Prefer
486    /// `PluginError::ValidationErrors` with per-field detail over
487    /// `PluginError::InvalidInput` for better error reporting.
488    fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError>;
489
490    /// Compute compliance metrics from the sector input.
491    ///
492    /// May return `None` for fields that do not apply to this sector.
493    fn calculate_metrics(&self, input: &PluginInput) -> Result<PluginResult, PluginError>;
494
495    /// Generate a passport-ready sector data JSON payload.
496    ///
497    /// Applies any normalisation or enrichment required by the sector schema
498    /// (e.g. rounding, unit conversion). The output is stored verbatim in the DPP.
499    fn generate_passport(&self, input: &PluginInput) -> Result<serde_json::Value, PluginError>;
500}
501
502// ─── Tests ──────────────────────────────────────────────────────────────────
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    fn sample_capabilities() -> PluginCapabilities {
509        PluginCapabilities {
510            abi_version: AbiVersion::current(),
511            supported_schemas: vec![SchemaVersionRange {
512                min_version: "1.0.0".into(),
513                max_version: "1.1.0".into(),
514            }],
515            capabilities: vec![
516                PluginCapability::Validate,
517                PluginCapability::ComputeMetrics,
518                PluginCapability::GeneratePassport,
519            ],
520            min_host_version: None,
521            max_fuel: None,
522            max_memory_bytes: None,
523        }
524    }
525
526    #[test]
527    fn abi_version_current_is_compatible() {
528        let current = AbiVersion::current();
529        assert!(current.is_compatible_with_host());
530    }
531
532    #[test]
533    fn abi_version_major_mismatch_incompatible() {
534        let future = AbiVersion { major: 2, minor: 0 };
535        assert!(!future.is_compatible_with_host());
536    }
537
538    #[test]
539    fn abi_version_minor_ahead_incompatible() {
540        let ahead = AbiVersion {
541            major: ABI_VERSION_MAJOR,
542            minor: ABI_VERSION_MINOR + 1,
543        };
544        assert!(!ahead.is_compatible_with_host());
545    }
546
547    #[test]
548    fn abi_version_display() {
549        let v = AbiVersion { major: 1, minor: 0 };
550        assert_eq!(format!("{v}"), "1.0");
551    }
552
553    #[test]
554    fn compatibility_check_passes() {
555        let caps = sample_capabilities();
556        let result = check_compatibility(&caps, Some("1.0.0"), &[PluginCapability::Validate]);
557        assert!(result.is_compatible());
558    }
559
560    #[test]
561    fn compatibility_check_schema_in_range() {
562        let caps = sample_capabilities();
563        let result = check_compatibility(&caps, Some("1.1.0"), &[]);
564        assert!(result.is_compatible());
565    }
566
567    #[test]
568    fn compatibility_check_schema_out_of_range() {
569        let caps = sample_capabilities();
570        let result = check_compatibility(&caps, Some("2.0.0"), &[]);
571        assert!(matches!(
572            result,
573            CompatibilityStatus::SchemaUnsupported { .. }
574        ));
575    }
576
577    #[test]
578    fn semver_multi_digit_minor_accepted() {
579        // Lexicographic comparison would reject "1.10.0" within ["1.0.0", "1.10.0"]
580        // because "1.10.0" < "1.2.0" as strings. Semantic comparison must handle this.
581        let caps = PluginCapabilities {
582            abi_version: AbiVersion::current(),
583            supported_schemas: vec![SchemaVersionRange {
584                min_version: "1.0.0".into(),
585                max_version: "1.10.0".into(),
586            }],
587            capabilities: vec![],
588            min_host_version: None,
589            max_fuel: None,
590            max_memory_bytes: None,
591        };
592        let result = check_compatibility(&caps, Some("1.10.0"), &[]);
593        assert!(
594            result.is_compatible(),
595            "1.10.0 must be accepted within [1.0.0, 1.10.0]"
596        );
597    }
598
599    #[test]
600    fn semver_multi_digit_minor_rejected_correctly() {
601        // "1.10.0" must be rejected when max is "1.2.0"
602        let caps = PluginCapabilities {
603            abi_version: AbiVersion::current(),
604            supported_schemas: vec![SchemaVersionRange {
605                min_version: "1.0.0".into(),
606                max_version: "1.2.0".into(),
607            }],
608            capabilities: vec![],
609            min_host_version: None,
610            max_fuel: None,
611            max_memory_bytes: None,
612        };
613        let result = check_compatibility(&caps, Some("1.10.0"), &[]);
614        assert!(
615            matches!(result, CompatibilityStatus::SchemaUnsupported { .. }),
616            "1.10.0 must be rejected when max is 1.2.0"
617        );
618    }
619
620    #[test]
621    fn compatibility_check_missing_capability() {
622        let caps = sample_capabilities();
623        let result = check_compatibility(&caps, None, &[PluginCapability::SubstanceScreening]);
624        assert!(matches!(result, CompatibilityStatus::MissingCapability(_)));
625    }
626
627    #[test]
628    fn compatibility_check_abi_mismatch() {
629        let mut caps = sample_capabilities();
630        caps.abi_version = AbiVersion { major: 2, minor: 0 };
631        let result = check_compatibility(&caps, None, &[]);
632        assert!(matches!(
633            result,
634            CompatibilityStatus::AbiIncompatible { .. }
635        ));
636    }
637
638    #[test]
639    fn compatibility_check_host_too_old() {
640        let mut caps = sample_capabilities();
641        caps.min_host_version = Some(AbiVersion {
642            major: ABI_VERSION_MAJOR,
643            minor: ABI_VERSION_MINOR + 5,
644        });
645        let result = check_compatibility(&caps, None, &[]);
646        assert!(matches!(result, CompatibilityStatus::HostTooOld { .. }));
647    }
648
649    #[test]
650    fn compatibility_check_no_schema_constraint() {
651        let caps = sample_capabilities();
652        let result = check_compatibility(&caps, None, &[]);
653        assert!(result.is_compatible());
654    }
655
656    #[test]
657    fn plugin_meta_round_trip() {
658        let meta = PluginMeta {
659            sector: "textile".into(),
660            name: "Textile Compliance Plugin".into(),
661            version: "0.2.0".into(),
662            license: "Apache-2.0".into(),
663            description: Some("Validates textile DPP data".into()),
664            author: Some("Odal Node".into()),
665            homepage: Some("https://github.com/odal-node".into()),
666        };
667        let json = serde_json::to_value(&meta).unwrap();
668        assert_eq!(json["sector"], "textile");
669        assert_eq!(json["description"], "Validates textile DPP data");
670        let back: PluginMeta = serde_json::from_value(json).unwrap();
671        assert_eq!(meta.name, back.name);
672    }
673
674    #[test]
675    fn capabilities_round_trip() {
676        let caps = sample_capabilities();
677        let json = serde_json::to_value(&caps).unwrap();
678        assert!(json["supportedSchemas"].is_array());
679        assert_eq!(json["abiVersion"]["major"], ABI_VERSION_MAJOR);
680        let back: PluginCapabilities = serde_json::from_value(json).unwrap();
681        assert_eq!(caps.abi_version, back.abi_version);
682    }
683
684    #[test]
685    fn plugin_field_error_round_trip() {
686        let err = PluginFieldError {
687            field: "/fibreComposition/0/pct".into(),
688            code: "out_of_range".into(),
689            message: "pct must be 0-100".into(),
690        };
691        let json = serde_json::to_value(&err).unwrap();
692        assert_eq!(json["code"], "out_of_range");
693        let back: PluginFieldError = serde_json::from_value(json).unwrap();
694        assert_eq!(err.field, back.field);
695    }
696
697    #[test]
698    fn custom_capability_round_trip() {
699        let cap = PluginCapability::Custom("carbon_offset_calc".into());
700        let json = serde_json::to_value(&cap).unwrap();
701        let back: PluginCapability = serde_json::from_value(json).unwrap();
702        assert_eq!(cap, back);
703    }
704
705    #[test]
706    fn abi_result_ok_round_trip() {
707        let result = PluginResult::new(PluginComplianceStatus::NotAssessed)
708            .with_metric(METRIC_CO2E_SCORE, 85.4)
709            .with_metric(METRIC_RECYCLED_CONTENT_PCT, 12.5);
710        let envelope = AbiResult::ok(&result);
711        assert!(envelope.is_ok());
712        let json = serde_json::to_value(&envelope).unwrap();
713        assert!(json["ok"].is_object());
714        assert_eq!(json["ok"]["complianceStatus"], "NOT_ASSESSED");
715
716        let back: AbiResult = serde_json::from_value(json).unwrap();
717        match back {
718            AbiResult::Ok(v) => assert_eq!(v["metrics"]["co2e_score"], 85.4),
719            AbiResult::Error(_) => panic!("expected ok variant"),
720        }
721    }
722
723    #[test]
724    fn abi_result_error_round_trip() {
725        let envelope = AbiResult::Error(PluginError::ValidationErrors(vec![PluginFieldError {
726            field: "/gtin".into(),
727            code: "missing".into(),
728            message: "gtin is required".into(),
729        }]));
730        assert!(!envelope.is_ok());
731        let json = serde_json::to_value(&envelope).unwrap();
732        assert!(json.get("error").is_some());
733
734        let back: AbiResult = serde_json::from_value(json).unwrap();
735        assert!(!back.is_ok());
736    }
737}