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
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}