1use crate::github::GithubClient;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum VersionComparison {
19 Newer,
21 Older,
23 Equal,
25 Incomparable,
27}
28
29#[derive(Debug, thiserror::Error)]
31pub enum VersionError {
32 #[error("invalid version format: {0}")]
34 InvalidFormat(String),
35 #[error("GitHub API error: {0}")]
37 GitHubApi(String),
38 #[error("inconsistent version data: {0}")]
40 InconsistentData(String),
41}
42
43#[derive(Debug, Deserialize)]
45struct CompareResponse {
46 status: String,
47}
48
49#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum BuildKind {
72 Test,
74 Standard,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct PullRequestInfo {
81 value: &'static str,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct BuildAttributes {
87 pub user: &'static str,
89 pub host: &'static str,
91 pub timestamp: BuildTimestamp,
93}
94
95#[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 pub fn git(&self) -> &GitVersion {
120 &self.git
121 }
122
123 pub fn pull_request(&self) -> Option<PullRequestInfo> {
125 self.pull_request
126 }
127
128 pub fn build(&self) -> &BuildAttributes {
130 &self.build
131 }
132
133 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 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 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 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 pub fn major(&self) -> u64 {
280 self.major
281 }
282
283 pub fn minor(&self) -> u64 {
285 self.minor
286 }
287
288 pub fn patch(&self) -> u64 {
290 self.patch
291 }
292
293 pub fn commits_ahead(&self) -> u64 {
295 self.commits_ahead
296 }
297
298 pub fn hash(&self) -> Option<&str> {
300 self.hash.as_deref()
301 }
302
303 pub fn is_dirty(&self) -> bool {
305 self.dirty
306 }
307
308 pub fn is_tagged_release(&self) -> bool {
310 self.commits_ahead == 0
311 }
312
313 #[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
453pub 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
485pub 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
495pub 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
535pub 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
567fn 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 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}