Skip to main content

openstranded_common_wasmcontract/
version.rs

1use std::fmt;
2
3/// Compile-time conversion of a decimal &str to u32.
4macro_rules! parse_version_num {
5    ($s:expr) => {{
6        const BYTES: &[u8] = $s.as_bytes();
7        let mut n: u32 = 0;
8        let mut i: usize = 0;
9        while i < BYTES.len() && BYTES[i] >= b'0' && BYTES[i] <= b'9' {
10            n = n * 10 + (BYTES[i] - b'0') as u32;
11            i += 1;
12        }
13        n
14    }};
15}
16
17/// Version of `openstranded-common-wasmcontract` baked at compile time.
18///
19/// Each WASM plugin exports this via `plugin_api_version()`.
20/// The engine reads it on load to check compatibility.
21///
22/// The version is automatically derived from the `Cargo.toml` of the
23/// `openstranded-common-wasmcontract` crate. Plugin authors never set it manually.
24///
25/// # WASM ABI
26///
27/// `#[repr(C)]` ensures a stable layout when passing across the WASM boundary.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29#[repr(C)]
30pub struct ApiVersion {
31    /// Major version — breaking changes.
32    pub major: u32,
33    /// Minor version — backwards-compatible additions.
34    pub minor: u32,
35    /// Patch version — backwards-compatible bug fixes.
36    pub patch: u32,
37}
38
39impl ApiVersion {
40    /// Current API version, baked from `CARGO_PKG_VERSION_*` environment variables.
41    ///
42    /// `env!()` evaluates at compile time, so the version is statically embedded.
43    ///
44    /// # Example
45    ///
46    /// ```rust
47    /// use openstranded_common_wasmcontract::ApiVersion;
48    ///
49    /// let v = ApiVersion::current();
50    /// println!("API version: {v}");
51    /// ```
52    #[must_use]
53    pub const fn current() -> Self {
54        Self {
55            major: parse_version_num!(env!("CARGO_PKG_VERSION_MAJOR")),
56            minor: parse_version_num!(env!("CARGO_PKG_VERSION_MINOR")),
57            patch: parse_version_num!(env!("CARGO_PKG_VERSION_PATCH")),
58        }
59    }
60
61    /// Check compatibility with an engine version.
62    ///
63    /// Returns `Ok(())` if major versions match.
64    /// Returns a warning string if only the minor differs.
65    /// Returns an error if major versions differ.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`VersionMismatch::Incompatible`] if `self.major != engine_version.major`.
70    /// Minor version mismatches are accepted with a logged warning
71    /// (the caller is expected to handle the log, not this function).
72    #[must_use]
73    pub fn compatible_with(&self, engine_version: &ApiVersion) -> Result<(), VersionMismatch> {
74        if self.major != engine_version.major {
75            return Err(VersionMismatch::Incompatible {
76                plugin: *self,
77                engine: *engine_version,
78            });
79        }
80        if self.minor != engine_version.minor {
81            // Minor mismatch: warn but allow loading
82        }
83        Ok(())
84    }
85}
86
87impl fmt::Display for ApiVersion {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
90    }
91}
92
93/// Version mismatch between a plugin and the engine.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub enum VersionMismatch {
96    /// Plug-in major version differs from engine — guaranteed incompatibility.
97    Incompatible {
98        plugin: ApiVersion,
99        engine: ApiVersion,
100    },
101}
102
103impl fmt::Display for VersionMismatch {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            VersionMismatch::Incompatible { plugin, engine } => {
107                write!(
108                    f,
109                    "plugin API v{plugin} is incompatible with engine API v{engine} \
110                     (major version mismatch; expected {major}, got {got})",
111                    major = engine.major,
112                    got = plugin.major,
113                )
114            }
115        }
116    }
117}
118
119// ── Tests ──────────────────────────────────────────────────────────
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_current_version_parsed() {
127        let v = ApiVersion::current();
128        // Version values are non-negative by definition; this test confirms
129        // the env macro produced a valid parseable number.
130        assert_eq!(v.major, 0);
131        assert!(v.minor < 100);
132        assert!(v.patch < 100);
133    }
134
135    #[test]
136    fn test_display() {
137        let v = ApiVersion {
138            major: 0,
139            minor: 2,
140            patch: 2,
141        };
142        assert_eq!(v.to_string(), "0.2.2");
143    }
144
145    #[test]
146    fn test_compatible_same_version() {
147        let v = ApiVersion { major: 1, minor: 0, patch: 0 };
148        assert!(v.compatible_with(&v).is_ok());
149    }
150
151    #[test]
152    fn test_compatible_minor_mismatch_still_ok() {
153        let plugin = ApiVersion { major: 1, minor: 1, patch: 0 };
154        let engine = ApiVersion { major: 1, minor: 2, patch: 0 };
155        assert!(plugin.compatible_with(&engine).is_ok());
156    }
157
158    #[test]
159    fn test_compatible_major_mismatch_error() {
160        let plugin = ApiVersion { major: 2, minor: 0, patch: 0 };
161        let engine = ApiVersion { major: 1, minor: 0, patch: 0 };
162        let err = plugin.compatible_with(&engine).unwrap_err();
163        assert!(matches!(err, VersionMismatch::Incompatible { .. }));
164    }
165
166    #[test]
167    fn test_version_mismatch_display() {
168        let mismatch = VersionMismatch::Incompatible {
169            plugin: ApiVersion { major: 2, minor: 0, patch: 0 },
170            engine: ApiVersion { major: 1, minor: 0, patch: 0 },
171        };
172        let msg = mismatch.to_string();
173        assert!(msg.contains("2.0.0"));
174        assert!(msg.contains("1.0.0"));
175    }
176}