1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum VersionComparison {
22 Newer,
24 Older,
26 Equal,
28 Incomparable,
30}
31
32#[derive(Debug, thiserror::Error)]
34pub enum VersionError {
35 #[error("invalid version format: {0}")]
37 InvalidFormat(String),
38 #[error("GitHub API error: {0}")]
40 GitHubApi(String),
41 #[error("inconsistent version data: {0}")]
43 InconsistentData(String),
44}
45
46#[derive(Debug, Deserialize)]
48struct CompareResponse {
49 status: String,
50}
51
52#[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
63impl 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum BuildKind {
126 Test,
128 Standard,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct PullRequestInfo {
135 value: &'static str,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct BuildAttributes {
141 pub user: &'static str,
143 pub host: &'static str,
145 pub timestamp: BuildTimestamp,
147}
148
149#[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 pub fn git(&self) -> &GitVersion {
174 &self.git
175 }
176
177 pub fn pull_request(&self) -> Option<PullRequestInfo> {
179 self.pull_request
180 }
181
182 pub fn build(&self) -> &BuildAttributes {
184 &self.build
185 }
186
187 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 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 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 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 pub fn major(&self) -> u64 {
334 self.major
335 }
336
337 pub fn minor(&self) -> u64 {
339 self.minor
340 }
341
342 pub fn patch(&self) -> u64 {
344 self.patch
345 }
346
347 pub fn commits_ahead(&self) -> u64 {
349 self.commits_ahead
350 }
351
352 pub fn hash(&self) -> Option<&str> {
354 self.hash.as_deref()
355 }
356
357 pub fn is_dirty(&self) -> bool {
359 self.dirty
360 }
361
362 pub fn is_tagged_release(&self) -> bool {
364 self.commits_ahead == 0
365 }
366
367 #[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
533pub 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
565pub 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
575pub 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
615pub 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
647fn 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 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}