use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const ABI_VERSION_MAJOR: u32 = 1;
pub const ABI_VERSION_MINOR: u32 = 1;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AbiVersion {
pub major: u32,
pub minor: u32,
}
impl AbiVersion {
pub const fn current() -> Self {
Self {
major: ABI_VERSION_MAJOR,
minor: ABI_VERSION_MINOR,
}
}
#[allow(clippy::absurd_extreme_comparisons)] pub fn is_compatible_with_host(&self) -> bool {
self.major == ABI_VERSION_MAJOR && self.minor <= ABI_VERSION_MINOR
}
}
impl std::fmt::Display for AbiVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMeta {
pub sector: String,
pub name: String,
pub version: String,
pub license: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SchemaVersionRange {
pub min_version: String,
pub max_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum PluginCapability {
Validate,
ComputeMetrics,
GeneratePassport,
SubstanceScreening,
LifecycleAssessment,
AasMapping,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginCapabilities {
pub abi_version: AbiVersion,
pub supported_schemas: Vec<SchemaVersionRange>,
pub capabilities: Vec<PluginCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_host_version: Option<AbiVersion>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_fuel: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_memory_bytes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompatibilityStatus {
Compatible,
AbiIncompatible {
host: AbiVersion,
plugin: AbiVersion,
},
HostTooOld {
required: AbiVersion,
actual: AbiVersion,
},
SchemaUnsupported {
requested: String,
supported: Vec<SchemaVersionRange>,
},
MissingCapability(PluginCapability),
}
impl CompatibilityStatus {
pub fn is_compatible(&self) -> bool {
matches!(self, Self::Compatible)
}
}
pub fn check_compatibility(
capabilities: &PluginCapabilities,
requested_schema_version: Option<&str>,
required_capabilities: &[PluginCapability],
) -> CompatibilityStatus {
if !capabilities.abi_version.is_compatible_with_host() {
return CompatibilityStatus::AbiIncompatible {
host: AbiVersion::current(),
plugin: capabilities.abi_version,
};
}
if let Some(ref min_host) = capabilities.min_host_version {
let current = AbiVersion::current();
if current.major < min_host.major
|| (current.major == min_host.major && current.minor < min_host.minor)
{
return CompatibilityStatus::HostTooOld {
required: *min_host,
actual: current,
};
}
}
if let Some(requested) = requested_schema_version {
let req = semver::Version::parse(requested).ok();
let supported = capabilities.supported_schemas.iter().any(|range| {
let lo = semver::Version::parse(&range.min_version).ok();
let hi = semver::Version::parse(&range.max_version).ok();
match (req.as_ref(), lo, hi) {
(Some(r), Some(l), Some(h)) => r >= &l && r <= &h,
_ => {
requested >= range.min_version.as_str()
&& requested <= range.max_version.as_str()
}
}
});
if !supported {
return CompatibilityStatus::SchemaUnsupported {
requested: requested.to_owned(),
supported: capabilities.supported_schemas.clone(),
};
}
}
for required in required_capabilities {
if !capabilities.capabilities.contains(required) {
return CompatibilityStatus::MissingCapability(required.clone());
}
}
CompatibilityStatus::Compatible
}
pub type PluginInput = serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PluginComplianceStatus {
Compliant,
NonCompliant,
NotAssessed,
PassthroughNoValidation,
NotImplemented,
}
pub const METRIC_CO2E_SCORE: &str = "co2e_score";
pub const METRIC_REPAIRABILITY_INDEX: &str = "repairability_index";
pub const METRIC_RECYCLED_CONTENT_PCT: &str = "recycled_content_pct";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginFinding {
pub code: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub field: String,
pub message: String,
}
impl PluginFinding {
pub fn new(
code: impl Into<String>,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
code: code.into(),
field: field.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginResult {
pub compliance_status: PluginComplianceStatus,
#[serde(default)]
pub metrics: std::collections::HashMap<String, f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub violations: Vec<PluginFinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<PluginFinding>,
}
impl PluginResult {
pub fn new(status: PluginComplianceStatus) -> Self {
Self {
compliance_status: status,
metrics: std::collections::HashMap::new(),
extra: None,
violations: Vec::new(),
warnings: Vec::new(),
}
}
pub fn with_metric(mut self, key: &str, value: f64) -> Self {
if value.is_finite() {
self.metrics.insert(key.to_owned(), value);
}
self
}
pub fn maybe_metric(mut self, key: &str, value: Option<f64>) -> Self {
if let Some(v) = value
&& v.is_finite()
{
self.metrics.insert(key.to_owned(), v);
}
self
}
pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
self.extra = Some(extra);
self
}
pub fn with_violation(mut self, finding: PluginFinding) -> Self {
self.violations.push(finding);
self
}
pub fn with_warning(mut self, finding: PluginFinding) -> Self {
self.warnings.push(finding);
self
}
pub fn co2e_score(&self) -> Option<f64> {
self.metrics.get(METRIC_CO2E_SCORE).copied()
}
pub fn repairability_index(&self) -> Option<f64> {
self.metrics.get(METRIC_REPAIRABILITY_INDEX).copied()
}
pub fn recycled_content_pct(&self) -> Option<f64> {
self.metrics.get(METRIC_RECYCLED_CONTENT_PCT).copied()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AbiResult {
Ok(serde_json::Value),
Error(PluginError),
}
impl AbiResult {
pub fn ok<T: Serialize>(value: &T) -> Self {
Self::Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null))
}
pub fn is_ok(&self) -> bool {
matches!(self, Self::Ok(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginFieldError {
pub field: String,
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
pub enum PluginError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("validation errors: {0:?}")]
ValidationErrors(Vec<PluginFieldError>),
#[error("calculation failed: {0}")]
Calculation(String),
#[error("sector not supported by this plugin: {0}")]
UnsupportedSector(String),
#[error("schema version not supported: {0}")]
UnsupportedSchemaVersion(String),
#[error("capability not available: {0}")]
CapabilityNotAvailable(String),
#[error("internal plugin error: {0}")]
Internal(String),
}
pub trait DppSectorPlugin: Send + Sync {
fn meta(&self) -> PluginMeta;
fn capabilities(&self) -> PluginCapabilities;
fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError>;
fn calculate_metrics(&self, input: &PluginInput) -> Result<PluginResult, PluginError>;
fn generate_passport(&self, input: &PluginInput) -> Result<serde_json::Value, PluginError>;
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_capabilities() -> PluginCapabilities {
PluginCapabilities {
abi_version: AbiVersion::current(),
supported_schemas: vec![SchemaVersionRange {
min_version: "1.0.0".into(),
max_version: "1.1.0".into(),
}],
capabilities: vec![
PluginCapability::Validate,
PluginCapability::ComputeMetrics,
PluginCapability::GeneratePassport,
],
min_host_version: None,
max_fuel: None,
max_memory_bytes: None,
}
}
#[test]
fn abi_version_current_is_compatible() {
let current = AbiVersion::current();
assert!(current.is_compatible_with_host());
}
#[test]
fn abi_version_major_mismatch_incompatible() {
let future = AbiVersion { major: 2, minor: 0 };
assert!(!future.is_compatible_with_host());
}
#[test]
fn abi_version_minor_ahead_incompatible() {
let ahead = AbiVersion {
major: ABI_VERSION_MAJOR,
minor: ABI_VERSION_MINOR + 1,
};
assert!(!ahead.is_compatible_with_host());
}
#[test]
fn abi_version_display() {
let v = AbiVersion { major: 1, minor: 0 };
assert_eq!(format!("{v}"), "1.0");
}
#[test]
fn compatibility_check_passes() {
let caps = sample_capabilities();
let result = check_compatibility(&caps, Some("1.0.0"), &[PluginCapability::Validate]);
assert!(result.is_compatible());
}
#[test]
fn compatibility_check_schema_in_range() {
let caps = sample_capabilities();
let result = check_compatibility(&caps, Some("1.1.0"), &[]);
assert!(result.is_compatible());
}
#[test]
fn compatibility_check_schema_out_of_range() {
let caps = sample_capabilities();
let result = check_compatibility(&caps, Some("2.0.0"), &[]);
assert!(matches!(
result,
CompatibilityStatus::SchemaUnsupported { .. }
));
}
#[test]
fn semver_multi_digit_minor_accepted() {
let caps = PluginCapabilities {
abi_version: AbiVersion::current(),
supported_schemas: vec![SchemaVersionRange {
min_version: "1.0.0".into(),
max_version: "1.10.0".into(),
}],
capabilities: vec![],
min_host_version: None,
max_fuel: None,
max_memory_bytes: None,
};
let result = check_compatibility(&caps, Some("1.10.0"), &[]);
assert!(
result.is_compatible(),
"1.10.0 must be accepted within [1.0.0, 1.10.0]"
);
}
#[test]
fn semver_multi_digit_minor_rejected_correctly() {
let caps = PluginCapabilities {
abi_version: AbiVersion::current(),
supported_schemas: vec![SchemaVersionRange {
min_version: "1.0.0".into(),
max_version: "1.2.0".into(),
}],
capabilities: vec![],
min_host_version: None,
max_fuel: None,
max_memory_bytes: None,
};
let result = check_compatibility(&caps, Some("1.10.0"), &[]);
assert!(
matches!(result, CompatibilityStatus::SchemaUnsupported { .. }),
"1.10.0 must be rejected when max is 1.2.0"
);
}
#[test]
fn compatibility_check_missing_capability() {
let caps = sample_capabilities();
let result = check_compatibility(&caps, None, &[PluginCapability::SubstanceScreening]);
assert!(matches!(result, CompatibilityStatus::MissingCapability(_)));
}
#[test]
fn compatibility_check_abi_mismatch() {
let mut caps = sample_capabilities();
caps.abi_version = AbiVersion { major: 2, minor: 0 };
let result = check_compatibility(&caps, None, &[]);
assert!(matches!(
result,
CompatibilityStatus::AbiIncompatible { .. }
));
}
#[test]
fn compatibility_check_host_too_old() {
let mut caps = sample_capabilities();
caps.min_host_version = Some(AbiVersion {
major: ABI_VERSION_MAJOR,
minor: ABI_VERSION_MINOR + 5,
});
let result = check_compatibility(&caps, None, &[]);
assert!(matches!(result, CompatibilityStatus::HostTooOld { .. }));
}
#[test]
fn compatibility_check_no_schema_constraint() {
let caps = sample_capabilities();
let result = check_compatibility(&caps, None, &[]);
assert!(result.is_compatible());
}
#[test]
fn plugin_meta_round_trip() {
let meta = PluginMeta {
sector: "textile".into(),
name: "Textile Compliance Plugin".into(),
version: "0.2.0".into(),
license: "Apache-2.0".into(),
description: Some("Validates textile DPP data".into()),
author: Some("Odal Node".into()),
homepage: Some("https://github.com/odal-node".into()),
};
let json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["sector"], "textile");
assert_eq!(json["description"], "Validates textile DPP data");
let back: PluginMeta = serde_json::from_value(json).unwrap();
assert_eq!(meta.name, back.name);
}
#[test]
fn capabilities_round_trip() {
let caps = sample_capabilities();
let json = serde_json::to_value(&caps).unwrap();
assert!(json["supportedSchemas"].is_array());
assert_eq!(json["abiVersion"]["major"], ABI_VERSION_MAJOR);
let back: PluginCapabilities = serde_json::from_value(json).unwrap();
assert_eq!(caps.abi_version, back.abi_version);
}
#[test]
fn plugin_field_error_round_trip() {
let err = PluginFieldError {
field: "/fibreComposition/0/pct".into(),
code: "out_of_range".into(),
message: "pct must be 0-100".into(),
};
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["code"], "out_of_range");
let back: PluginFieldError = serde_json::from_value(json).unwrap();
assert_eq!(err.field, back.field);
}
#[test]
fn custom_capability_round_trip() {
let cap = PluginCapability::Custom("carbon_offset_calc".into());
let json = serde_json::to_value(&cap).unwrap();
let back: PluginCapability = serde_json::from_value(json).unwrap();
assert_eq!(cap, back);
}
#[test]
fn abi_result_ok_round_trip() {
let result = PluginResult::new(PluginComplianceStatus::NotAssessed)
.with_metric(METRIC_CO2E_SCORE, 85.4)
.with_metric(METRIC_RECYCLED_CONTENT_PCT, 12.5);
let envelope = AbiResult::ok(&result);
assert!(envelope.is_ok());
let json = serde_json::to_value(&envelope).unwrap();
assert!(json["ok"].is_object());
assert_eq!(json["ok"]["complianceStatus"], "NOT_ASSESSED");
let back: AbiResult = serde_json::from_value(json).unwrap();
match back {
AbiResult::Ok(v) => assert_eq!(v["metrics"]["co2e_score"], 85.4),
AbiResult::Error(_) => panic!("expected ok variant"),
}
}
#[test]
fn abi_result_error_round_trip() {
let envelope = AbiResult::Error(PluginError::ValidationErrors(vec![PluginFieldError {
field: "/gtin".into(),
code: "missing".into(),
message: "gtin is required".into(),
}]));
assert!(!envelope.is_ok());
let json = serde_json::to_value(&envelope).unwrap();
assert!(json.get("error").is_some());
let back: AbiResult = serde_json::from_value(json).unwrap();
assert!(!back.is_ok());
}
}