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";
266pub const METRIC_RECYCLED_CONTENT_PCT: &str = "recycled_content_pct";
268
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct PluginFinding {
279 pub code: String,
281 #[serde(default, skip_serializing_if = "String::is_empty")]
284 pub field: String,
285 pub message: String,
287}
288
289impl PluginFinding {
290 pub fn new(
292 code: impl Into<String>,
293 field: impl Into<String>,
294 message: impl Into<String>,
295 ) -> Self {
296 Self {
297 code: code.into(),
298 field: field.into(),
299 message: message.into(),
300 }
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct PluginResult {
322 pub compliance_status: PluginComplianceStatus,
324 #[serde(default)]
326 pub metrics: std::collections::HashMap<String, f64>,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub extra: Option<serde_json::Value>,
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
333 pub violations: Vec<PluginFinding>,
334 #[serde(default, skip_serializing_if = "Vec::is_empty")]
337 pub warnings: Vec<PluginFinding>,
338}
339
340impl PluginResult {
341 pub fn new(status: PluginComplianceStatus) -> Self {
343 Self {
344 compliance_status: status,
345 metrics: std::collections::HashMap::new(),
346 extra: None,
347 violations: Vec::new(),
348 warnings: Vec::new(),
349 }
350 }
351
352 pub fn with_metric(mut self, key: &str, value: f64) -> Self {
354 if value.is_finite() {
355 self.metrics.insert(key.to_owned(), value);
356 }
357 self
358 }
359
360 pub fn maybe_metric(mut self, key: &str, value: Option<f64>) -> Self {
362 if let Some(v) = value
363 && v.is_finite()
364 {
365 self.metrics.insert(key.to_owned(), v);
366 }
367 self
368 }
369
370 pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
372 self.extra = Some(extra);
373 self
374 }
375
376 pub fn with_violation(mut self, finding: PluginFinding) -> Self {
378 self.violations.push(finding);
379 self
380 }
381
382 pub fn with_warning(mut self, finding: PluginFinding) -> Self {
384 self.warnings.push(finding);
385 self
386 }
387
388 pub fn co2e_score(&self) -> Option<f64> {
391 self.metrics.get(METRIC_CO2E_SCORE).copied()
392 }
393
394 pub fn repairability_index(&self) -> Option<f64> {
395 self.metrics.get(METRIC_REPAIRABILITY_INDEX).copied()
396 }
397
398 pub fn recycled_content_pct(&self) -> Option<f64> {
399 self.metrics.get(METRIC_RECYCLED_CONTENT_PCT).copied()
400 }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum AbiResult {
422 Ok(serde_json::Value),
423 Error(PluginError),
424}
425
426impl AbiResult {
427 pub fn ok<T: Serialize>(value: &T) -> Self {
429 Self::Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null))
430 }
431
432 pub fn is_ok(&self) -> bool {
434 matches!(self, Self::Ok(_))
435 }
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(rename_all = "camelCase")]
443pub struct PluginFieldError {
444 pub field: String,
446 pub code: String,
448 pub message: String,
450}
451
452#[derive(Debug, Clone, Error, Serialize, Deserialize)]
453pub enum PluginError {
454 #[error("invalid input: {0}")]
455 InvalidInput(String),
456 #[error("validation errors: {0:?}")]
457 ValidationErrors(Vec<PluginFieldError>),
458 #[error("calculation failed: {0}")]
459 Calculation(String),
460 #[error("sector not supported by this plugin: {0}")]
461 UnsupportedSector(String),
462 #[error("schema version not supported: {0}")]
463 UnsupportedSchemaVersion(String),
464 #[error("capability not available: {0}")]
465 CapabilityNotAvailable(String),
466 #[error("internal plugin error: {0}")]
467 Internal(String),
468}
469
470pub trait DppSectorPlugin: Send + Sync {
477 fn meta(&self) -> PluginMeta;
479
480 fn capabilities(&self) -> PluginCapabilities;
482
483 fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError>;
490
491 fn calculate_metrics(&self, input: &PluginInput) -> Result<PluginResult, PluginError>;
495
496 fn generate_passport(&self, input: &PluginInput) -> Result<serde_json::Value, PluginError>;
501}
502
503#[cfg(test)]
506mod tests {
507 use super::*;
508
509 fn sample_capabilities() -> PluginCapabilities {
510 PluginCapabilities {
511 abi_version: AbiVersion::current(),
512 supported_schemas: vec![SchemaVersionRange {
513 min_version: "1.0.0".into(),
514 max_version: "1.1.0".into(),
515 }],
516 capabilities: vec![
517 PluginCapability::Validate,
518 PluginCapability::ComputeMetrics,
519 PluginCapability::GeneratePassport,
520 ],
521 min_host_version: None,
522 max_fuel: None,
523 max_memory_bytes: None,
524 }
525 }
526
527 #[test]
528 fn abi_version_current_is_compatible() {
529 let current = AbiVersion::current();
530 assert!(current.is_compatible_with_host());
531 }
532
533 #[test]
534 fn abi_version_major_mismatch_incompatible() {
535 let future = AbiVersion { major: 2, minor: 0 };
536 assert!(!future.is_compatible_with_host());
537 }
538
539 #[test]
540 fn abi_version_minor_ahead_incompatible() {
541 let ahead = AbiVersion {
542 major: ABI_VERSION_MAJOR,
543 minor: ABI_VERSION_MINOR + 1,
544 };
545 assert!(!ahead.is_compatible_with_host());
546 }
547
548 #[test]
549 fn abi_version_display() {
550 let v = AbiVersion { major: 1, minor: 0 };
551 assert_eq!(format!("{v}"), "1.0");
552 }
553
554 #[test]
555 fn compatibility_check_passes() {
556 let caps = sample_capabilities();
557 let result = check_compatibility(&caps, Some("1.0.0"), &[PluginCapability::Validate]);
558 assert!(result.is_compatible());
559 }
560
561 #[test]
562 fn compatibility_check_schema_in_range() {
563 let caps = sample_capabilities();
564 let result = check_compatibility(&caps, Some("1.1.0"), &[]);
565 assert!(result.is_compatible());
566 }
567
568 #[test]
569 fn compatibility_check_schema_out_of_range() {
570 let caps = sample_capabilities();
571 let result = check_compatibility(&caps, Some("2.0.0"), &[]);
572 assert!(matches!(
573 result,
574 CompatibilityStatus::SchemaUnsupported { .. }
575 ));
576 }
577
578 #[test]
579 fn semver_multi_digit_minor_accepted() {
580 let caps = PluginCapabilities {
583 abi_version: AbiVersion::current(),
584 supported_schemas: vec![SchemaVersionRange {
585 min_version: "1.0.0".into(),
586 max_version: "1.10.0".into(),
587 }],
588 capabilities: vec![],
589 min_host_version: None,
590 max_fuel: None,
591 max_memory_bytes: None,
592 };
593 let result = check_compatibility(&caps, Some("1.10.0"), &[]);
594 assert!(
595 result.is_compatible(),
596 "1.10.0 must be accepted within [1.0.0, 1.10.0]"
597 );
598 }
599
600 #[test]
601 fn semver_multi_digit_minor_rejected_correctly() {
602 let caps = PluginCapabilities {
604 abi_version: AbiVersion::current(),
605 supported_schemas: vec![SchemaVersionRange {
606 min_version: "1.0.0".into(),
607 max_version: "1.2.0".into(),
608 }],
609 capabilities: vec![],
610 min_host_version: None,
611 max_fuel: None,
612 max_memory_bytes: None,
613 };
614 let result = check_compatibility(&caps, Some("1.10.0"), &[]);
615 assert!(
616 matches!(result, CompatibilityStatus::SchemaUnsupported { .. }),
617 "1.10.0 must be rejected when max is 1.2.0"
618 );
619 }
620
621 #[test]
622 fn compatibility_check_missing_capability() {
623 let caps = sample_capabilities();
624 let result = check_compatibility(&caps, None, &[PluginCapability::SubstanceScreening]);
625 assert!(matches!(result, CompatibilityStatus::MissingCapability(_)));
626 }
627
628 #[test]
629 fn compatibility_check_abi_mismatch() {
630 let mut caps = sample_capabilities();
631 caps.abi_version = AbiVersion { major: 2, minor: 0 };
632 let result = check_compatibility(&caps, None, &[]);
633 assert!(matches!(
634 result,
635 CompatibilityStatus::AbiIncompatible { .. }
636 ));
637 }
638
639 #[test]
640 fn compatibility_check_host_too_old() {
641 let mut caps = sample_capabilities();
642 caps.min_host_version = Some(AbiVersion {
643 major: ABI_VERSION_MAJOR,
644 minor: ABI_VERSION_MINOR + 5,
645 });
646 let result = check_compatibility(&caps, None, &[]);
647 assert!(matches!(result, CompatibilityStatus::HostTooOld { .. }));
648 }
649
650 #[test]
651 fn compatibility_check_no_schema_constraint() {
652 let caps = sample_capabilities();
653 let result = check_compatibility(&caps, None, &[]);
654 assert!(result.is_compatible());
655 }
656
657 #[test]
658 fn plugin_meta_round_trip() {
659 let meta = PluginMeta {
660 sector: "textile".into(),
661 name: "Textile Compliance Plugin".into(),
662 version: "0.2.0".into(),
663 license: "Apache-2.0".into(),
664 description: Some("Validates textile DPP data".into()),
665 author: Some("Odal Node".into()),
666 homepage: Some("https://github.com/odal-node".into()),
667 };
668 let json = serde_json::to_value(&meta).unwrap();
669 assert_eq!(json["sector"], "textile");
670 assert_eq!(json["description"], "Validates textile DPP data");
671 let back: PluginMeta = serde_json::from_value(json).unwrap();
672 assert_eq!(meta.name, back.name);
673 }
674
675 #[test]
676 fn capabilities_round_trip() {
677 let caps = sample_capabilities();
678 let json = serde_json::to_value(&caps).unwrap();
679 assert!(json["supportedSchemas"].is_array());
680 assert_eq!(json["abiVersion"]["major"], ABI_VERSION_MAJOR);
681 let back: PluginCapabilities = serde_json::from_value(json).unwrap();
682 assert_eq!(caps.abi_version, back.abi_version);
683 }
684
685 #[test]
686 fn plugin_field_error_round_trip() {
687 let err = PluginFieldError {
688 field: "/fibreComposition/0/pct".into(),
689 code: "out_of_range".into(),
690 message: "pct must be 0-100".into(),
691 };
692 let json = serde_json::to_value(&err).unwrap();
693 assert_eq!(json["code"], "out_of_range");
694 let back: PluginFieldError = serde_json::from_value(json).unwrap();
695 assert_eq!(err.field, back.field);
696 }
697
698 #[test]
699 fn custom_capability_round_trip() {
700 let cap = PluginCapability::Custom("carbon_offset_calc".into());
701 let json = serde_json::to_value(&cap).unwrap();
702 let back: PluginCapability = serde_json::from_value(json).unwrap();
703 assert_eq!(cap, back);
704 }
705
706 #[test]
707 fn abi_result_ok_round_trip() {
708 let result = PluginResult::new(PluginComplianceStatus::NotAssessed)
709 .with_metric(METRIC_CO2E_SCORE, 85.4)
710 .with_metric(METRIC_RECYCLED_CONTENT_PCT, 12.5);
711 let envelope = AbiResult::ok(&result);
712 assert!(envelope.is_ok());
713 let json = serde_json::to_value(&envelope).unwrap();
714 assert!(json["ok"].is_object());
715 assert_eq!(json["ok"]["complianceStatus"], "NOT_ASSESSED");
716
717 let back: AbiResult = serde_json::from_value(json).unwrap();
718 match back {
719 AbiResult::Ok(v) => assert_eq!(v["metrics"]["co2e_score"], 85.4),
720 AbiResult::Error(_) => panic!("expected ok variant"),
721 }
722 }
723
724 #[test]
725 fn abi_result_error_round_trip() {
726 let envelope = AbiResult::Error(PluginError::ValidationErrors(vec![PluginFieldError {
727 field: "/gtin".into(),
728 code: "missing".into(),
729 message: "gtin is required".into(),
730 }]));
731 assert!(!envelope.is_ok());
732 let json = serde_json::to_value(&envelope).unwrap();
733 assert!(json.get("error").is_some());
734
735 let back: AbiResult = serde_json::from_value(json).unwrap();
736 assert!(!back.is_ok());
737 }
738}