1use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19pub const ABI_VERSION_MAJOR: u32 = 1;
26pub const ABI_VERSION_MINOR: u32 = 1;
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "camelCase")]
33pub struct AbiVersion {
34 pub major: u32,
35 pub minor: u32,
36}
37
38impl AbiVersion {
39 pub const fn current() -> Self {
40 Self {
41 major: ABI_VERSION_MAJOR,
42 minor: ABI_VERSION_MINOR,
43 }
44 }
45
46 #[allow(clippy::absurd_extreme_comparisons)] pub fn is_compatible_with_host(&self) -> bool {
52 self.major == ABI_VERSION_MAJOR && self.minor <= ABI_VERSION_MINOR
53 }
54}
55
56impl std::fmt::Display for AbiVersion {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 write!(f, "{}.{}", self.major, self.minor)
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct PluginMeta {
68 pub sector: String,
70 pub name: String,
72 pub version: String,
74 pub license: String,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub description: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub author: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub homepage: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "camelCase")]
96pub struct SchemaVersionRange {
97 pub min_version: String,
99 pub max_version: String,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
105#[serde(rename_all = "snake_case")]
106pub enum PluginCapability {
107 Validate,
109 ComputeMetrics,
111 GeneratePassport,
113 SubstanceScreening,
115 LifecycleAssessment,
117 AasMapping,
119 Custom(String),
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct PluginCapabilities {
130 pub abi_version: AbiVersion,
132 pub supported_schemas: Vec<SchemaVersionRange>,
134 pub capabilities: Vec<PluginCapability>,
136 #[serde(skip_serializing_if = "Option::is_none")]
139 pub min_host_version: Option<AbiVersion>,
140 #[serde(skip_serializing_if = "Option::is_none")]
143 pub max_fuel: Option<u64>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub max_memory_bytes: Option<u64>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum CompatibilityStatus {
154 Compatible,
156 AbiIncompatible {
158 host: AbiVersion,
159 plugin: AbiVersion,
160 },
161 HostTooOld {
163 required: AbiVersion,
164 actual: AbiVersion,
165 },
166 SchemaUnsupported {
168 requested: String,
169 supported: Vec<SchemaVersionRange>,
170 },
171 MissingCapability(PluginCapability),
173}
174
175impl CompatibilityStatus {
176 pub fn is_compatible(&self) -> bool {
177 matches!(self, Self::Compatible)
178 }
179}
180
181pub fn check_compatibility(
184 capabilities: &PluginCapabilities,
185 requested_schema_version: Option<&str>,
186 required_capabilities: &[PluginCapability],
187) -> CompatibilityStatus {
188 if !capabilities.abi_version.is_compatible_with_host() {
190 return CompatibilityStatus::AbiIncompatible {
191 host: AbiVersion::current(),
192 plugin: capabilities.abi_version,
193 };
194 }
195
196 if let Some(ref min_host) = capabilities.min_host_version {
198 let current = AbiVersion::current();
199 if current.major < min_host.major
200 || (current.major == min_host.major && current.minor < min_host.minor)
201 {
202 return CompatibilityStatus::HostTooOld {
203 required: *min_host,
204 actual: current,
205 };
206 }
207 }
208
209 if let Some(requested) = requested_schema_version {
211 let req = semver::Version::parse(requested).ok();
212 let supported = capabilities.supported_schemas.iter().any(|range| {
213 let lo = semver::Version::parse(&range.min_version).ok();
214 let hi = semver::Version::parse(&range.max_version).ok();
215 match (req.as_ref(), lo, hi) {
216 (Some(r), Some(l), Some(h)) => r >= &l && r <= &h,
217 _ => {
218 requested >= range.min_version.as_str()
219 && requested <= range.max_version.as_str()
220 }
221 }
222 });
223 if !supported {
224 return CompatibilityStatus::SchemaUnsupported {
225 requested: requested.to_owned(),
226 supported: capabilities.supported_schemas.clone(),
227 };
228 }
229 }
230
231 for required in required_capabilities {
233 if !capabilities.capabilities.contains(required) {
234 return CompatibilityStatus::MissingCapability(required.clone());
235 }
236 }
237
238 CompatibilityStatus::Compatible
239}
240
241pub type PluginInput = serde_json::Value;
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
253pub enum PluginComplianceStatus {
254 Compliant,
255 NonCompliant,
256 NotAssessed,
257 PassthroughNoValidation,
258 NotImplemented,
259}
260
261pub const METRIC_CO2E_SCORE: &str = "co2e_score";
263pub const METRIC_REPAIRABILITY_INDEX: &str = "repairability_index";
265pub const METRIC_RECYCLED_CONTENT_PCT: &str = "recycled_content_pct";
267
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct PluginFinding {
278 pub code: String,
280 #[serde(default, skip_serializing_if = "String::is_empty")]
283 pub field: String,
284 pub message: String,
286}
287
288impl PluginFinding {
289 pub fn new(
291 code: impl Into<String>,
292 field: impl Into<String>,
293 message: impl Into<String>,
294 ) -> Self {
295 Self {
296 code: code.into(),
297 field: field.into(),
298 message: message.into(),
299 }
300 }
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320pub struct PluginResult {
321 pub compliance_status: PluginComplianceStatus,
323 #[serde(default)]
325 pub metrics: std::collections::HashMap<String, f64>,
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub extra: Option<serde_json::Value>,
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub violations: Vec<PluginFinding>,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub warnings: Vec<PluginFinding>,
337}
338
339impl PluginResult {
340 pub fn new(status: PluginComplianceStatus) -> Self {
342 Self {
343 compliance_status: status,
344 metrics: std::collections::HashMap::new(),
345 extra: None,
346 violations: Vec::new(),
347 warnings: Vec::new(),
348 }
349 }
350
351 pub fn with_metric(mut self, key: &str, value: f64) -> Self {
353 if value.is_finite() {
354 self.metrics.insert(key.to_owned(), value);
355 }
356 self
357 }
358
359 pub fn maybe_metric(mut self, key: &str, value: Option<f64>) -> Self {
361 if let Some(v) = value
362 && v.is_finite()
363 {
364 self.metrics.insert(key.to_owned(), v);
365 }
366 self
367 }
368
369 pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
371 self.extra = Some(extra);
372 self
373 }
374
375 pub fn with_violation(mut self, finding: PluginFinding) -> Self {
377 self.violations.push(finding);
378 self
379 }
380
381 pub fn with_warning(mut self, finding: PluginFinding) -> Self {
383 self.warnings.push(finding);
384 self
385 }
386
387 pub fn co2e_score(&self) -> Option<f64> {
390 self.metrics.get(METRIC_CO2E_SCORE).copied()
391 }
392
393 pub fn repairability_index(&self) -> Option<f64> {
394 self.metrics.get(METRIC_REPAIRABILITY_INDEX).copied()
395 }
396
397 pub fn recycled_content_pct(&self) -> Option<f64> {
398 self.metrics.get(METRIC_RECYCLED_CONTENT_PCT).copied()
399 }
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum AbiResult {
421 Ok(serde_json::Value),
422 Error(PluginError),
423}
424
425impl AbiResult {
426 pub fn ok<T: Serialize>(value: &T) -> Self {
428 Self::Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null))
429 }
430
431 pub fn is_ok(&self) -> bool {
433 matches!(self, Self::Ok(_))
434 }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub struct PluginFieldError {
443 pub field: String,
445 pub code: String,
447 pub message: String,
449}
450
451#[derive(Debug, Clone, Error, Serialize, Deserialize)]
452pub enum PluginError {
453 #[error("invalid input: {0}")]
454 InvalidInput(String),
455 #[error("validation errors: {0:?}")]
456 ValidationErrors(Vec<PluginFieldError>),
457 #[error("calculation failed: {0}")]
458 Calculation(String),
459 #[error("sector not supported by this plugin: {0}")]
460 UnsupportedSector(String),
461 #[error("schema version not supported: {0}")]
462 UnsupportedSchemaVersion(String),
463 #[error("capability not available: {0}")]
464 CapabilityNotAvailable(String),
465 #[error("internal plugin error: {0}")]
466 Internal(String),
467}
468
469pub trait DppSectorPlugin: Send + Sync {
476 fn meta(&self) -> PluginMeta;
478
479 fn capabilities(&self) -> PluginCapabilities;
481
482 fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError>;
489
490 fn calculate_metrics(&self, input: &PluginInput) -> Result<PluginResult, PluginError>;
494
495 fn generate_passport(&self, input: &PluginInput) -> Result<serde_json::Value, PluginError>;
500}
501
502#[cfg(test)]
505mod tests {
506 use super::*;
507
508 fn sample_capabilities() -> PluginCapabilities {
509 PluginCapabilities {
510 abi_version: AbiVersion::current(),
511 supported_schemas: vec![SchemaVersionRange {
512 min_version: "1.0.0".into(),
513 max_version: "1.1.0".into(),
514 }],
515 capabilities: vec![
516 PluginCapability::Validate,
517 PluginCapability::ComputeMetrics,
518 PluginCapability::GeneratePassport,
519 ],
520 min_host_version: None,
521 max_fuel: None,
522 max_memory_bytes: None,
523 }
524 }
525
526 #[test]
527 fn abi_version_current_is_compatible() {
528 let current = AbiVersion::current();
529 assert!(current.is_compatible_with_host());
530 }
531
532 #[test]
533 fn abi_version_major_mismatch_incompatible() {
534 let future = AbiVersion { major: 2, minor: 0 };
535 assert!(!future.is_compatible_with_host());
536 }
537
538 #[test]
539 fn abi_version_minor_ahead_incompatible() {
540 let ahead = AbiVersion {
541 major: ABI_VERSION_MAJOR,
542 minor: ABI_VERSION_MINOR + 1,
543 };
544 assert!(!ahead.is_compatible_with_host());
545 }
546
547 #[test]
548 fn abi_version_display() {
549 let v = AbiVersion { major: 1, minor: 0 };
550 assert_eq!(format!("{v}"), "1.0");
551 }
552
553 #[test]
554 fn compatibility_check_passes() {
555 let caps = sample_capabilities();
556 let result = check_compatibility(&caps, Some("1.0.0"), &[PluginCapability::Validate]);
557 assert!(result.is_compatible());
558 }
559
560 #[test]
561 fn compatibility_check_schema_in_range() {
562 let caps = sample_capabilities();
563 let result = check_compatibility(&caps, Some("1.1.0"), &[]);
564 assert!(result.is_compatible());
565 }
566
567 #[test]
568 fn compatibility_check_schema_out_of_range() {
569 let caps = sample_capabilities();
570 let result = check_compatibility(&caps, Some("2.0.0"), &[]);
571 assert!(matches!(
572 result,
573 CompatibilityStatus::SchemaUnsupported { .. }
574 ));
575 }
576
577 #[test]
578 fn semver_multi_digit_minor_accepted() {
579 let caps = PluginCapabilities {
582 abi_version: AbiVersion::current(),
583 supported_schemas: vec![SchemaVersionRange {
584 min_version: "1.0.0".into(),
585 max_version: "1.10.0".into(),
586 }],
587 capabilities: vec![],
588 min_host_version: None,
589 max_fuel: None,
590 max_memory_bytes: None,
591 };
592 let result = check_compatibility(&caps, Some("1.10.0"), &[]);
593 assert!(
594 result.is_compatible(),
595 "1.10.0 must be accepted within [1.0.0, 1.10.0]"
596 );
597 }
598
599 #[test]
600 fn semver_multi_digit_minor_rejected_correctly() {
601 let caps = PluginCapabilities {
603 abi_version: AbiVersion::current(),
604 supported_schemas: vec![SchemaVersionRange {
605 min_version: "1.0.0".into(),
606 max_version: "1.2.0".into(),
607 }],
608 capabilities: vec![],
609 min_host_version: None,
610 max_fuel: None,
611 max_memory_bytes: None,
612 };
613 let result = check_compatibility(&caps, Some("1.10.0"), &[]);
614 assert!(
615 matches!(result, CompatibilityStatus::SchemaUnsupported { .. }),
616 "1.10.0 must be rejected when max is 1.2.0"
617 );
618 }
619
620 #[test]
621 fn compatibility_check_missing_capability() {
622 let caps = sample_capabilities();
623 let result = check_compatibility(&caps, None, &[PluginCapability::SubstanceScreening]);
624 assert!(matches!(result, CompatibilityStatus::MissingCapability(_)));
625 }
626
627 #[test]
628 fn compatibility_check_abi_mismatch() {
629 let mut caps = sample_capabilities();
630 caps.abi_version = AbiVersion { major: 2, minor: 0 };
631 let result = check_compatibility(&caps, None, &[]);
632 assert!(matches!(
633 result,
634 CompatibilityStatus::AbiIncompatible { .. }
635 ));
636 }
637
638 #[test]
639 fn compatibility_check_host_too_old() {
640 let mut caps = sample_capabilities();
641 caps.min_host_version = Some(AbiVersion {
642 major: ABI_VERSION_MAJOR,
643 minor: ABI_VERSION_MINOR + 5,
644 });
645 let result = check_compatibility(&caps, None, &[]);
646 assert!(matches!(result, CompatibilityStatus::HostTooOld { .. }));
647 }
648
649 #[test]
650 fn compatibility_check_no_schema_constraint() {
651 let caps = sample_capabilities();
652 let result = check_compatibility(&caps, None, &[]);
653 assert!(result.is_compatible());
654 }
655
656 #[test]
657 fn plugin_meta_round_trip() {
658 let meta = PluginMeta {
659 sector: "textile".into(),
660 name: "Textile Compliance Plugin".into(),
661 version: "0.2.0".into(),
662 license: "Apache-2.0".into(),
663 description: Some("Validates textile DPP data".into()),
664 author: Some("Odal Node".into()),
665 homepage: Some("https://github.com/odal-node".into()),
666 };
667 let json = serde_json::to_value(&meta).unwrap();
668 assert_eq!(json["sector"], "textile");
669 assert_eq!(json["description"], "Validates textile DPP data");
670 let back: PluginMeta = serde_json::from_value(json).unwrap();
671 assert_eq!(meta.name, back.name);
672 }
673
674 #[test]
675 fn capabilities_round_trip() {
676 let caps = sample_capabilities();
677 let json = serde_json::to_value(&caps).unwrap();
678 assert!(json["supportedSchemas"].is_array());
679 assert_eq!(json["abiVersion"]["major"], ABI_VERSION_MAJOR);
680 let back: PluginCapabilities = serde_json::from_value(json).unwrap();
681 assert_eq!(caps.abi_version, back.abi_version);
682 }
683
684 #[test]
685 fn plugin_field_error_round_trip() {
686 let err = PluginFieldError {
687 field: "/fibreComposition/0/pct".into(),
688 code: "out_of_range".into(),
689 message: "pct must be 0-100".into(),
690 };
691 let json = serde_json::to_value(&err).unwrap();
692 assert_eq!(json["code"], "out_of_range");
693 let back: PluginFieldError = serde_json::from_value(json).unwrap();
694 assert_eq!(err.field, back.field);
695 }
696
697 #[test]
698 fn custom_capability_round_trip() {
699 let cap = PluginCapability::Custom("carbon_offset_calc".into());
700 let json = serde_json::to_value(&cap).unwrap();
701 let back: PluginCapability = serde_json::from_value(json).unwrap();
702 assert_eq!(cap, back);
703 }
704
705 #[test]
706 fn abi_result_ok_round_trip() {
707 let result = PluginResult::new(PluginComplianceStatus::NotAssessed)
708 .with_metric(METRIC_CO2E_SCORE, 85.4)
709 .with_metric(METRIC_RECYCLED_CONTENT_PCT, 12.5);
710 let envelope = AbiResult::ok(&result);
711 assert!(envelope.is_ok());
712 let json = serde_json::to_value(&envelope).unwrap();
713 assert!(json["ok"].is_object());
714 assert_eq!(json["ok"]["complianceStatus"], "NOT_ASSESSED");
715
716 let back: AbiResult = serde_json::from_value(json).unwrap();
717 match back {
718 AbiResult::Ok(v) => assert_eq!(v["metrics"]["co2e_score"], 85.4),
719 AbiResult::Error(_) => panic!("expected ok variant"),
720 }
721 }
722
723 #[test]
724 fn abi_result_error_round_trip() {
725 let envelope = AbiResult::Error(PluginError::ValidationErrors(vec![PluginFieldError {
726 field: "/gtin".into(),
727 code: "missing".into(),
728 message: "gtin is required".into(),
729 }]));
730 assert!(!envelope.is_ok());
731 let json = serde_json::to_value(&envelope).unwrap();
732 assert!(json.get("error").is_some());
733
734 let back: AbiResult = serde_json::from_value(json).unwrap();
735 assert!(!back.is_ok());
736 }
737}