1use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19pub const ABI_VERSION_MAJOR: u32 = 1;
26pub const ABI_VERSION_MINOR: u32 = 0;
27
28#[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 #[allow(clippy::absurd_extreme_comparisons)] 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#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct PluginMeta {
66 pub sector: String,
68 pub name: String,
70 pub version: String,
72 pub license: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub description: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub author: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub homepage: Option<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94pub struct SchemaVersionRange {
95 pub min_version: String,
97 pub max_version: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
103#[serde(rename_all = "snake_case")]
104pub enum PluginCapability {
105 Validate,
107 ComputeMetrics,
109 GeneratePassport,
111 SubstanceScreening,
113 LifecycleAssessment,
115 AasMapping,
117 Custom(String),
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127pub struct PluginCapabilities {
128 pub abi_version: AbiVersion,
130 pub supported_schemas: Vec<SchemaVersionRange>,
132 pub capabilities: Vec<PluginCapability>,
134 #[serde(skip_serializing_if = "Option::is_none")]
137 pub min_host_version: Option<AbiVersion>,
138 #[serde(skip_serializing_if = "Option::is_none")]
141 pub max_fuel: Option<u64>,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub max_memory_bytes: Option<u64>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum CompatibilityStatus {
152 Compatible,
154 AbiIncompatible {
156 host: AbiVersion,
157 plugin: AbiVersion,
158 },
159 HostTooOld {
161 required: AbiVersion,
162 actual: AbiVersion,
163 },
164 SchemaUnsupported {
166 requested: String,
167 supported: Vec<SchemaVersionRange>,
168 },
169 MissingCapability(PluginCapability),
171}
172
173impl CompatibilityStatus {
174 pub fn is_compatible(&self) -> bool {
175 matches!(self, Self::Compatible)
176 }
177}
178
179pub fn check_compatibility(
182 capabilities: &PluginCapabilities,
183 requested_schema_version: Option<&str>,
184 required_capabilities: &[PluginCapability],
185) -> CompatibilityStatus {
186 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 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 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 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
239pub type PluginInput = serde_json::Value;
243
244#[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
259pub const METRIC_CO2E_SCORE: &str = "co2e_score";
261pub const METRIC_REPAIRABILITY_INDEX: &str = "repairability_index";
263pub const METRIC_RECYCLED_CONTENT_PCT: &str = "recycled_content_pct";
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct PluginResult {
283 pub compliance_status: PluginComplianceStatus,
285 #[serde(default)]
287 pub metrics: std::collections::HashMap<String, f64>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub extra: Option<serde_json::Value>,
291}
292
293impl PluginResult {
294 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 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 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 pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
323 self.extra = Some(extra);
324 self
325 }
326
327 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#[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 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 pub fn is_ok(&self) -> bool {
373 matches!(self, Self::Ok(_))
374 }
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(rename_all = "camelCase")]
382pub struct PluginFieldError {
383 pub field: String,
385 pub code: String,
387 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
409pub trait DppSectorPlugin: Send + Sync {
416 fn meta(&self) -> PluginMeta;
418
419 fn capabilities(&self) -> PluginCapabilities;
421
422 fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError>;
429
430 fn calculate_metrics(&self, input: &PluginInput) -> Result<PluginResult, PluginError>;
434
435 fn generate_passport(&self, input: &PluginInput) -> Result<serde_json::Value, PluginError>;
440}
441
442#[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 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 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}