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(¤t));
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}