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 protocol/process-contract revision, carried INSIDE [`ProtocolVersion::semantic`] as
35/// semver build metadata (`0.38.0+w1`) rather than as a separate field — deliberately, for symmetry:
36/// the 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 old
39/// parser ignores unknown JSON fields and could accept a changed serialized or spawn contract.
40///
41/// Bump this on any wire-visible change to `remote/src/protocol` OR a version-sensitive rcpd spawn
42/// argument change whenever the crate version does not also change. The crate version alone cannot
43/// tell two 0.38.0-dev builds apart, so a stale same-version `rcpd` (on `PATH` or in the deploy
44/// cache) would otherwise pass compatibility and then fail — or misbehave — mid-copy. Revision 1
45/// covers the 0.38.0-dev `WireAcls` reshape; revision 2 the 0.39.0-dev `ExtendedMetadataCapture`
46/// gaining `root_acl_notice`; revision 3 covers the first max-files-in-flight rcpd spawn contract;
47/// revision 4 covers source-owned concurrency, its internal overrides, and readiness fields;
48/// revision 5 covers the final daemon CLI contract: removing its unreachable explicit-unlimited
49/// override and requiring a positive remote-copy connection timeout; revision 6 makes the public
50/// `--max-files-in-flight=unlimited` spelling part of the daemon spawn contract.
51pub const WIRE_REVISION: u32 = 6;
52
53impl ProtocolVersion {
54    /// Get the current protocol version
55    ///
56    /// Reads version information from environment variables set at compile time
57    /// by build.rs. The semantic version is always available, while git information
58    /// may be absent if the build was done without git available.
59    pub fn current() -> Self {
60        Self {
61            semantic: format!("{}+w{}", env!("CARGO_PKG_VERSION"), WIRE_REVISION),
62            git_describe: option_env!("RCP_GIT_DESCRIBE").map(String::from),
63            git_hash: option_env!("RCP_GIT_HASH").map(String::from),
64        }
65    }
66
67    /// The crate version alone — `semantic` with the `+w<revision>` build metadata stripped.
68    /// This is what user-facing remedies quote (`cargo install --version <this>`); the wire
69    /// revision is not a cargo version.
70    pub fn crate_version(&self) -> &str {
71        self.semantic.split('+').next().unwrap_or(&self.semantic)
72    }
73
74    /// Check if this version is compatible with another version
75    ///
76    /// Currently implements exact version matching: versions are compatible
77    /// only if their semantic versions match exactly.
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// use common::version::ProtocolVersion;
83    ///
84    /// let v1 = ProtocolVersion {
85    ///     semantic: "0.22.0".to_string(),
86    ///     git_describe: None,
87    ///     git_hash: None,
88    /// };
89    ///
90    /// let v2 = ProtocolVersion {
91    ///     semantic: "0.22.0".to_string(),
92    ///     git_describe: Some("v0.21.1-7-g644da27".to_string()),
93    ///     git_hash: None,
94    /// };
95    ///
96    /// assert!(v1.is_compatible_with(&v2));
97    /// ```
98    pub fn is_compatible_with(&self, other: &Self) -> bool {
99        // exact version match for now
100        // in the future, we might allow minor version skew (e.g., 0.22.x compatible with 0.22.y).
101        // `semantic` embeds the remote compatibility revision as build metadata (see
102        // wire revision), so this one string equality also rejects two same-release builds
103        // straddling a protocol/process-contract change — in BOTH directions, including a peer
104        // built before the revision existed.
105        self.semantic == other.semantic
106    }
107
108    /// The deployed-binary cache filename component for this version: the semantic string with
109    /// its `+` made filename-friendly (`0.38.0+w1` → `0.38.0-w1`).
110    ///
111    /// One string for BOTH the discovery probe and the deploy target, so an incompatible
112    /// same-release cached binary is never found (its filename carries the old revision) and a
113    /// fresh deploy never overwrites a binary another compatibility revision still resolves to.
114    pub fn cache_tag(&self) -> String {
115        self.semantic.replace('+', "-")
116    }
117
118    /// Get a human-readable version string
119    ///
120    /// Returns the semantic version, optionally including git describe information
121    /// if available.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use common::version::ProtocolVersion;
127    ///
128    /// let v = ProtocolVersion {
129    ///     semantic: "0.22.0".to_string(),
130    ///     git_describe: Some("v0.21.1-7-g644da27".to_string()),
131    ///     git_hash: None,
132    /// };
133    ///
134    /// assert_eq!(v.display(), "0.22.0 (v0.21.1-7-g644da27)");
135    /// ```
136    pub fn display(&self) -> String {
137        if let Some(ref git_describe) = self.git_describe {
138            format!("{} ({})", self.semantic, git_describe)
139        } else {
140            self.semantic.clone()
141        }
142    }
143
144    /// Serialize to JSON string
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if JSON serialization fails.
149    pub fn to_json(&self) -> anyhow::Result<String> {
150        serde_json::to_string(self)
151            .map_err(|e| anyhow::anyhow!("failed to serialize version: {:#}", e))
152    }
153
154    /// Deserialize from JSON string
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if JSON deserialization fails or the format is invalid.
159    pub fn from_json(json: &str) -> anyhow::Result<Self> {
160        serde_json::from_str(json)
161            .map_err(|e| anyhow::anyhow!("failed to parse version JSON: {:#}", e))
162    }
163}
164
165impl std::fmt::Display for ProtocolVersion {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        write!(f, "{}", self.display())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[cfg_attr(rcp_nix_sandbox, ignore = "Nix source build has no Git metadata")]
176    #[test]
177    fn test_current_version() {
178        let version = ProtocolVersion::current();
179        // semantic version should always be available
180        assert!(!version.semantic.is_empty());
181        // git info should be available when building from git repository
182        // this test will catch if build.rs is not activated in Cargo.toml
183        assert!(
184            version.git_describe.is_some(),
185            "git_describe should be populated by build.rs (check that build.rs is activated in Cargo.toml)"
186        );
187        assert!(
188            version.git_hash.is_some(),
189            "git_hash should be populated by build.rs (check that build.rs is activated in Cargo.toml)"
190        );
191    }
192
193    #[test]
194    fn test_exact_version_compatibility() {
195        let v1 = ProtocolVersion {
196            semantic: "0.22.0".to_string(),
197            git_describe: None,
198            git_hash: None,
199        };
200
201        let v2 = ProtocolVersion {
202            semantic: "0.22.0".to_string(),
203            git_describe: Some("v0.21.1-7-g644da27".to_string()),
204            git_hash: Some("644da27".to_string()),
205        };
206
207        let v3 = ProtocolVersion {
208            semantic: "0.21.0".to_string(),
209            git_describe: None,
210            git_hash: None,
211        };
212
213        // same semantic version should be compatible
214        assert!(v1.is_compatible_with(&v2));
215        assert!(v2.is_compatible_with(&v1));
216
217        // different semantic versions should not be compatible
218        assert!(!v1.is_compatible_with(&v3));
219        assert!(!v3.is_compatible_with(&v1));
220    }
221
222    #[test]
223    fn test_display() {
224        let v1 = ProtocolVersion {
225            semantic: "0.22.0".to_string(),
226            git_describe: None,
227            git_hash: None,
228        };
229        assert_eq!(v1.display(), "0.22.0");
230
231        let v2 = ProtocolVersion {
232            semantic: "0.22.0".to_string(),
233            git_describe: Some("v0.21.1-7-g644da27".to_string()),
234            git_hash: None,
235        };
236        assert_eq!(v2.display(), "0.22.0 (v0.21.1-7-g644da27)");
237    }
238
239    #[test]
240    fn wire_revision_mismatch_is_incompatible_in_both_directions() {
241        let a = ProtocolVersion::current();
242        assert!(a.is_compatible_with(&a.clone()));
243        assert!(
244            a.semantic.contains("+w"),
245            "the compatibility revision must ride inside `semantic`, where even a pre-revision peer's string comparison sees it"
246        );
247        let mut b = a.clone();
248        b.semantic = format!("{}+w{}", a.crate_version(), WIRE_REVISION + 1);
249        assert!(
250            !a.is_compatible_with(&b),
251            "same release with a different remote protocol/process contract must be rejected"
252        );
253        // a build predating the revision reports the bare crate version; the SAME string
254        // equality both sides have always used rejects it — symmetry is the point
255        let legacy = ProtocolVersion {
256            semantic: a.crate_version().to_string(),
257            git_describe: None,
258            git_hash: None,
259        };
260        assert!(!a.is_compatible_with(&legacy));
261        assert!(!legacy.is_compatible_with(&a));
262        assert_eq!(a.cache_tag(), a.semantic.replace('+', "-"));
263    }
264
265    #[test]
266    fn current_process_contract_rejects_revision_five_daemons() {
267        let current = ProtocolVersion::current();
268        let revision_five = ProtocolVersion {
269            semantic: format!("{}+w5", current.crate_version()),
270            git_describe: None,
271            git_hash: None,
272        };
273        assert!(!current.is_compatible_with(&revision_five));
274        assert!(!revision_five.is_compatible_with(&current));
275        assert_eq!(
276            current.cache_tag(),
277            format!("{}-w6", current.crate_version())
278        );
279        assert_ne!(current.cache_tag(), revision_five.cache_tag());
280    }
281
282    #[test]
283    fn test_json_serialization() {
284        let v = ProtocolVersion {
285            semantic: "0.22.0".to_string(),
286            git_describe: Some("v0.21.1-7-g644da27".to_string()),
287            git_hash: Some("644da27abc".to_string()),
288        };
289
290        let json = v.to_json().unwrap();
291        let parsed = ProtocolVersion::from_json(&json).unwrap();
292
293        assert_eq!(v, parsed);
294    }
295
296    #[test]
297    fn test_json_deserialization_without_git() {
298        let json = r#"{"semantic":"0.22.0"}"#;
299        let v = ProtocolVersion::from_json(json).unwrap();
300
301        assert_eq!(v.semantic, "0.22.0");
302        assert!(v.git_describe.is_none());
303        assert!(v.git_hash.is_none());
304    }
305}