Skip to main content

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 chrono::{DateTime, Utc};
14use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor};
15
16/// Result of comparing two versions.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum VersionComparison {
19    /// The local version is newer than the remote.
20    Newer,
21    /// The local version is older than the remote.
22    Older,
23    /// Both versions are equal.
24    Equal,
25    /// Cannot determine order (divergent branches).
26    Incomparable,
27}
28
29/// Errors that can occur during version parsing or comparison.
30#[derive(Debug, thiserror::Error)]
31pub enum VersionError {
32    /// Invalid version format.
33    #[error("invalid version format: {0}")]
34    InvalidFormat(String),
35    /// GitHub API error.
36    #[error("GitHub API error: {0}")]
37    GitHubApi(String),
38    /// Inconsistent version data (e.g., same hash but different commit counts).
39    #[error("inconsistent version data: {0}")]
40    InconsistentData(String),
41}
42
43/// Response from GitHub's compare API.
44#[derive(Debug, Deserialize)]
45struct CompareResponse {
46    status: String,
47}
48
49/// A parsed git describe version string.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct GitVersion {
52    major: u64,
53    minor: u64,
54    patch: u64,
55    commits_ahead: u64,
56    hash: Option<String>,
57    dirty: bool,
58}
59
60/// Complete compile-time version metadata for display.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Version {
63    git: GitVersion,
64    pull_request: Option<PullRequestInfo>,
65    build: BuildAttributes,
66    build_kind: BuildKind,
67}
68
69/// Build flavor captured from Cargo features.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum BuildKind {
72    /// Test build.
73    Test,
74    /// Standard build.
75    Standard,
76}
77
78/// Pull request metadata captured at build time.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct PullRequestInfo {
81    value: &'static str,
82}
83
84/// Compile-time build provenance attributes.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct BuildAttributes {
87    /// User that produced this build.
88    pub user: &'static str,
89    /// Host that produced this build.
90    pub host: &'static str,
91    /// Timestamp when this build was produced.
92    pub timestamp: BuildTimestamp,
93}
94
95/// Build timestamp parsed from Unix epoch seconds.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct BuildTimestamp {
98    raw: &'static str,
99    datetime: Option<DateTime<Utc>>,
100}
101
102impl std::fmt::Display for Version {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self.build_kind {
105            BuildKind::Test => write!(f, "Test {}", self.git)?,
106            BuildKind::Standard => write!(f, "{}", self.git)?,
107        }
108
109        if let Some(pull_request) = self.pull_request {
110            write!(f, "\n{}", pull_request)?;
111        }
112
113        write!(f, "\n{}", self.build)
114    }
115}
116
117impl Version {
118    /// Returns the git describe version.
119    pub fn git(&self) -> &GitVersion {
120        &self.git
121    }
122
123    /// Returns the pull request metadata, if present.
124    pub fn pull_request(&self) -> Option<PullRequestInfo> {
125        self.pull_request
126    }
127
128    /// Returns the build provenance attributes.
129    pub fn build(&self) -> &BuildAttributes {
130        &self.build
131    }
132
133    /// Returns the build flavor.
134    pub fn build_kind(&self) -> BuildKind {
135        self.build_kind
136    }
137}
138
139impl std::fmt::Display for PullRequestInfo {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.write_str(self.value)
142    }
143}
144
145impl PullRequestInfo {
146    /// Returns the raw pull request metadata.
147    pub fn as_str(&self) -> &'static str {
148        self.value
149    }
150}
151
152impl std::fmt::Display for BuildAttributes {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        let timestamp = self.timestamp.to_string();
155        f.write_str(&crate::fl!(
156            "build-attributes",
157            timestamp = timestamp.as_str(),
158            user = self.user,
159            host = self.host,
160        ))
161    }
162}
163
164impl std::fmt::Display for BuildTimestamp {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        if let Some(datetime) = self.datetime {
167            return write!(f, "{}", datetime.format("%Y-%m-%d %H:%M:%S %Z"));
168        }
169
170        f.write_str(self.raw)
171    }
172}
173
174impl BuildTimestamp {
175    /// Parses a build timestamp from Unix epoch seconds.
176    ///
177    /// Invalid values are preserved so the About dialog can still show the raw
178    /// build metadata instead of failing to render version information.
179    pub fn parse(raw: &'static str) -> Self {
180        Self {
181            raw,
182            datetime: raw
183                .parse::<i64>()
184                .ok()
185                .and_then(|seconds| DateTime::from_timestamp(seconds, 0)),
186        }
187    }
188}
189
190impl std::fmt::Display for GitVersion {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)?;
193
194        if self.commits_ahead > 0 {
195            if let Some(ref hash) = self.hash {
196                write!(f, "-{}-g{}", self.commits_ahead, hash)?;
197            }
198        }
199
200        if self.dirty {
201            write!(f, "-dirty")?;
202        }
203
204        Ok(())
205    }
206}
207
208impl std::str::FromStr for GitVersion {
209    type Err = VersionError;
210
211    fn from_str(s: &str) -> Result<Self, Self::Err> {
212        Self::parse(s)
213    }
214}
215
216impl GitVersion {
217    /// Parses a version string in git describe format.
218    ///
219    /// Supported formats:
220    /// - `v0.9.46` - Tagged release
221    /// - `v0.9.46-5-gabc123` - Development build with commits ahead
222    /// - `v0.9.46-5-gabc123-dirty` - Dirty working tree
223    ///
224    /// # Errors
225    ///
226    /// Returns `VersionError::InvalidFormat` if the version string cannot be parsed.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use cadmus_core::version::GitVersion;
232    ///
233    /// let v = GitVersion::parse("v0.9.46").unwrap();
234    /// assert_eq!(v.major(), 0);
235    /// assert_eq!(v.minor(), 9);
236    /// assert_eq!(v.patch(), 46);
237    /// ```
238    pub fn parse(version: &str) -> Result<Self, VersionError> {
239        let original = version.to_string();
240        let (version, dirty) = version
241            .strip_suffix("-dirty")
242            .map_or((version, false), |v| (v, true));
243
244        let parts: Vec<&str> = version.split('-').collect();
245
246        if parts.is_empty() {
247            return Err(VersionError::InvalidFormat(original));
248        }
249
250        let semver = parts[0];
251        let (major, minor, patch) = parse_semver(semver)?;
252
253        let (commits_ahead, hash) = if parts.len() == 3 {
254            let ahead = parts[1]
255                .parse::<u64>()
256                .map_err(|_| VersionError::InvalidFormat(original.clone()))?;
257            let hash = parts[2]
258                .strip_prefix('g')
259                .ok_or_else(|| VersionError::InvalidFormat(original.clone()))?
260                .to_string();
261            (ahead, Some(hash))
262        } else if parts.len() == 1 {
263            (0, None)
264        } else {
265            return Err(VersionError::InvalidFormat(original));
266        };
267
268        Ok(GitVersion {
269            major,
270            minor,
271            patch,
272            commits_ahead,
273            hash,
274            dirty,
275        })
276    }
277
278    /// Returns the major version number.
279    pub fn major(&self) -> u64 {
280        self.major
281    }
282
283    /// Returns the minor version number.
284    pub fn minor(&self) -> u64 {
285        self.minor
286    }
287
288    /// Returns the patch version number.
289    pub fn patch(&self) -> u64 {
290        self.patch
291    }
292
293    /// Returns the number of commits ahead of the tag.
294    pub fn commits_ahead(&self) -> u64 {
295        self.commits_ahead
296    }
297
298    /// Returns the git hash if present.
299    pub fn hash(&self) -> Option<&str> {
300        self.hash.as_deref()
301    }
302
303    /// Returns true if the working tree was dirty.
304    pub fn is_dirty(&self) -> bool {
305        self.dirty
306    }
307
308    /// Returns true if this is a tagged release (no commits ahead).
309    pub fn is_tagged_release(&self) -> bool {
310        self.commits_ahead == 0
311    }
312
313    /// Compares this version with another.
314    ///
315    /// If both versions contain different git hashes, this method will
316    /// use the GitHub API to check ancestry relationships.
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// use cadmus_core::version::{GitVersion, VersionComparison};
322    ///
323    /// // Local is newer than remote (higher semver)
324    /// let local: GitVersion = "v0.9.46".parse().unwrap();
325    /// let remote: GitVersion = "v0.9.45".parse().unwrap();
326    /// let result = local.compare(&remote).unwrap();
327    /// assert_eq!(result, VersionComparison::Newer);
328    ///
329    /// // Local is older than remote (lower semver)
330    /// let local: GitVersion = "v0.9.44".parse().unwrap();
331    /// let remote: GitVersion = "v0.9.45".parse().unwrap();
332    /// let result = local.compare(&remote).unwrap();
333    /// assert_eq!(result, VersionComparison::Older);
334    ///
335    /// // Local equals remote (same version)
336    /// let local: GitVersion = "v0.9.46".parse().unwrap();
337    /// let remote: GitVersion = "v0.9.46".parse().unwrap();
338    /// let result = local.compare(&remote).unwrap();
339    /// assert_eq!(result, VersionComparison::Equal);
340    /// ```
341    #[cfg_attr(
342        feature = "tracing",
343        tracing::instrument(skip(self, other), fields(local = %self, remote = %other))
344    )]
345    pub fn compare(&self, other: &GitVersion) -> Result<VersionComparison, VersionError> {
346        tracing::debug!(local = %self, remote = %other, "Comparing versions");
347
348        let semver_cmp = compare_semver(self, other);
349        if semver_cmp != std::cmp::Ordering::Equal {
350            tracing::debug!(result = ?semver_cmp, "Semver comparison determined order");
351            return Ok(match semver_cmp {
352                std::cmp::Ordering::Greater => VersionComparison::Newer,
353                std::cmp::Ordering::Less => VersionComparison::Older,
354                std::cmp::Ordering::Equal => unreachable!(),
355            });
356        }
357
358        match (
359            self.commits_ahead(),
360            other.commits_ahead(),
361            self.hash(),
362            other.hash(),
363        ) {
364            (0, 0, _, _) => {
365                tracing::debug!("Both versions are tagged releases with same semver");
366                Ok(VersionComparison::Equal)
367            }
368
369            (0, remote_ahead, _, Some(_)) => {
370                tracing::debug!(
371                    remote_ahead,
372                    "Local is tagged release, remote has commits ahead"
373                );
374                Ok(VersionComparison::Older)
375            }
376
377            (local_ahead, 0, Some(_), _) => {
378                tracing::debug!(
379                    local_ahead,
380                    "Local has commits ahead, remote is tagged release"
381                );
382                Ok(VersionComparison::Newer)
383            }
384
385            (local_ahead, remote_ahead, Some(local_hash), Some(remote_hash)) => {
386                tracing::debug!(
387                    local_ahead,
388                    remote_ahead,
389                    local_hash,
390                    remote_hash,
391                    "Both versions have commits ahead, checking ancestry"
392                );
393
394                if local_hash == remote_hash {
395                    if local_ahead != remote_ahead {
396                        return Err(VersionError::InconsistentData(format!(
397                            "same hash '{}' but different commits ahead: {} vs {}",
398                            local_hash, local_ahead, remote_ahead
399                        )));
400                    }
401                    tracing::debug!("Same hash and same commit count");
402                    return Ok(VersionComparison::Equal);
403                }
404
405                let github =
406                    GithubClient::new(None).map_err(|e| VersionError::GitHubApi(e.to_string()))?;
407                check_ancestry(&github, local_hash, remote_hash)
408            }
409
410            _ => {
411                tracing::warn!("Unexpected version comparison state");
412                Ok(VersionComparison::Incomparable)
413            }
414        }
415    }
416}
417
418impl Serialize for GitVersion {
419    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
420    where
421        S: Serializer,
422    {
423        serializer.serialize_str(&self.to_string())
424    }
425}
426
427impl<'de> Deserialize<'de> for GitVersion {
428    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
429    where
430        D: Deserializer<'de>,
431    {
432        struct GitVersionVisitor;
433
434        impl Visitor<'_> for GitVersionVisitor {
435            type Value = GitVersion;
436
437            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
438                formatter.write_str("a git version string (e.g., 'v1.2.3' or 'v1.2.3-5-gabc123')")
439            }
440
441            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
442            where
443                E: serde::de::Error,
444            {
445                GitVersion::parse(value).map_err(serde::de::Error::custom)
446            }
447        }
448
449        deserializer.deserialize_str(GitVersionVisitor)
450    }
451}
452
453/// Returns the current application version from compile-time environment.
454///
455/// On the emulator path this function panics if the version string cannot be parsed,
456/// catching build issues early during development. On the app path it logs a warning
457/// and falls back to `v0.0.0` so a bad build descriptor does not crash the device.
458pub fn get_current_version() -> GitVersion {
459    let version_str = env!("GIT_VERSION");
460
461    cfg_select! {
462        feature = "emulator" => {
463            version_str.parse().unwrap_or_else(|e| {
464                panic!("compile-time GIT_VERSION is not a valid git-describe string: {e}")
465            })
466        }
467        _ => {
468            match version_str.parse() {
469                Ok(version) => version,
470                Err(e) => {
471                    tracing::warn!(
472                        error = %e,
473                        version = version_str,
474                        "Failed to parse compile-time GIT_VERSION; falling back to v0.0.0"
475                    );
476                    "v0.0.0"
477                        .parse()
478                        .expect("v0.0.0 is always a valid version string")
479                }
480            }
481        }
482    }
483}
484
485/// Returns complete compile-time version metadata for display.
486pub fn get_version() -> Version {
487    Version {
488        git: get_current_version(),
489        pull_request: option_env!("PR_INFO").map(|value| PullRequestInfo { value }),
490        build: get_build_attributes(),
491        build_kind: get_build_kind(),
492    }
493}
494
495/// Returns compile-time build provenance attributes.
496///
497/// The timestamp is parsed from `BUILD_TIMESTAMP`, which is emitted by
498/// `build.rs` as Unix epoch seconds.
499pub fn get_build_attributes() -> BuildAttributes {
500    BuildAttributes {
501        user: env!("BUILD_USER"),
502        host: env!("BUILD_HOST"),
503        timestamp: BuildTimestamp::parse(env!("BUILD_TIMESTAMP")),
504    }
505}
506
507fn get_build_kind() -> BuildKind {
508    cfg_select! {
509        feature = "test" => { BuildKind::Test }
510        _ => { BuildKind::Standard }
511    }
512}
513
514fn parse_semver(semver: &str) -> Result<(u64, u64, u64), VersionError> {
515    let without_v = semver.strip_prefix('v').unwrap_or(semver);
516    let nums: Vec<&str> = without_v.split('.').collect();
517
518    if nums.len() != 3 {
519        return Err(VersionError::InvalidFormat(semver.to_string()));
520    }
521
522    let major = nums[0]
523        .parse::<u64>()
524        .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
525    let minor = nums[1]
526        .parse::<u64>()
527        .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
528    let patch = nums[2]
529        .parse::<u64>()
530        .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
531
532    Ok((major, minor, patch))
533}
534
535/// Compares semantic versions (major, minor, patch) between two versions.
536///
537/// Returns `Ordering::Greater` if local has a higher semantic version,
538/// `Ordering::Less` if remote has a higher semantic version,
539/// or `Ordering::Equal` if both have the same semantic version.
540///
541/// # Examples
542///
543/// ```
544/// use cadmus_core::version::{GitVersion, compare_semver};
545/// use std::cmp::Ordering;
546///
547/// let v1: GitVersion = "v0.9.46".parse().unwrap();
548/// let v2: GitVersion = "v0.9.45".parse().unwrap();
549/// assert_eq!(compare_semver(&v1, &v2), Ordering::Greater);
550///
551/// let v1: GitVersion = "v0.9.44".parse().unwrap();
552/// let v2: GitVersion = "v0.9.45".parse().unwrap();
553/// assert_eq!(compare_semver(&v1, &v2), Ordering::Less);
554///
555/// let v1: GitVersion = "v0.9.46".parse().unwrap();
556/// let v2: GitVersion = "v0.9.46".parse().unwrap();
557/// assert_eq!(compare_semver(&v1, &v2), Ordering::Equal);
558/// ```
559pub fn compare_semver(local: &GitVersion, remote: &GitVersion) -> std::cmp::Ordering {
560    local
561        .major()
562        .cmp(&remote.major())
563        .then_with(|| local.minor().cmp(&remote.minor()))
564        .then_with(|| local.patch().cmp(&remote.patch()))
565}
566
567/// Checks commit ancestry using GitHub's compare API.
568///
569/// Makes a request to GitHub's compare endpoint to determine if `local_hash`
570/// is ahead of, behind, or diverged from `remote_hash`.
571///
572/// # Arguments
573///
574/// * `github` - GitHub client for making API requests
575/// * `local_hash` - The local commit hash to compare
576/// * `remote_hash` - The remote commit hash to compare against
577///
578/// # Errors
579///
580/// Returns `VersionError::GitHubApi` if:
581/// - The HTTP request fails
582/// - GitHub returns a non-success status code
583/// - The response cannot be parsed
584fn check_ancestry(
585    github: &GithubClient,
586    local_hash: &str,
587    remote_hash: &str,
588) -> Result<VersionComparison, VersionError> {
589    let url = format!(
590        "https://api.github.com/repos/ogkevin/cadmus/compare/{}...{}",
591        remote_hash, local_hash
592    );
593
594    tracing::debug!(url = %url, "Checking commit ancestry via GitHub API");
595
596    let response = github
597        .get_unauthenticated(&url)
598        .header("Accept", "application/vnd.github+json")
599        .send()
600        .map_err(|e| {
601            tracing::error!(error = %e, "GitHub API request failed");
602            VersionError::GitHubApi(e.to_string())
603        })?;
604
605    if !response.status().is_success() {
606        let status = response.status();
607        tracing::error!(status = ?status, "GitHub API returned error");
608        return Err(VersionError::GitHubApi(format!(
609            "HTTP {}",
610            response.status()
611        )));
612    }
613
614    let compare: CompareResponse = response.json().map_err(|e| {
615        tracing::error!(error = %e, "Failed to parse GitHub response");
616        VersionError::GitHubApi(e.to_string())
617    })?;
618
619    tracing::debug!(status = %compare.status, "GitHub compare result received");
620
621    match compare.status.as_str() {
622        "ahead" => Ok(VersionComparison::Newer),
623        "behind" => Ok(VersionComparison::Older),
624        "identical" => Ok(VersionComparison::Equal),
625        "diverged" => Ok(VersionComparison::Incomparable),
626        other => {
627            tracing::warn!(status = other, "Unknown compare status from GitHub");
628            Err(VersionError::GitHubApi(format!(
629                "Unknown compare status: {}",
630                other
631            )))
632        }
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn test_version_display_includes_metadata() {
642        let version = Version {
643            git: GitVersion::parse("v0.9.46-5-gabc123").unwrap(),
644            pull_request: Some(PullRequestInfo {
645                value: "PR #12 (abc1234)",
646            }),
647            build: BuildAttributes {
648                user: "builder",
649                host: "host",
650                timestamp: BuildTimestamp::parse("1234567890"),
651            },
652            build_kind: BuildKind::Test,
653        };
654        let build_attributes = crate::fl!(
655            "build-attributes",
656            timestamp = "2009-02-13 23:31:30 UTC",
657            user = "builder",
658            host = "host",
659        );
660
661        assert_eq!(
662            version.to_string(),
663            format!("Test v0.9.46-5-gabc123\nPR #12 (abc1234)\n{build_attributes}")
664        );
665    }
666
667    #[test]
668    fn test_version_display_without_pull_request() {
669        let version = Version {
670            git: GitVersion::parse("v0.9.46").unwrap(),
671            pull_request: None,
672            build: BuildAttributes {
673                user: "builder",
674                host: "host",
675                timestamp: BuildTimestamp::parse("1234567890"),
676            },
677            build_kind: BuildKind::Standard,
678        };
679        let build_attributes = crate::fl!(
680            "build-attributes",
681            timestamp = "2009-02-13 23:31:30 UTC",
682            user = "builder",
683            host = "host",
684        );
685
686        assert_eq!(version.to_string(), format!("v0.9.46\n{build_attributes}"));
687    }
688
689    #[test]
690    fn test_build_timestamp_display_falls_back_to_raw_value() {
691        assert_eq!(BuildTimestamp::parse("unknown").to_string(), "unknown");
692    }
693
694    #[test]
695    fn test_parse_release_version() {
696        let v = GitVersion::parse("v0.9.46").unwrap();
697        assert_eq!(v.major(), 0);
698        assert_eq!(v.minor(), 9);
699        assert_eq!(v.patch(), 46);
700        assert_eq!(v.commits_ahead(), 0);
701        assert!(v.hash().is_none());
702        assert!(!v.is_dirty());
703        assert!(v.is_tagged_release());
704    }
705
706    #[test]
707    fn test_parse_development_version() {
708        let v = GitVersion::parse("v0.9.46-5-gabc123").unwrap();
709        assert_eq!(v.major(), 0);
710        assert_eq!(v.minor(), 9);
711        assert_eq!(v.patch(), 46);
712        assert_eq!(v.commits_ahead(), 5);
713        assert_eq!(v.hash(), Some("abc123"));
714        assert!(!v.is_dirty());
715        assert!(!v.is_tagged_release());
716    }
717
718    #[test]
719    fn test_parse_dirty_version() {
720        let v = GitVersion::parse("v0.9.46-5-gabc123-dirty").unwrap();
721        assert_eq!(v.major(), 0);
722        assert_eq!(v.minor(), 9);
723        assert_eq!(v.patch(), 46);
724        assert_eq!(v.commits_ahead(), 5);
725        assert_eq!(v.hash(), Some("abc123"));
726        assert!(v.is_dirty());
727    }
728
729    #[test]
730    fn test_parse_without_v_prefix() {
731        let v = GitVersion::parse("0.9.46").unwrap();
732        assert_eq!(v.major(), 0);
733        assert_eq!(v.minor(), 9);
734        assert_eq!(v.patch(), 46);
735    }
736
737    #[test]
738    fn test_parse_invalid_version() {
739        assert!(GitVersion::parse("invalid").is_err());
740        assert!(GitVersion::parse("v1.2").is_err());
741        assert!(GitVersion::parse("v1.2.3.4").is_err());
742        assert!(GitVersion::parse("v1.2.3-abc").is_err());
743    }
744
745    #[test]
746    fn test_compare_different_semver() {
747        let local1: GitVersion = "v0.9.46".parse().unwrap();
748        let remote1: GitVersion = "v0.9.45".parse().unwrap();
749        assert_eq!(local1.compare(&remote1).unwrap(), VersionComparison::Newer);
750
751        let local2: GitVersion = "v0.9.45".parse().unwrap();
752        let remote2: GitVersion = "v0.9.46".parse().unwrap();
753        assert_eq!(local2.compare(&remote2).unwrap(), VersionComparison::Older);
754
755        let local3: GitVersion = "v0.9.46".parse().unwrap();
756        let remote3: GitVersion = "v0.9.46".parse().unwrap();
757        assert_eq!(local3.compare(&remote3).unwrap(), VersionComparison::Equal);
758
759        let local4: GitVersion = "v0.10.0".parse().unwrap();
760        let remote4: GitVersion = "v0.9.46".parse().unwrap();
761        assert_eq!(local4.compare(&remote4).unwrap(), VersionComparison::Newer);
762
763        let local5: GitVersion = "v1.0.0".parse().unwrap();
764        let remote5: GitVersion = "v0.9.46".parse().unwrap();
765        assert_eq!(local5.compare(&remote5).unwrap(), VersionComparison::Newer);
766    }
767
768    #[test]
769    fn test_compare_tagged_vs_development() {
770        let local1: GitVersion = "v0.9.46".parse().unwrap();
771        let remote1: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
772        assert_eq!(local1.compare(&remote1).unwrap(), VersionComparison::Older);
773
774        let local2: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
775        let remote2: GitVersion = "v0.9.46".parse().unwrap();
776        assert_eq!(local2.compare(&remote2).unwrap(), VersionComparison::Newer);
777    }
778
779    #[test]
780    fn test_compare_same_hash_different_ahead() {
781        let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
782        let remote: GitVersion = "v0.9.46-3-gabc123".parse().unwrap();
783        let result = local.compare(&remote);
784        assert!(matches!(result, Err(VersionError::InconsistentData(_))));
785    }
786
787    #[test]
788    fn test_compare_same_hash_same_ahead() {
789        let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
790        let remote: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
791        assert_eq!(local.compare(&remote).unwrap(), VersionComparison::Equal);
792    }
793
794    #[test]
795    #[ignore = "requires network access to GitHub API"]
796    fn test_compare_different_hashes_needs_github() {
797        let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
798        let remote: GitVersion = "v0.9.46-3-gdef456".parse().unwrap();
799        // This will attempt to create a GitHub client and call the API
800        // Since abc123 and def456 are not real commits, it will fail
801        let result = local.compare(&remote);
802        assert!(result.is_err());
803    }
804
805    #[test]
806    fn test_git_version_serde_roundtrip() {
807        let versions = vec!["v0.9.46", "v0.9.46-5-gabc123", "v0.9.46-5-gabc123-dirty"];
808
809        for version_str in versions {
810            let version: GitVersion = version_str.parse().unwrap();
811            let serialized = serde_json::to_string(&version).unwrap();
812            let deserialized: GitVersion = serde_json::from_str(&serialized).unwrap();
813            assert_eq!(version, deserialized);
814            assert_eq!(serialized, format!("\"{}\"", version_str));
815        }
816    }
817
818    #[test]
819    fn test_git_version_deserialize_from_string() {
820        let json = "\"v0.9.46-5-gabc123\"";
821        let version: GitVersion = serde_json::from_str(json).unwrap();
822        assert_eq!(version.major(), 0);
823        assert_eq!(version.minor(), 9);
824        assert_eq!(version.patch(), 46);
825        assert_eq!(version.commits_ahead(), 5);
826        assert_eq!(version.hash(), Some("abc123"));
827    }
828
829    #[test]
830    #[ignore = "requires network access to GitHub API"]
831    fn test_check_ancestry_ahead() {
832        crate::crypto::init_crypto_provider();
833        let github = GithubClient::new(None).expect("client build");
834
835        let result = check_ancestry(&github, "HEAD", "v0.9.46");
836        assert!(
837            result.is_ok(),
838            "Ancestry check should succeed: {:?}",
839            result.err()
840        );
841
842        let comparison = result.unwrap();
843        assert_eq!(
844            comparison,
845            VersionComparison::Newer,
846            "HEAD should be ahead of v0.9.46"
847        );
848    }
849
850    #[test]
851    #[ignore = "requires network access to GitHub API"]
852    fn test_check_ancestry_same_commit() {
853        crate::crypto::init_crypto_provider();
854        let github = GithubClient::new(None).expect("client build");
855
856        let result = check_ancestry(&github, "HEAD", "HEAD");
857        assert!(
858            result.is_ok(),
859            "Same commit comparison should succeed: {:?}",
860            result.err()
861        );
862        assert_eq!(result.unwrap(), VersionComparison::Equal);
863    }
864}