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}