cadmus_core/
version.rs

1//! Version comparison utility for git describe format version strings.
2//!
3//! Supports comparing versions like:
4//! - `v0.9.46` (tagged releases)
5//! - `v0.9.46-5-gabc123` (development builds with commits ahead)
6//! - `v0.9.46-5-gabc123-dirty` (dirty working tree)
7//!
8//! When versions contain different git hashes, GitHub API is used to check
9//! ancestry relationships. The API client is created internally with no
10//! authentication for public repository access.
11
12use crate::github::GithubClient;
13use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer};
14
15/// Result of comparing two versions.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum VersionComparison {
18    /// The local version is newer than the remote.
19    Newer,
20    /// The local version is older than the remote.
21    Older,
22    /// Both versions are equal.
23    Equal,
24    /// Cannot determine order (divergent branches).
25    Incomparable,
26}
27
28/// Errors that can occur during version parsing or comparison.
29#[derive(Debug, thiserror::Error)]
30pub enum VersionError {
31    /// Invalid version format.
32    #[error("invalid version format: {0}")]
33    InvalidFormat(String),
34    /// GitHub API error.
35    #[error("GitHub API error: {0}")]
36    GitHubApi(String),
37    /// Inconsistent version data (e.g., same hash but different commit counts).
38    #[error("inconsistent version data: {0}")]
39    InconsistentData(String),
40}
41
42/// Response from GitHub's compare API.
43#[derive(Debug, Deserialize)]
44struct CompareResponse {
45    status: String,
46}
47
48/// A parsed git describe version string.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct GitVersion {
51    major: u64,
52    minor: u64,
53    patch: u64,
54    commits_ahead: u64,
55    hash: Option<String>,
56    dirty: bool,
57}
58
59impl std::fmt::Display for GitVersion {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)?;
62
63        if self.commits_ahead > 0 {
64            if let Some(ref hash) = self.hash {
65                write!(f, "-{}-g{}", self.commits_ahead, hash)?;
66            }
67        }
68
69        if self.dirty {
70            write!(f, "-dirty")?;
71        }
72
73        Ok(())
74    }
75}
76
77impl std::str::FromStr for GitVersion {
78    type Err = VersionError;
79
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        Self::parse(s)
82    }
83}
84
85impl GitVersion {
86    /// Parses a version string in git describe format.
87    ///
88    /// Supported formats:
89    /// - `v0.9.46` - Tagged release
90    /// - `v0.9.46-5-gabc123` - Development build with commits ahead
91    /// - `v0.9.46-5-gabc123-dirty` - Dirty working tree
92    ///
93    /// # Errors
94    ///
95    /// Returns `VersionError::InvalidFormat` if the version string cannot be parsed.
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use cadmus_core::version::GitVersion;
101    ///
102    /// let v = GitVersion::parse("v0.9.46").unwrap();
103    /// assert_eq!(v.major(), 0);
104    /// assert_eq!(v.minor(), 9);
105    /// assert_eq!(v.patch(), 46);
106    /// ```
107    pub fn parse(version: &str) -> Result<Self, VersionError> {
108        let original = version.to_string();
109        let (version, dirty) = version
110            .strip_suffix("-dirty")
111            .map_or((version, false), |v| (v, true));
112
113        let parts: Vec<&str> = version.split('-').collect();
114
115        if parts.is_empty() {
116            return Err(VersionError::InvalidFormat(original));
117        }
118
119        let semver = parts[0];
120        let (major, minor, patch) = parse_semver(semver)?;
121
122        let (commits_ahead, hash) = if parts.len() == 3 {
123            let ahead = parts[1]
124                .parse::<u64>()
125                .map_err(|_| VersionError::InvalidFormat(original.clone()))?;
126            let hash = parts[2]
127                .strip_prefix('g')
128                .ok_or_else(|| VersionError::InvalidFormat(original.clone()))?
129                .to_string();
130            (ahead, Some(hash))
131        } else if parts.len() == 1 {
132            (0, None)
133        } else {
134            return Err(VersionError::InvalidFormat(original));
135        };
136
137        Ok(GitVersion {
138            major,
139            minor,
140            patch,
141            commits_ahead,
142            hash,
143            dirty,
144        })
145    }
146
147    /// Returns the major version number.
148    pub fn major(&self) -> u64 {
149        self.major
150    }
151
152    /// Returns the minor version number.
153    pub fn minor(&self) -> u64 {
154        self.minor
155    }
156
157    /// Returns the patch version number.
158    pub fn patch(&self) -> u64 {
159        self.patch
160    }
161
162    /// Returns the number of commits ahead of the tag.
163    pub fn commits_ahead(&self) -> u64 {
164        self.commits_ahead
165    }
166
167    /// Returns the git hash if present.
168    pub fn hash(&self) -> Option<&str> {
169        self.hash.as_deref()
170    }
171
172    /// Returns true if the working tree was dirty.
173    pub fn is_dirty(&self) -> bool {
174        self.dirty
175    }
176
177    /// Returns true if this is a tagged release (no commits ahead).
178    pub fn is_tagged_release(&self) -> bool {
179        self.commits_ahead == 0
180    }
181
182    /// Compares this version with another.
183    ///
184    /// If both versions contain different git hashes, this method will
185    /// use the GitHub API to check ancestry relationships.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use cadmus_core::version::{GitVersion, VersionComparison};
191    ///
192    /// // Local is newer than remote (higher semver)
193    /// let local: GitVersion = "v0.9.46".parse().unwrap();
194    /// let remote: GitVersion = "v0.9.45".parse().unwrap();
195    /// let result = local.compare(&remote).unwrap();
196    /// assert_eq!(result, VersionComparison::Newer);
197    ///
198    /// // Local is older than remote (lower semver)
199    /// let local: GitVersion = "v0.9.44".parse().unwrap();
200    /// let remote: GitVersion = "v0.9.45".parse().unwrap();
201    /// let result = local.compare(&remote).unwrap();
202    /// assert_eq!(result, VersionComparison::Older);
203    ///
204    /// // Local equals remote (same version)
205    /// let local: GitVersion = "v0.9.46".parse().unwrap();
206    /// let remote: GitVersion = "v0.9.46".parse().unwrap();
207    /// let result = local.compare(&remote).unwrap();
208    /// assert_eq!(result, VersionComparison::Equal);
209    /// ```
210    #[cfg_attr(
211        feature = "otel",
212        tracing::instrument(skip(self, other), fields(local = %self, remote = %other))
213    )]
214    pub fn compare(&self, other: &GitVersion) -> Result<VersionComparison, VersionError> {
215        tracing::debug!(local = %self, remote = %other, "Comparing versions");
216
217        let semver_cmp = compare_semver(self, other);
218        if semver_cmp != std::cmp::Ordering::Equal {
219            tracing::debug!(result = ?semver_cmp, "Semver comparison determined order");
220            return Ok(match semver_cmp {
221                std::cmp::Ordering::Greater => VersionComparison::Newer,
222                std::cmp::Ordering::Less => VersionComparison::Older,
223                std::cmp::Ordering::Equal => unreachable!(),
224            });
225        }
226
227        match (
228            self.commits_ahead(),
229            other.commits_ahead(),
230            self.hash(),
231            other.hash(),
232        ) {
233            (0, 0, _, _) => {
234                tracing::debug!("Both versions are tagged releases with same semver");
235                Ok(VersionComparison::Equal)
236            }
237
238            (0, remote_ahead, _, Some(_)) => {
239                tracing::debug!(
240                    remote_ahead,
241                    "Local is tagged release, remote has commits ahead"
242                );
243                Ok(VersionComparison::Older)
244            }
245
246            (local_ahead, 0, Some(_), _) => {
247                tracing::debug!(
248                    local_ahead,
249                    "Local has commits ahead, remote is tagged release"
250                );
251                Ok(VersionComparison::Newer)
252            }
253
254            (local_ahead, remote_ahead, Some(local_hash), Some(remote_hash)) => {
255                tracing::debug!(
256                    local_ahead,
257                    remote_ahead,
258                    local_hash,
259                    remote_hash,
260                    "Both versions have commits ahead, checking ancestry"
261                );
262
263                if local_hash == remote_hash {
264                    if local_ahead != remote_ahead {
265                        return Err(VersionError::InconsistentData(format!(
266                            "same hash '{}' but different commits ahead: {} vs {}",
267                            local_hash, local_ahead, remote_ahead
268                        )));
269                    }
270                    tracing::debug!("Same hash and same commit count");
271                    return Ok(VersionComparison::Equal);
272                }
273
274                let github =
275                    GithubClient::new(None).map_err(|e| VersionError::GitHubApi(e.to_string()))?;
276                check_ancestry(&github, local_hash, remote_hash)
277            }
278
279            _ => {
280                tracing::warn!("Unexpected version comparison state");
281                Ok(VersionComparison::Incomparable)
282            }
283        }
284    }
285}
286
287impl Serialize for GitVersion {
288    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
289    where
290        S: Serializer,
291    {
292        serializer.serialize_str(&self.to_string())
293    }
294}
295
296impl<'de> Deserialize<'de> for GitVersion {
297    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
298    where
299        D: Deserializer<'de>,
300    {
301        struct GitVersionVisitor;
302
303        impl Visitor<'_> for GitVersionVisitor {
304            type Value = GitVersion;
305
306            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
307                formatter.write_str("a git version string (e.g., 'v1.2.3' or 'v1.2.3-5-gabc123')")
308            }
309
310            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
311            where
312                E: serde::de::Error,
313            {
314                GitVersion::parse(value).map_err(serde::de::Error::custom)
315            }
316        }
317
318        deserializer.deserialize_str(GitVersionVisitor)
319    }
320}
321
322/// Returns the current application version from compile-time environment.
323///
324/// On the emulator path this function panics if the version string cannot be parsed,
325/// catching build issues early during development. On the app path it logs a warning
326/// and falls back to `v0.0.0` so a bad build descriptor does not crash the device.
327pub fn get_current_version() -> GitVersion {
328    let version_str = env!("GIT_VERSION");
329
330    match version_str.parse() {
331        Ok(version) => version,
332        Err(e) => {
333            #[cfg(feature = "emulator")]
334            panic!("compile-time GIT_VERSION is not a valid git-describe string: {e}");
335
336            #[cfg(not(feature = "emulator"))]
337            {
338                tracing::warn!(
339                    error = %e,
340                    version = version_str,
341                    "Failed to parse compile-time GIT_VERSION; falling back to v0.0.0"
342                );
343                "v0.0.0"
344                    .parse()
345                    .expect("v0.0.0 is always a valid version string")
346            }
347        }
348    }
349}
350
351fn parse_semver(semver: &str) -> Result<(u64, u64, u64), VersionError> {
352    let without_v = semver.strip_prefix('v').unwrap_or(semver);
353    let nums: Vec<&str> = without_v.split('.').collect();
354
355    if nums.len() != 3 {
356        return Err(VersionError::InvalidFormat(semver.to_string()));
357    }
358
359    let major = nums[0]
360        .parse::<u64>()
361        .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
362    let minor = nums[1]
363        .parse::<u64>()
364        .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
365    let patch = nums[2]
366        .parse::<u64>()
367        .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
368
369    Ok((major, minor, patch))
370}
371
372/// Compares semantic versions (major, minor, patch) between two versions.
373///
374/// Returns `Ordering::Greater` if local has a higher semantic version,
375/// `Ordering::Less` if remote has a higher semantic version,
376/// or `Ordering::Equal` if both have the same semantic version.
377///
378/// # Examples
379///
380/// ```
381/// use cadmus_core::version::{GitVersion, compare_semver};
382/// use std::cmp::Ordering;
383///
384/// let v1: GitVersion = "v0.9.46".parse().unwrap();
385/// let v2: GitVersion = "v0.9.45".parse().unwrap();
386/// assert_eq!(compare_semver(&v1, &v2), Ordering::Greater);
387///
388/// let v1: GitVersion = "v0.9.44".parse().unwrap();
389/// let v2: GitVersion = "v0.9.45".parse().unwrap();
390/// assert_eq!(compare_semver(&v1, &v2), Ordering::Less);
391///
392/// let v1: GitVersion = "v0.9.46".parse().unwrap();
393/// let v2: GitVersion = "v0.9.46".parse().unwrap();
394/// assert_eq!(compare_semver(&v1, &v2), Ordering::Equal);
395/// ```
396pub fn compare_semver(local: &GitVersion, remote: &GitVersion) -> std::cmp::Ordering {
397    local
398        .major()
399        .cmp(&remote.major())
400        .then_with(|| local.minor().cmp(&remote.minor()))
401        .then_with(|| local.patch().cmp(&remote.patch()))
402}
403
404/// Checks commit ancestry using GitHub's compare API.
405///
406/// Makes a request to GitHub's compare endpoint to determine if `local_hash`
407/// is ahead of, behind, or diverged from `remote_hash`.
408///
409/// # Arguments
410///
411/// * `github` - GitHub client for making API requests
412/// * `local_hash` - The local commit hash to compare
413/// * `remote_hash` - The remote commit hash to compare against
414///
415/// # Errors
416///
417/// Returns `VersionError::GitHubApi` if:
418/// - The HTTP request fails
419/// - GitHub returns a non-success status code
420/// - The response cannot be parsed
421fn check_ancestry(
422    github: &GithubClient,
423    local_hash: &str,
424    remote_hash: &str,
425) -> Result<VersionComparison, VersionError> {
426    let url = format!(
427        "https://api.github.com/repos/ogkevin/cadmus/compare/{}...{}",
428        remote_hash, local_hash
429    );
430
431    tracing::debug!(url = %url, "Checking commit ancestry via GitHub API");
432
433    let response = github
434        .get_unauthenticated(&url)
435        .header("Accept", "application/vnd.github+json")
436        .send()
437        .map_err(|e| {
438            tracing::error!(error = %e, "GitHub API request failed");
439            VersionError::GitHubApi(e.to_string())
440        })?;
441
442    if !response.status().is_success() {
443        let status = response.status();
444        tracing::error!(status = ?status, "GitHub API returned error");
445        return Err(VersionError::GitHubApi(format!(
446            "HTTP {}",
447            response.status()
448        )));
449    }
450
451    let compare: CompareResponse = response.json().map_err(|e| {
452        tracing::error!(error = %e, "Failed to parse GitHub response");
453        VersionError::GitHubApi(e.to_string())
454    })?;
455
456    tracing::debug!(status = %compare.status, "GitHub compare result received");
457
458    match compare.status.as_str() {
459        "ahead" => Ok(VersionComparison::Newer),
460        "behind" => Ok(VersionComparison::Older),
461        "identical" => Ok(VersionComparison::Equal),
462        "diverged" => Ok(VersionComparison::Incomparable),
463        other => {
464            tracing::warn!(status = other, "Unknown compare status from GitHub");
465            Err(VersionError::GitHubApi(format!(
466                "Unknown compare status: {}",
467                other
468            )))
469        }
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_parse_release_version() {
479        let v = GitVersion::parse("v0.9.46").unwrap();
480        assert_eq!(v.major(), 0);
481        assert_eq!(v.minor(), 9);
482        assert_eq!(v.patch(), 46);
483        assert_eq!(v.commits_ahead(), 0);
484        assert!(v.hash().is_none());
485        assert!(!v.is_dirty());
486        assert!(v.is_tagged_release());
487    }
488
489    #[test]
490    fn test_parse_development_version() {
491        let v = GitVersion::parse("v0.9.46-5-gabc123").unwrap();
492        assert_eq!(v.major(), 0);
493        assert_eq!(v.minor(), 9);
494        assert_eq!(v.patch(), 46);
495        assert_eq!(v.commits_ahead(), 5);
496        assert_eq!(v.hash(), Some("abc123"));
497        assert!(!v.is_dirty());
498        assert!(!v.is_tagged_release());
499    }
500
501    #[test]
502    fn test_parse_dirty_version() {
503        let v = GitVersion::parse("v0.9.46-5-gabc123-dirty").unwrap();
504        assert_eq!(v.major(), 0);
505        assert_eq!(v.minor(), 9);
506        assert_eq!(v.patch(), 46);
507        assert_eq!(v.commits_ahead(), 5);
508        assert_eq!(v.hash(), Some("abc123"));
509        assert!(v.is_dirty());
510    }
511
512    #[test]
513    fn test_parse_without_v_prefix() {
514        let v = GitVersion::parse("0.9.46").unwrap();
515        assert_eq!(v.major(), 0);
516        assert_eq!(v.minor(), 9);
517        assert_eq!(v.patch(), 46);
518    }
519
520    #[test]
521    fn test_parse_invalid_version() {
522        assert!(GitVersion::parse("invalid").is_err());
523        assert!(GitVersion::parse("v1.2").is_err());
524        assert!(GitVersion::parse("v1.2.3.4").is_err());
525        assert!(GitVersion::parse("v1.2.3-abc").is_err());
526    }
527
528    #[test]
529    fn test_compare_different_semver() {
530        let local1: GitVersion = "v0.9.46".parse().unwrap();
531        let remote1: GitVersion = "v0.9.45".parse().unwrap();
532        assert_eq!(local1.compare(&remote1).unwrap(), VersionComparison::Newer);
533
534        let local2: GitVersion = "v0.9.45".parse().unwrap();
535        let remote2: GitVersion = "v0.9.46".parse().unwrap();
536        assert_eq!(local2.compare(&remote2).unwrap(), VersionComparison::Older);
537
538        let local3: GitVersion = "v0.9.46".parse().unwrap();
539        let remote3: GitVersion = "v0.9.46".parse().unwrap();
540        assert_eq!(local3.compare(&remote3).unwrap(), VersionComparison::Equal);
541
542        let local4: GitVersion = "v0.10.0".parse().unwrap();
543        let remote4: GitVersion = "v0.9.46".parse().unwrap();
544        assert_eq!(local4.compare(&remote4).unwrap(), VersionComparison::Newer);
545
546        let local5: GitVersion = "v1.0.0".parse().unwrap();
547        let remote5: GitVersion = "v0.9.46".parse().unwrap();
548        assert_eq!(local5.compare(&remote5).unwrap(), VersionComparison::Newer);
549    }
550
551    #[test]
552    fn test_compare_tagged_vs_development() {
553        let local1: GitVersion = "v0.9.46".parse().unwrap();
554        let remote1: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
555        assert_eq!(local1.compare(&remote1).unwrap(), VersionComparison::Older);
556
557        let local2: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
558        let remote2: GitVersion = "v0.9.46".parse().unwrap();
559        assert_eq!(local2.compare(&remote2).unwrap(), VersionComparison::Newer);
560    }
561
562    #[test]
563    fn test_compare_same_hash_different_ahead() {
564        let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
565        let remote: GitVersion = "v0.9.46-3-gabc123".parse().unwrap();
566        let result = local.compare(&remote);
567        assert!(matches!(result, Err(VersionError::InconsistentData(_))));
568    }
569
570    #[test]
571    fn test_compare_same_hash_same_ahead() {
572        let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
573        let remote: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
574        assert_eq!(local.compare(&remote).unwrap(), VersionComparison::Equal);
575    }
576
577    #[test]
578    #[ignore = "requires network access to GitHub API"]
579    fn test_compare_different_hashes_needs_github() {
580        let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
581        let remote: GitVersion = "v0.9.46-3-gdef456".parse().unwrap();
582        // This will attempt to create a GitHub client and call the API
583        // Since abc123 and def456 are not real commits, it will fail
584        let result = local.compare(&remote);
585        assert!(result.is_err());
586    }
587
588    #[test]
589    fn test_git_version_serde_roundtrip() {
590        let versions = vec!["v0.9.46", "v0.9.46-5-gabc123", "v0.9.46-5-gabc123-dirty"];
591
592        for version_str in versions {
593            let version: GitVersion = version_str.parse().unwrap();
594            let serialized = serde_json::to_string(&version).unwrap();
595            let deserialized: GitVersion = serde_json::from_str(&serialized).unwrap();
596            assert_eq!(version, deserialized);
597            assert_eq!(serialized, format!("\"{}\"", version_str));
598        }
599    }
600
601    #[test]
602    fn test_git_version_deserialize_from_string() {
603        let json = "\"v0.9.46-5-gabc123\"";
604        let version: GitVersion = serde_json::from_str(json).unwrap();
605        assert_eq!(version.major(), 0);
606        assert_eq!(version.minor(), 9);
607        assert_eq!(version.patch(), 46);
608        assert_eq!(version.commits_ahead(), 5);
609        assert_eq!(version.hash(), Some("abc123"));
610    }
611
612    #[test]
613    #[ignore = "requires network access to GitHub API"]
614    fn test_check_ancestry_ahead() {
615        crate::crypto::init_crypto_provider();
616        let github = GithubClient::new(None).expect("client build");
617
618        let result = check_ancestry(&github, "HEAD", "v0.9.46");
619        assert!(
620            result.is_ok(),
621            "Ancestry check should succeed: {:?}",
622            result.err()
623        );
624
625        let comparison = result.unwrap();
626        assert_eq!(
627            comparison,
628            VersionComparison::Newer,
629            "HEAD should be ahead of v0.9.46"
630        );
631    }
632
633    #[test]
634    #[ignore = "requires network access to GitHub API"]
635    fn test_check_ancestry_same_commit() {
636        crate::crypto::init_crypto_provider();
637        let github = GithubClient::new(None).expect("client build");
638
639        let result = check_ancestry(&github, "HEAD", "HEAD");
640        assert!(
641            result.is_ok(),
642            "Same commit comparison should succeed: {:?}",
643            result.err()
644        );
645        assert_eq!(result.unwrap(), VersionComparison::Equal);
646    }
647}