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