1use crate::github::GithubClient;
13use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum VersionComparison {
18 Newer,
20 Older,
22 Equal,
24 Incomparable,
26}
27
28#[derive(Debug, thiserror::Error)]
30pub enum VersionError {
31 #[error("invalid version format: {0}")]
33 InvalidFormat(String),
34 #[error("GitHub API error: {0}")]
36 GitHubApi(String),
37 #[error("inconsistent version data: {0}")]
39 InconsistentData(String),
40}
41
42#[derive(Debug, Deserialize)]
44struct CompareResponse {
45 status: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct GitVersion {
51 major: u64,
52 minor: u64,
53 patch: u64,
54 commits_ahead: u64,
55 hash: Option<String>,
56 dirty: bool,
57}
58
59impl std::fmt::Display for GitVersion {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)?;
62
63 if self.commits_ahead > 0 {
64 if let Some(ref hash) = self.hash {
65 write!(f, "-{}-g{}", self.commits_ahead, hash)?;
66 }
67 }
68
69 if self.dirty {
70 write!(f, "-dirty")?;
71 }
72
73 Ok(())
74 }
75}
76
77impl std::str::FromStr for GitVersion {
78 type Err = VersionError;
79
80 fn from_str(s: &str) -> Result<Self, Self::Err> {
81 Self::parse(s)
82 }
83}
84
85impl GitVersion {
86 pub fn parse(version: &str) -> Result<Self, VersionError> {
108 let original = version.to_string();
109 let (version, dirty) = version
110 .strip_suffix("-dirty")
111 .map_or((version, false), |v| (v, true));
112
113 let parts: Vec<&str> = version.split('-').collect();
114
115 if parts.is_empty() {
116 return Err(VersionError::InvalidFormat(original));
117 }
118
119 let semver = parts[0];
120 let (major, minor, patch) = parse_semver(semver)?;
121
122 let (commits_ahead, hash) = if parts.len() == 3 {
123 let ahead = parts[1]
124 .parse::<u64>()
125 .map_err(|_| VersionError::InvalidFormat(original.clone()))?;
126 let hash = parts[2]
127 .strip_prefix('g')
128 .ok_or_else(|| VersionError::InvalidFormat(original.clone()))?
129 .to_string();
130 (ahead, Some(hash))
131 } else if parts.len() == 1 {
132 (0, None)
133 } else {
134 return Err(VersionError::InvalidFormat(original));
135 };
136
137 Ok(GitVersion {
138 major,
139 minor,
140 patch,
141 commits_ahead,
142 hash,
143 dirty,
144 })
145 }
146
147 pub fn major(&self) -> u64 {
149 self.major
150 }
151
152 pub fn minor(&self) -> u64 {
154 self.minor
155 }
156
157 pub fn patch(&self) -> u64 {
159 self.patch
160 }
161
162 pub fn commits_ahead(&self) -> u64 {
164 self.commits_ahead
165 }
166
167 pub fn hash(&self) -> Option<&str> {
169 self.hash.as_deref()
170 }
171
172 pub fn is_dirty(&self) -> bool {
174 self.dirty
175 }
176
177 pub fn is_tagged_release(&self) -> bool {
179 self.commits_ahead == 0
180 }
181
182 #[cfg_attr(
211 feature = "otel",
212 tracing::instrument(skip(self, other), fields(local = %self, remote = %other))
213 )]
214 pub fn compare(&self, other: &GitVersion) -> Result<VersionComparison, VersionError> {
215 tracing::debug!(local = %self, remote = %other, "Comparing versions");
216
217 let semver_cmp = compare_semver(self, other);
218 if semver_cmp != std::cmp::Ordering::Equal {
219 tracing::debug!(result = ?semver_cmp, "Semver comparison determined order");
220 return Ok(match semver_cmp {
221 std::cmp::Ordering::Greater => VersionComparison::Newer,
222 std::cmp::Ordering::Less => VersionComparison::Older,
223 std::cmp::Ordering::Equal => unreachable!(),
224 });
225 }
226
227 match (
228 self.commits_ahead(),
229 other.commits_ahead(),
230 self.hash(),
231 other.hash(),
232 ) {
233 (0, 0, _, _) => {
234 tracing::debug!("Both versions are tagged releases with same semver");
235 Ok(VersionComparison::Equal)
236 }
237
238 (0, remote_ahead, _, Some(_)) => {
239 tracing::debug!(
240 remote_ahead,
241 "Local is tagged release, remote has commits ahead"
242 );
243 Ok(VersionComparison::Older)
244 }
245
246 (local_ahead, 0, Some(_), _) => {
247 tracing::debug!(
248 local_ahead,
249 "Local has commits ahead, remote is tagged release"
250 );
251 Ok(VersionComparison::Newer)
252 }
253
254 (local_ahead, remote_ahead, Some(local_hash), Some(remote_hash)) => {
255 tracing::debug!(
256 local_ahead,
257 remote_ahead,
258 local_hash,
259 remote_hash,
260 "Both versions have commits ahead, checking ancestry"
261 );
262
263 if local_hash == remote_hash {
264 if local_ahead != remote_ahead {
265 return Err(VersionError::InconsistentData(format!(
266 "same hash '{}' but different commits ahead: {} vs {}",
267 local_hash, local_ahead, remote_ahead
268 )));
269 }
270 tracing::debug!("Same hash and same commit count");
271 return Ok(VersionComparison::Equal);
272 }
273
274 let github =
275 GithubClient::new(None).map_err(|e| VersionError::GitHubApi(e.to_string()))?;
276 check_ancestry(&github, local_hash, remote_hash)
277 }
278
279 _ => {
280 tracing::warn!("Unexpected version comparison state");
281 Ok(VersionComparison::Incomparable)
282 }
283 }
284 }
285}
286
287impl Serialize for GitVersion {
288 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
289 where
290 S: Serializer,
291 {
292 serializer.serialize_str(&self.to_string())
293 }
294}
295
296impl<'de> Deserialize<'de> for GitVersion {
297 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
298 where
299 D: Deserializer<'de>,
300 {
301 struct GitVersionVisitor;
302
303 impl Visitor<'_> for GitVersionVisitor {
304 type Value = GitVersion;
305
306 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
307 formatter.write_str("a git version string (e.g., 'v1.2.3' or 'v1.2.3-5-gabc123')")
308 }
309
310 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
311 where
312 E: serde::de::Error,
313 {
314 GitVersion::parse(value).map_err(serde::de::Error::custom)
315 }
316 }
317
318 deserializer.deserialize_str(GitVersionVisitor)
319 }
320}
321
322pub fn get_current_version() -> GitVersion {
328 let version_str = env!("GIT_VERSION");
329
330 match version_str.parse() {
331 Ok(version) => version,
332 Err(e) => {
333 #[cfg(feature = "emulator")]
334 panic!("compile-time GIT_VERSION is not a valid git-describe string: {e}");
335
336 #[cfg(not(feature = "emulator"))]
337 {
338 tracing::warn!(
339 error = %e,
340 version = version_str,
341 "Failed to parse compile-time GIT_VERSION; falling back to v0.0.0"
342 );
343 "v0.0.0"
344 .parse()
345 .expect("v0.0.0 is always a valid version string")
346 }
347 }
348 }
349}
350
351fn parse_semver(semver: &str) -> Result<(u64, u64, u64), VersionError> {
352 let without_v = semver.strip_prefix('v').unwrap_or(semver);
353 let nums: Vec<&str> = without_v.split('.').collect();
354
355 if nums.len() != 3 {
356 return Err(VersionError::InvalidFormat(semver.to_string()));
357 }
358
359 let major = nums[0]
360 .parse::<u64>()
361 .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
362 let minor = nums[1]
363 .parse::<u64>()
364 .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
365 let patch = nums[2]
366 .parse::<u64>()
367 .map_err(|_| VersionError::InvalidFormat(semver.to_string()))?;
368
369 Ok((major, minor, patch))
370}
371
372pub fn compare_semver(local: &GitVersion, remote: &GitVersion) -> std::cmp::Ordering {
397 local
398 .major()
399 .cmp(&remote.major())
400 .then_with(|| local.minor().cmp(&remote.minor()))
401 .then_with(|| local.patch().cmp(&remote.patch()))
402}
403
404fn check_ancestry(
422 github: &GithubClient,
423 local_hash: &str,
424 remote_hash: &str,
425) -> Result<VersionComparison, VersionError> {
426 let url = format!(
427 "https://api.github.com/repos/ogkevin/cadmus/compare/{}...{}",
428 remote_hash, local_hash
429 );
430
431 tracing::debug!(url = %url, "Checking commit ancestry via GitHub API");
432
433 let response = github
434 .get_unauthenticated(&url)
435 .header("Accept", "application/vnd.github+json")
436 .send()
437 .map_err(|e| {
438 tracing::error!(error = %e, "GitHub API request failed");
439 VersionError::GitHubApi(e.to_string())
440 })?;
441
442 if !response.status().is_success() {
443 let status = response.status();
444 tracing::error!(status = ?status, "GitHub API returned error");
445 return Err(VersionError::GitHubApi(format!(
446 "HTTP {}",
447 response.status()
448 )));
449 }
450
451 let compare: CompareResponse = response.json().map_err(|e| {
452 tracing::error!(error = %e, "Failed to parse GitHub response");
453 VersionError::GitHubApi(e.to_string())
454 })?;
455
456 tracing::debug!(status = %compare.status, "GitHub compare result received");
457
458 match compare.status.as_str() {
459 "ahead" => Ok(VersionComparison::Newer),
460 "behind" => Ok(VersionComparison::Older),
461 "identical" => Ok(VersionComparison::Equal),
462 "diverged" => Ok(VersionComparison::Incomparable),
463 other => {
464 tracing::warn!(status = other, "Unknown compare status from GitHub");
465 Err(VersionError::GitHubApi(format!(
466 "Unknown compare status: {}",
467 other
468 )))
469 }
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_parse_release_version() {
479 let v = GitVersion::parse("v0.9.46").unwrap();
480 assert_eq!(v.major(), 0);
481 assert_eq!(v.minor(), 9);
482 assert_eq!(v.patch(), 46);
483 assert_eq!(v.commits_ahead(), 0);
484 assert!(v.hash().is_none());
485 assert!(!v.is_dirty());
486 assert!(v.is_tagged_release());
487 }
488
489 #[test]
490 fn test_parse_development_version() {
491 let v = GitVersion::parse("v0.9.46-5-gabc123").unwrap();
492 assert_eq!(v.major(), 0);
493 assert_eq!(v.minor(), 9);
494 assert_eq!(v.patch(), 46);
495 assert_eq!(v.commits_ahead(), 5);
496 assert_eq!(v.hash(), Some("abc123"));
497 assert!(!v.is_dirty());
498 assert!(!v.is_tagged_release());
499 }
500
501 #[test]
502 fn test_parse_dirty_version() {
503 let v = GitVersion::parse("v0.9.46-5-gabc123-dirty").unwrap();
504 assert_eq!(v.major(), 0);
505 assert_eq!(v.minor(), 9);
506 assert_eq!(v.patch(), 46);
507 assert_eq!(v.commits_ahead(), 5);
508 assert_eq!(v.hash(), Some("abc123"));
509 assert!(v.is_dirty());
510 }
511
512 #[test]
513 fn test_parse_without_v_prefix() {
514 let v = GitVersion::parse("0.9.46").unwrap();
515 assert_eq!(v.major(), 0);
516 assert_eq!(v.minor(), 9);
517 assert_eq!(v.patch(), 46);
518 }
519
520 #[test]
521 fn test_parse_invalid_version() {
522 assert!(GitVersion::parse("invalid").is_err());
523 assert!(GitVersion::parse("v1.2").is_err());
524 assert!(GitVersion::parse("v1.2.3.4").is_err());
525 assert!(GitVersion::parse("v1.2.3-abc").is_err());
526 }
527
528 #[test]
529 fn test_compare_different_semver() {
530 let local1: GitVersion = "v0.9.46".parse().unwrap();
531 let remote1: GitVersion = "v0.9.45".parse().unwrap();
532 assert_eq!(local1.compare(&remote1).unwrap(), VersionComparison::Newer);
533
534 let local2: GitVersion = "v0.9.45".parse().unwrap();
535 let remote2: GitVersion = "v0.9.46".parse().unwrap();
536 assert_eq!(local2.compare(&remote2).unwrap(), VersionComparison::Older);
537
538 let local3: GitVersion = "v0.9.46".parse().unwrap();
539 let remote3: GitVersion = "v0.9.46".parse().unwrap();
540 assert_eq!(local3.compare(&remote3).unwrap(), VersionComparison::Equal);
541
542 let local4: GitVersion = "v0.10.0".parse().unwrap();
543 let remote4: GitVersion = "v0.9.46".parse().unwrap();
544 assert_eq!(local4.compare(&remote4).unwrap(), VersionComparison::Newer);
545
546 let local5: GitVersion = "v1.0.0".parse().unwrap();
547 let remote5: GitVersion = "v0.9.46".parse().unwrap();
548 assert_eq!(local5.compare(&remote5).unwrap(), VersionComparison::Newer);
549 }
550
551 #[test]
552 fn test_compare_tagged_vs_development() {
553 let local1: GitVersion = "v0.9.46".parse().unwrap();
554 let remote1: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
555 assert_eq!(local1.compare(&remote1).unwrap(), VersionComparison::Older);
556
557 let local2: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
558 let remote2: GitVersion = "v0.9.46".parse().unwrap();
559 assert_eq!(local2.compare(&remote2).unwrap(), VersionComparison::Newer);
560 }
561
562 #[test]
563 fn test_compare_same_hash_different_ahead() {
564 let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
565 let remote: GitVersion = "v0.9.46-3-gabc123".parse().unwrap();
566 let result = local.compare(&remote);
567 assert!(matches!(result, Err(VersionError::InconsistentData(_))));
568 }
569
570 #[test]
571 fn test_compare_same_hash_same_ahead() {
572 let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
573 let remote: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
574 assert_eq!(local.compare(&remote).unwrap(), VersionComparison::Equal);
575 }
576
577 #[test]
578 #[ignore = "requires network access to GitHub API"]
579 fn test_compare_different_hashes_needs_github() {
580 let local: GitVersion = "v0.9.46-5-gabc123".parse().unwrap();
581 let remote: GitVersion = "v0.9.46-3-gdef456".parse().unwrap();
582 let result = local.compare(&remote);
585 assert!(result.is_err());
586 }
587
588 #[test]
589 fn test_git_version_serde_roundtrip() {
590 let versions = vec!["v0.9.46", "v0.9.46-5-gabc123", "v0.9.46-5-gabc123-dirty"];
591
592 for version_str in versions {
593 let version: GitVersion = version_str.parse().unwrap();
594 let serialized = serde_json::to_string(&version).unwrap();
595 let deserialized: GitVersion = serde_json::from_str(&serialized).unwrap();
596 assert_eq!(version, deserialized);
597 assert_eq!(serialized, format!("\"{}\"", version_str));
598 }
599 }
600
601 #[test]
602 fn test_git_version_deserialize_from_string() {
603 let json = "\"v0.9.46-5-gabc123\"";
604 let version: GitVersion = serde_json::from_str(json).unwrap();
605 assert_eq!(version.major(), 0);
606 assert_eq!(version.minor(), 9);
607 assert_eq!(version.patch(), 46);
608 assert_eq!(version.commits_ahead(), 5);
609 assert_eq!(version.hash(), Some("abc123"));
610 }
611
612 #[test]
613 #[ignore = "requires network access to GitHub API"]
614 fn test_check_ancestry_ahead() {
615 crate::crypto::init_crypto_provider();
616 let github = GithubClient::new(None).expect("client build");
617
618 let result = check_ancestry(&github, "HEAD", "v0.9.46");
619 assert!(
620 result.is_ok(),
621 "Ancestry check should succeed: {:?}",
622 result.err()
623 );
624
625 let comparison = result.unwrap();
626 assert_eq!(
627 comparison,
628 VersionComparison::Newer,
629 "HEAD should be ahead of v0.9.46"
630 );
631 }
632
633 #[test]
634 #[ignore = "requires network access to GitHub API"]
635 fn test_check_ancestry_same_commit() {
636 crate::crypto::init_crypto_provider();
637 let github = GithubClient::new(None).expect("client build");
638
639 let result = check_ancestry(&github, "HEAD", "HEAD");
640 assert!(
641 result.is_ok(),
642 "Same commit comparison should succeed: {:?}",
643 result.err()
644 );
645 assert_eq!(result.unwrap(), VersionComparison::Equal);
646 }
647}