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
34impl ProtocolVersion {
35 /// Get the current protocol version
36 ///
37 /// Reads version information from environment variables set at compile time
38 /// by build.rs. The semantic version is always available, while git information
39 /// may be absent if the build was done without git available.
40 pub fn current() -> Self {
41 Self {
42 semantic: env!("CARGO_PKG_VERSION").to_string(),
43 git_describe: option_env!("RCP_GIT_DESCRIBE").map(String::from),
44 git_hash: option_env!("RCP_GIT_HASH").map(String::from),
45 }
46 }
47
48 /// Check if this version is compatible with another version
49 ///
50 /// Currently implements exact version matching: versions are compatible
51 /// only if their semantic versions match exactly.
52 ///
53 /// # Examples
54 ///
55 /// ```
56 /// use common::version::ProtocolVersion;
57 ///
58 /// let v1 = ProtocolVersion {
59 /// semantic: "0.22.0".to_string(),
60 /// git_describe: None,
61 /// git_hash: None,
62 /// };
63 ///
64 /// let v2 = ProtocolVersion {
65 /// semantic: "0.22.0".to_string(),
66 /// git_describe: Some("v0.21.1-7-g644da27".to_string()),
67 /// git_hash: None,
68 /// };
69 ///
70 /// assert!(v1.is_compatible_with(&v2));
71 /// ```
72 pub fn is_compatible_with(&self, other: &Self) -> bool {
73 // exact version match for now
74 // in the future, we might allow minor version skew (e.g., 0.22.x compatible with 0.22.y)
75 self.semantic == other.semantic
76 }
77
78 /// Get a human-readable version string
79 ///
80 /// Returns the semantic version, optionally including git describe information
81 /// if available.
82 ///
83 /// # Examples
84 ///
85 /// ```
86 /// use common::version::ProtocolVersion;
87 ///
88 /// let v = ProtocolVersion {
89 /// semantic: "0.22.0".to_string(),
90 /// git_describe: Some("v0.21.1-7-g644da27".to_string()),
91 /// git_hash: None,
92 /// };
93 ///
94 /// assert_eq!(v.display(), "0.22.0 (v0.21.1-7-g644da27)");
95 /// ```
96 pub fn display(&self) -> String {
97 if let Some(ref git_describe) = self.git_describe {
98 format!("{} ({})", self.semantic, git_describe)
99 } else {
100 self.semantic.clone()
101 }
102 }
103
104 /// Serialize to JSON string
105 ///
106 /// # Errors
107 ///
108 /// Returns an error if JSON serialization fails.
109 pub fn to_json(&self) -> anyhow::Result<String> {
110 serde_json::to_string(self)
111 .map_err(|e| anyhow::anyhow!("failed to serialize version: {:#}", e))
112 }
113
114 /// Deserialize from JSON string
115 ///
116 /// # Errors
117 ///
118 /// Returns an error if JSON deserialization fails or the format is invalid.
119 pub fn from_json(json: &str) -> anyhow::Result<Self> {
120 serde_json::from_str(json)
121 .map_err(|e| anyhow::anyhow!("failed to parse version JSON: {:#}", e))
122 }
123}
124
125impl std::fmt::Display for ProtocolVersion {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 write!(f, "{}", self.display())
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn test_current_version() {
137 let version = ProtocolVersion::current();
138 // semantic version should always be available
139 assert!(!version.semantic.is_empty());
140 // git info should be available when building from git repository
141 // this test will catch if build.rs is not activated in Cargo.toml
142 assert!(
143 version.git_describe.is_some(),
144 "git_describe should be populated by build.rs (check that build.rs is activated in Cargo.toml)"
145 );
146 assert!(
147 version.git_hash.is_some(),
148 "git_hash should be populated by build.rs (check that build.rs is activated in Cargo.toml)"
149 );
150 }
151
152 #[test]
153 fn test_exact_version_compatibility() {
154 let v1 = ProtocolVersion {
155 semantic: "0.22.0".to_string(),
156 git_describe: None,
157 git_hash: None,
158 };
159
160 let v2 = ProtocolVersion {
161 semantic: "0.22.0".to_string(),
162 git_describe: Some("v0.21.1-7-g644da27".to_string()),
163 git_hash: Some("644da27".to_string()),
164 };
165
166 let v3 = ProtocolVersion {
167 semantic: "0.21.0".to_string(),
168 git_describe: None,
169 git_hash: None,
170 };
171
172 // same semantic version should be compatible
173 assert!(v1.is_compatible_with(&v2));
174 assert!(v2.is_compatible_with(&v1));
175
176 // different semantic versions should not be compatible
177 assert!(!v1.is_compatible_with(&v3));
178 assert!(!v3.is_compatible_with(&v1));
179 }
180
181 #[test]
182 fn test_display() {
183 let v1 = ProtocolVersion {
184 semantic: "0.22.0".to_string(),
185 git_describe: None,
186 git_hash: None,
187 };
188 assert_eq!(v1.display(), "0.22.0");
189
190 let v2 = ProtocolVersion {
191 semantic: "0.22.0".to_string(),
192 git_describe: Some("v0.21.1-7-g644da27".to_string()),
193 git_hash: None,
194 };
195 assert_eq!(v2.display(), "0.22.0 (v0.21.1-7-g644da27)");
196 }
197
198 #[test]
199 fn test_json_serialization() {
200 let v = ProtocolVersion {
201 semantic: "0.22.0".to_string(),
202 git_describe: Some("v0.21.1-7-g644da27".to_string()),
203 git_hash: Some("644da27abc".to_string()),
204 };
205
206 let json = v.to_json().unwrap();
207 let parsed = ProtocolVersion::from_json(&json).unwrap();
208
209 assert_eq!(v, parsed);
210 }
211
212 #[test]
213 fn test_json_deserialization_without_git() {
214 let json = r#"{"semantic":"0.22.0"}"#;
215 let v = ProtocolVersion::from_json(json).unwrap();
216
217 assert_eq!(v.semantic, "0.22.0");
218 assert!(v.git_describe.is_none());
219 assert!(v.git_hash.is_none());
220 }
221}