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 — strictly semantic (semver). A version string
121    // that isn't valid semver cannot be compared as a version; treat such a
122    // range as non-matching rather than falling back to a lexicographic string
123    // comparison (where e.g. "1.9.0" > "1.10.0" gives the wrong answer).
124    if let Some(requested) = requested_schema_version {
125        let req = semver::Version::parse(requested).ok();
126        let supported = capabilities.supported_schemas.iter().any(|range| {
127            match (
128                req.as_ref(),
129                semver::Version::parse(&range.min_version),
130                semver::Version::parse(&range.max_version),
131            ) {
132                (Some(r), Ok(l), Ok(h)) => r >= &l && r <= &h,
133                _ => false,
134            }
135        });
136        if !supported {
137            return CompatibilityStatus::SchemaUnsupported {
138                requested: requested.to_owned(),
139                supported: capabilities.supported_schemas.clone(),
140            };
141        }
142    }
143
144    // 4. Capability check
145    for required in required_capabilities {
146        if !capabilities.capabilities.contains(required) {
147            return CompatibilityStatus::MissingCapability(required.clone());
148        }
149    }
150
151    CompatibilityStatus::Compatible
152}