Skip to main content

dpp_plugin_traits/
version.rs

1//! ABI versioning and host/plugin compatibility negotiation.
2
3use serde::{Deserialize, Serialize};
4
5use crate::meta::{PluginCapabilities, PluginCapability};
6
7/// Current host ABI version.
8///
9/// Increment the major version for breaking changes to the plugin interface.
10/// Increment the minor version for backward-compatible additions.
11pub const ABI_VERSION_MAJOR: u32 = 1;
12// 1.1: PluginResult gained backward-compatible `violations`/`warnings` finding
13// lists. Older (1.0) plugins omit them (serde defaults to empty) and still load.
14pub const ABI_VERSION_MINOR: u32 = 1;
15
16/// ABI version declared by a plugin.
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "camelCase")]
19pub struct AbiVersion {
20    pub major: u32,
21    pub minor: u32,
22}
23
24impl AbiVersion {
25    pub const fn current() -> Self {
26        Self {
27            major: ABI_VERSION_MAJOR,
28            minor: ABI_VERSION_MINOR,
29        }
30    }
31
32    /// Check if this ABI version is compatible with the host.
33    ///
34    /// Major versions must match exactly. The plugin's minor version must be
35    /// ≤ the host's minor version (the host supports all older minor versions).
36    #[allow(clippy::absurd_extreme_comparisons)] // intentional: works correctly when ABI_VERSION_MINOR > 0
37    pub fn is_compatible_with_host(&self) -> bool {
38        self.major == ABI_VERSION_MAJOR && self.minor <= ABI_VERSION_MINOR
39    }
40}
41
42impl std::fmt::Display for AbiVersion {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "{}.{}", self.major, self.minor)
45    }
46}
47
48/// Schema version range a plugin supports.
49///
50/// A plugin may support multiple schema versions (e.g., it can validate
51/// both v1.0.0 and v1.1.0 textile data). The host uses this to dispatch
52/// data to the correct plugin version.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase")]
55pub struct SchemaVersionRange {
56    /// Minimum supported schema version (inclusive), e.g. `"1.0.0"`.
57    pub min_version: String,
58    /// Maximum supported schema version (inclusive), e.g. `"1.1.0"`.
59    pub max_version: String,
60}
61
62/// Result of a compatibility check between host and plugin.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum CompatibilityStatus {
65    /// Fully compatible — all checks pass.
66    Compatible,
67    /// ABI version mismatch — major version differs.
68    AbiIncompatible {
69        host: AbiVersion,
70        plugin: AbiVersion,
71    },
72    /// Plugin requires a newer host than what's running.
73    HostTooOld {
74        required: AbiVersion,
75        actual: AbiVersion,
76    },
77    /// The plugin doesn't support the requested schema version.
78    SchemaUnsupported {
79        requested: String,
80        supported: Vec<SchemaVersionRange>,
81    },
82    /// Missing a required capability.
83    MissingCapability(PluginCapability),
84}
85
86impl CompatibilityStatus {
87    pub fn is_compatible(&self) -> bool {
88        matches!(self, Self::Compatible)
89    }
90}
91
92/// Check if a plugin is compatible with the current host and a requested
93/// schema version.
94pub fn check_compatibility(
95    capabilities: &PluginCapabilities,
96    requested_schema_version: Option<&str>,
97    required_capabilities: &[PluginCapability],
98) -> CompatibilityStatus {
99    // 1. ABI version check
100    if !capabilities.abi_version.is_compatible_with_host() {
101        return CompatibilityStatus::AbiIncompatible {
102            host: AbiVersion::current(),
103            plugin: capabilities.abi_version,
104        };
105    }
106
107    // 2. Min host version check
108    if let Some(ref min_host) = capabilities.min_host_version {
109        let current = AbiVersion::current();
110        if current.major < min_host.major
111            || (current.major == min_host.major && current.minor < min_host.minor)
112        {
113            return CompatibilityStatus::HostTooOld {
114                required: *min_host,
115                actual: current,
116            };
117        }
118    }
119
120    // 3. Schema version check (semantic via semver crate; falls back to lexicographic)
121    if let Some(requested) = requested_schema_version {
122        let req = semver::Version::parse(requested).ok();
123        let supported = capabilities.supported_schemas.iter().any(|range| {
124            let lo = semver::Version::parse(&range.min_version).ok();
125            let hi = semver::Version::parse(&range.max_version).ok();
126            match (req.as_ref(), lo, hi) {
127                (Some(r), Some(l), Some(h)) => r >= &l && r <= &h,
128                _ => {
129                    requested >= range.min_version.as_str()
130                        && requested <= range.max_version.as_str()
131                }
132            }
133        });
134        if !supported {
135            return CompatibilityStatus::SchemaUnsupported {
136                requested: requested.to_owned(),
137                supported: capabilities.supported_schemas.clone(),
138            };
139        }
140    }
141
142    // 4. Capability check
143    for required in required_capabilities {
144        if !capabilities.capabilities.contains(required) {
145            return CompatibilityStatus::MissingCapability(required.clone());
146        }
147    }
148
149    CompatibilityStatus::Compatible
150}