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}