Skip to main content

dpp_plugin_traits/
result.rs

1//! Plugin call outcomes: [`PluginResult`] (typed compliance determination)
2//! and [`AbiResult`] (the wire envelope carrying it or a [`PluginError`]).
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::PluginError;
7
8/// Typed compliance determination returned by a plugin.
9///
10/// Mirrors `dpp_domain::ports::compliance::ComplianceStatus` so the host maps
11/// directly from one typed enum to the other rather than parsing a string.
12/// Serialised as SCREAMING_SNAKE_CASE JSON strings for ABI stability.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
15pub enum PluginComplianceStatus {
16    Compliant,
17    NonCompliant,
18    NotAssessed,
19    PassthroughNoValidation,
20    NotImplemented,
21}
22
23/// Well-known metric key for CO₂e score (kg CO₂e per functional unit).
24pub const METRIC_CO2E_SCORE: &str = "co2e_score";
25/// Well-known metric key for the repairability index (0.0–10.0; non-regulatory
26/// heuristic, not EN 45554 / EU 2023/1669).
27pub const METRIC_REPAIRABILITY_INDEX: &str = "repairability_index";
28/// Well-known metric key for recycled content percentage (0.0–100.0).
29pub const METRIC_RECYCLED_CONTENT_PCT: &str = "recycled_content_pct";
30
31/// A single determination finding emitted by a plugin's `calculate_metrics`.
32///
33/// Findings split into [`PluginResult::violations`] (binding — the host blocks
34/// publish for an in-force sector) and [`PluginResult::warnings`]
35/// (advisory/experimental — surfaced, never blocks). The vec encodes severity,
36/// so there is no separate severity field. Maps 1:1 onto the host's
37/// `ComplianceFinding`.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct PluginFinding {
41    /// Stable machine-readable code, e.g. `"battery.recycled_content.cobalt_below_2031"`.
42    pub code: String,
43    /// JSON-pointer-style field locator (e.g. `"/recycledContentCobaltPct"`), or
44    /// empty when the finding is not tied to a single field.
45    #[serde(default, skip_serializing_if = "String::is_empty")]
46    pub field: String,
47    /// Human-readable explanation.
48    pub message: String,
49}
50
51impl PluginFinding {
52    /// Construct a finding from its code, field locator, and message.
53    pub fn new(
54        code: impl Into<String>,
55        field: impl Into<String>,
56        message: impl Into<String>,
57    ) -> Self {
58        Self {
59            code: code.into(),
60            field: field.into(),
61            message: message.into(),
62        }
63    }
64}
65
66/// Serialise a metric map, failing on any non-finite value instead of letting
67/// `serde_json` silently emit `null` for it.
68fn serialize_finite_metrics<S>(
69    metrics: &std::collections::HashMap<String, f64>,
70    serializer: S,
71) -> Result<S::Ok, S::Error>
72where
73    S: serde::Serializer,
74{
75    use serde::ser::{Error, SerializeMap};
76    let mut map = serializer.serialize_map(Some(metrics.len()))?;
77    for (key, value) in metrics {
78        if !value.is_finite() {
79            return Err(S::Error::custom(format!(
80                "metric '{key}' is not finite ({value})"
81            )));
82        }
83        map.serialize_entry(key, value)?;
84    }
85    map.end()
86}
87
88/// Compliance result returned by the plugin.
89///
90/// `metrics` is a sector-extensible map of named numeric values. Use the
91/// `METRIC_*` constants for the three well-known fields; plugins may add
92/// sector-specific keys (`"water_use_litres"`, `"pef_score"`, …).
93///
94/// ## Aligning with `ComplianceResult` on the host
95///
96/// ```text
97/// co2e_score           ← metrics["co2e_score"]
98/// repairability_index  ← metrics["repairability_index"]
99/// recycled_content_pct ← metrics["recycled_content_pct"]
100/// compliance_status    ← PluginComplianceStatus → ComplianceStatus (typed map)
101/// violations/warnings  ← PluginFinding → ComplianceFinding (host blocks on violations)
102/// ```
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct PluginResult {
106    /// Typed compliance determination.
107    pub compliance_status: PluginComplianceStatus,
108    /// Sector-extensible keyed metric map (all values finite f64).
109    ///
110    /// Serialisation **fails** on a non-finite value (`NaN`/`Infinity`) rather
111    /// than silently coercing it to JSON `null`, so a metric inserted directly
112    /// into this `pub` field (bypassing the `with_metric` guard) cannot slip
113    /// through the ABI envelope as a spurious success.
114    #[serde(default, serialize_with = "serialize_finite_metrics")]
115    pub metrics: std::collections::HashMap<String, f64>,
116    /// Non-numeric sector-specific data (free-form; stored verbatim in extra).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub extra: Option<serde_json::Value>,
119    /// Binding findings — the host blocks publish for an in-force sector. Empty
120    /// for passthrough / not-assessed determinations. (ABI 1.1+)
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub violations: Vec<PluginFinding>,
123    /// Advisory / experimental findings — surfaced but never block publish (e.g.
124    /// recycled-content thresholds that are not yet in force). (ABI 1.1+)
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub warnings: Vec<PluginFinding>,
127}
128
129impl PluginResult {
130    /// Construct a result carrying only a compliance status.
131    pub fn new(status: PluginComplianceStatus) -> Self {
132        Self {
133            compliance_status: status,
134            metrics: std::collections::HashMap::new(),
135            extra: None,
136            violations: Vec::new(),
137            warnings: Vec::new(),
138        }
139    }
140
141    /// Insert a metric unconditionally (non-finite values are silently dropped).
142    pub fn with_metric(mut self, key: &str, value: f64) -> Self {
143        if value.is_finite() {
144            self.metrics.insert(key.to_owned(), value);
145        }
146        self
147    }
148
149    /// Insert a metric only when `value` is `Some` (non-finite values dropped).
150    pub fn maybe_metric(mut self, key: &str, value: Option<f64>) -> Self {
151        if let Some(v) = value
152            && v.is_finite()
153        {
154            self.metrics.insert(key.to_owned(), v);
155        }
156        self
157    }
158
159    /// Attach free-form non-numeric extra data.
160    pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
161        self.extra = Some(extra);
162        self
163    }
164
165    /// Append a binding violation (host blocks publish for an in-force sector).
166    pub fn with_violation(mut self, finding: PluginFinding) -> Self {
167        self.violations.push(finding);
168        self
169    }
170
171    /// Append an advisory warning (surfaced, never blocks publish).
172    pub fn with_warning(mut self, finding: PluginFinding) -> Self {
173        self.warnings.push(finding);
174        self
175    }
176
177    // ── Convenience accessors for the three well-known metrics ──────────────
178
179    pub fn co2e_score(&self) -> Option<f64> {
180        self.metrics.get(METRIC_CO2E_SCORE).copied()
181    }
182
183    pub fn repairability_index(&self) -> Option<f64> {
184        self.metrics.get(METRIC_REPAIRABILITY_INDEX).copied()
185    }
186
187    pub fn recycled_content_pct(&self) -> Option<f64> {
188        self.metrics.get(METRIC_RECYCLED_CONTENT_PCT).copied()
189    }
190}
191
192/// JSON envelope wrapping the outcome of a fallible plugin ABI call.
193///
194/// The low-level Wasm exports (`validate`, `calculate_metrics`,
195/// `generate_passport`) cannot return a Rust `Result` across the C ABI, so the
196/// outcome is serialised as this externally-tagged enum. On success, `ok`
197/// carries the method's return value (a [`PluginResult`] for
198/// `calculate_metrics`, the normalised payload for `generate_passport`, or
199/// `null` for `validate`). On failure, `error` carries a structured
200/// [`PluginError`]. The host deserialises this to recover the typed result.
201///
202/// ```json
203/// { "ok": { "co2eScore": 85.4, "complianceStatus": "NOT_ASSESSED", ... } }
204/// { "error": { "ValidationErrors": [ { "field": "/gtin", "code": "missing", ... } ] } }
205/// ```
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum AbiResult {
209    Ok(serde_json::Value),
210    Error(PluginError),
211}
212
213impl AbiResult {
214    /// Build a successful response by serialising `value`.
215    ///
216    /// If serialisation fails — e.g. a plugin inserted a non-finite `f64` into
217    /// `PluginResult.metrics`, bypassing the `with_metric` finite-value guard —
218    /// this returns the [`AbiResult::Error`] variant rather than a spurious
219    /// `Ok(null)`, so the failure is not silently swallowed.
220    pub fn ok<T: Serialize>(value: &T) -> Self {
221        match serde_json::to_value(value) {
222            Ok(v) => Self::Ok(v),
223            Err(e) => Self::Error(PluginError::Internal(format!(
224                "failed to serialise plugin result: {e}"
225            ))),
226        }
227    }
228
229    /// Returns `true` if this is the success variant.
230    pub fn is_ok(&self) -> bool {
231        matches!(self, Self::Ok(_))
232    }
233}