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/// Compliance result returned by the plugin.
67///
68/// `metrics` is a sector-extensible map of named numeric values. Use the
69/// `METRIC_*` constants for the three well-known fields; plugins may add
70/// sector-specific keys (`"water_use_litres"`, `"pef_score"`, …).
71///
72/// ## Aligning with `ComplianceResult` on the host
73///
74/// ```text
75/// co2e_score           ← metrics["co2e_score"]
76/// repairability_index  ← metrics["repairability_index"]
77/// recycled_content_pct ← metrics["recycled_content_pct"]
78/// compliance_status    ← PluginComplianceStatus → ComplianceStatus (typed map)
79/// violations/warnings  ← PluginFinding → ComplianceFinding (host blocks on violations)
80/// ```
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct PluginResult {
84    /// Typed compliance determination.
85    pub compliance_status: PluginComplianceStatus,
86    /// Sector-extensible keyed metric map (all values finite f64).
87    #[serde(default)]
88    pub metrics: std::collections::HashMap<String, f64>,
89    /// Non-numeric sector-specific data (free-form; stored verbatim in extra).
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub extra: Option<serde_json::Value>,
92    /// Binding findings — the host blocks publish for an in-force sector. Empty
93    /// for passthrough / not-assessed determinations. (ABI 1.1+)
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub violations: Vec<PluginFinding>,
96    /// Advisory / experimental findings — surfaced but never block publish (e.g.
97    /// recycled-content thresholds that are not yet in force). (ABI 1.1+)
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub warnings: Vec<PluginFinding>,
100}
101
102impl PluginResult {
103    /// Construct a result carrying only a compliance status.
104    pub fn new(status: PluginComplianceStatus) -> Self {
105        Self {
106            compliance_status: status,
107            metrics: std::collections::HashMap::new(),
108            extra: None,
109            violations: Vec::new(),
110            warnings: Vec::new(),
111        }
112    }
113
114    /// Insert a metric unconditionally (non-finite values are silently dropped).
115    pub fn with_metric(mut self, key: &str, value: f64) -> Self {
116        if value.is_finite() {
117            self.metrics.insert(key.to_owned(), value);
118        }
119        self
120    }
121
122    /// Insert a metric only when `value` is `Some` (non-finite values dropped).
123    pub fn maybe_metric(mut self, key: &str, value: Option<f64>) -> Self {
124        if let Some(v) = value
125            && v.is_finite()
126        {
127            self.metrics.insert(key.to_owned(), v);
128        }
129        self
130    }
131
132    /// Attach free-form non-numeric extra data.
133    pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
134        self.extra = Some(extra);
135        self
136    }
137
138    /// Append a binding violation (host blocks publish for an in-force sector).
139    pub fn with_violation(mut self, finding: PluginFinding) -> Self {
140        self.violations.push(finding);
141        self
142    }
143
144    /// Append an advisory warning (surfaced, never blocks publish).
145    pub fn with_warning(mut self, finding: PluginFinding) -> Self {
146        self.warnings.push(finding);
147        self
148    }
149
150    // ── Convenience accessors for the three well-known metrics ──────────────
151
152    pub fn co2e_score(&self) -> Option<f64> {
153        self.metrics.get(METRIC_CO2E_SCORE).copied()
154    }
155
156    pub fn repairability_index(&self) -> Option<f64> {
157        self.metrics.get(METRIC_REPAIRABILITY_INDEX).copied()
158    }
159
160    pub fn recycled_content_pct(&self) -> Option<f64> {
161        self.metrics.get(METRIC_RECYCLED_CONTENT_PCT).copied()
162    }
163}
164
165/// JSON envelope wrapping the outcome of a fallible plugin ABI call.
166///
167/// The low-level Wasm exports (`validate`, `calculate_metrics`,
168/// `generate_passport`) cannot return a Rust `Result` across the C ABI, so the
169/// outcome is serialised as this externally-tagged enum. On success, `ok`
170/// carries the method's return value (a [`PluginResult`] for
171/// `calculate_metrics`, the normalised payload for `generate_passport`, or
172/// `null` for `validate`). On failure, `error` carries a structured
173/// [`PluginError`]. The host deserialises this to recover the typed result.
174///
175/// ```json
176/// { "ok": { "co2eScore": 85.4, "complianceStatus": "NOT_ASSESSED", ... } }
177/// { "error": { "ValidationErrors": [ { "field": "/gtin", "code": "missing", ... } ] } }
178/// ```
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum AbiResult {
182    Ok(serde_json::Value),
183    Error(PluginError),
184}
185
186impl AbiResult {
187    /// Build a successful response by serialising `value`.
188    pub fn ok<T: Serialize>(value: &T) -> Self {
189        Self::Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null))
190    }
191
192    /// Returns `true` if this is the success variant.
193    pub fn is_ok(&self) -> bool {
194        matches!(self, Self::Ok(_))
195    }
196}