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