Skip to main content

common/
version.rs

1// version information for protocol compatibility checking
2
3use serde::{Deserialize, Serialize};
4
5/// Protocol version information
6///
7/// Contains version information for compatibility checking between rcp and rcpd.
8/// The semantic version is used for compatibility checks, while git information
9/// provides additional debugging context.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct ProtocolVersion {
12    /// Semantic version from Cargo.toml (e.g., "0.22.0")
13    ///
14    /// This is the primary version used for compatibility checking.
15    pub semantic: String,
16
17    /// Git describe output (e.g., "v0.21.1-7-g644da27")
18    ///
19    /// Optional. Provides detailed version information including:
20    /// - Most recent tag
21    /// - Number of commits since tag
22    /// - Short commit hash
23    /// - "-dirty" suffix if working tree has uncommitted changes
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub git_describe: Option<String>,
26
27    /// Full git commit hash
28    ///
29    /// Optional. Useful for exact build identification and debugging.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub git_hash: Option<String>,
32}
33
34/// The remote wire-schema revision, carried INSIDE [`ProtocolVersion::semantic`] as semver build
35/// metadata (`0.38.0+w1`) rather than as a separate field — deliberately, for symmetry: the
36/// compatibility check is a string equality on `semantic`, so a build predating this constant
37/// rejects a current one exactly as a current build rejects it ("0.38.0" != "0.38.0+w1"), no
38/// matter which side was upgraded first. A separate field would protect only the new side — an
39/// old parser ignores unknown JSON fields and would accept the changed schema.
40///
41/// Bump this on ANY wire-visible change to `remote/src/protocol` — a message added, removed, or
42/// reshaped; an enum variant added; a field's meaning changed — whenever the crate version does
43/// not also change. The crate version alone cannot tell two 0.38.0-dev builds apart, so a stale
44/// same-version `rcpd` (on `PATH` or in the deploy cache) would otherwise pass the compatibility
45/// check and then fail — or misbehave — mid-copy when the schemas diverge. Revision 1 covers the
46/// 0.38.0-dev `WireAcls` reshape.
47pub const WIRE_REVISION: u32 = 1;
48
49impl ProtocolVersion {
50    /// Get the current protocol version
51    ///
52    /// Reads version information from environment variables set at compile time
53    /// by build.rs. The semantic version is always available, while git information
54    /// may be absent if the build was done without git available.
55    pub fn current() -> Self {
56        Self {
57            semantic: format!("{}+w{}", env!("CARGO_PKG_VERSION"), WIRE_REVISION),
58            git_describe: option_env!("RCP_GIT_DESCRIBE").map(String::from),
59            git_hash: option_env!("RCP_GIT_HASH").map(String::from),
60        }
61    }
62
63    /// The crate version alone — `semantic` with the `+w<revision>` build metadata stripped.
64    /// This is what user-facing remedies quote (`cargo install --version <this>`); the wire
65    /// revision is not a cargo version.
66    pub fn crate_version(&self) -> &str {
67        self.semantic.split('+').next().unwrap_or(&self.semantic)
68    }
69
70    /// Check if this version is compatible with another version
71    ///
72    /// Currently implements exact version matching: versions are compatible
73    /// only if their semantic versions match exactly.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// use common::version::ProtocolVersion;
79    ///
80    /// let v1 = ProtocolVersion {
81    ///     semantic: "0.22.0".to_string(),
82    ///     git_describe: None,
83    ///     git_hash: None,
84    /// };
85    ///
86    /// let v2 = ProtocolVersion {
87    ///     semantic: "0.22.0".to_string(),
88    ///     git_describe: Some("v0.21.1-7-g644da27".to_string()),
89    ///     git_hash: None,
90    /// };
91    ///
92    /// assert!(v1.is_compatible_with(&v2));
93    /// ```
94    pub fn is_compatible_with(&self, other: &Self) -> bool {
95        // exact version match for now
96        // in the future, we might allow minor version skew (e.g., 0.22.x compatible with 0.22.y).
97        // `semantic` embeds the wire revision as build metadata (see WIRE_REVISION), so this one
98        // string equality also rejects two same-release builds straddling a wire change — in BOTH
99        // directions, including a peer built before the revision existed.
100        self.semantic == other.semantic
101    }
102
103    /// The deployed-binary cache filename component for this version: the semantic string with
104    /// its `+` made filename-friendly (`0.38.0+w1` → `0.38.0-w1`).
105    ///
106    /// One string for BOTH the discovery probe and the deploy target, so an incompatible
107    /// same-release cached binary is never found (its filename carries the old revision) and a
108    /// fresh deploy never overwrites a binary another wire revision still resolves to.
109    pub fn cache_tag(&self) -> String {
110        self.semantic.replace('+', "-")
111    }
112
113    /// Get a human-readable version string
114    ///
115    /// Returns the semantic version, optionally including git describe information
116    /// if available.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use common::version::ProtocolVersion;
122    ///
123    /// let v = ProtocolVersion {
124    ///     semantic: "0.22.0".to_string(),
125    ///     git_describe: Some("v0.21.1-7-g644da27".to_string()),
126    ///     git_hash: None,
127    /// };
128    ///
129    /// assert_eq!(v.display(), "0.22.0 (v0.21.1-7-g644da27)");
130    /// ```
131    pub fn display(&self) -> String {
132        if let Some(ref git_describe) = self.git_describe {
133            format!("{} ({})", self.semantic, git_describe)
134        } else {
135            self.semantic.clone()
136        }
137    }
138
139    /// Serialize to JSON string
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if JSON serialization fails.
144    pub fn to_json(&self) -> anyhow::Result<String> {
145        serde_json::to_string(self)
146            .map_err(|e| anyhow::anyhow!("failed to serialize version: {:#}", e))
147    }
148
149    /// Deserialize from JSON string
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if JSON deserialization fails or the format is invalid.
154    pub fn from_json(json: &str) -> anyhow::Result<Self> {
155        serde_json::from_str(json)
156            .map_err(|e| anyhow::anyhow!("failed to parse version JSON: {:#}", e))
157    }
158}
159
160impl std::fmt::Display for ProtocolVersion {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(f, "{}", self.display())
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_current_version() {
172        let version = ProtocolVersion::current();
173        // semantic version should always be available
174        assert!(!version.semantic.is_empty());
175        // git info should be available when building from git repository
176        // this test will catch if build.rs is not activated in Cargo.toml
177        assert!(
178            version.git_describe.is_some(),
179            "git_describe should be populated by build.rs (check that build.rs is activated in Cargo.toml)"
180        );
181        assert!(
182            version.git_hash.is_some(),
183            "git_hash should be populated by build.rs (check that build.rs is activated in Cargo.toml)"
184        );
185    }
186
187    #[test]
188    fn test_exact_version_compatibility() {
189        let v1 = ProtocolVersion {
190            semantic: "0.22.0".to_string(),
191            git_describe: None,
192            git_hash: None,
193        };
194
195        let v2 = ProtocolVersion {
196            semantic: "0.22.0".to_string(),
197            git_describe: Some("v0.21.1-7-g644da27".to_string()),
198            git_hash: Some("644da27".to_string()),
199        };
200
201        let v3 = ProtocolVersion {
202            semantic: "0.21.0".to_string(),
203            git_describe: None,
204            git_hash: None,
205        };
206
207        // same semantic version should be compatible
208        assert!(v1.is_compatible_with(&v2));
209        assert!(v2.is_compatible_with(&v1));
210
211        // different semantic versions should not be compatible
212        assert!(!v1.is_compatible_with(&v3));
213        assert!(!v3.is_compatible_with(&v1));
214    }
215
216    #[test]
217    fn test_display() {
218        let v1 = ProtocolVersion {
219            semantic: "0.22.0".to_string(),
220            git_describe: None,
221            git_hash: None,
222        };
223        assert_eq!(v1.display(), "0.22.0");
224
225        let v2 = ProtocolVersion {
226            semantic: "0.22.0".to_string(),
227            git_describe: Some("v0.21.1-7-g644da27".to_string()),
228            git_hash: None,
229        };
230        assert_eq!(v2.display(), "0.22.0 (v0.21.1-7-g644da27)");
231    }
232
233    #[test]
234    fn wire_revision_mismatch_is_incompatible_in_both_directions() {
235        let a = ProtocolVersion::current();
236        assert!(a.is_compatible_with(&a.clone()));
237        assert!(
238            a.semantic.contains("+w"),
239            "the wire revision must ride inside `semantic`, where even a pre-revision peer's              string comparison sees it"
240        );
241        let mut b = a.clone();
242        b.semantic = format!("{}+w{}", a.crate_version(), WIRE_REVISION + 1);
243        assert!(
244            !a.is_compatible_with(&b),
245            "same release with a different wire schema must be rejected"
246        );
247        // a build predating the revision reports the bare crate version; the SAME string
248        // equality both sides have always used rejects it — symmetry is the point
249        let legacy = ProtocolVersion {
250            semantic: a.crate_version().to_string(),
251            git_describe: None,
252            git_hash: None,
253        };
254        assert!(!a.is_compatible_with(&legacy));
255        assert!(!legacy.is_compatible_with(&a));
256        assert_eq!(a.cache_tag(), a.semantic.replace('+', "-"));
257    }
258
259    #[test]
260    fn test_json_serialization() {
261        let v = ProtocolVersion {
262            semantic: "0.22.0".to_string(),
263            git_describe: Some("v0.21.1-7-g644da27".to_string()),
264            git_hash: Some("644da27abc".to_string()),
265        };
266
267        let json = v.to_json().unwrap();
268        let parsed = ProtocolVersion::from_json(&json).unwrap();
269
270        assert_eq!(v, parsed);
271    }
272
273    #[test]
274    fn test_json_deserialization_without_git() {
275        let json = r#"{"semantic":"0.22.0"}"#;
276        let v = ProtocolVersion::from_json(json).unwrap();
277
278        assert_eq!(v.semantic, "0.22.0");
279        assert!(v.git_describe.is_none());
280        assert!(v.git_hash.is_none());
281    }
282}