cuenv_release/
version.rs

1//! Version calculation and bumping logic.
2//!
3//! This module provides semantic versioning support including:
4//! - Version parsing and formatting
5//! - Version bump calculation based on changesets
6//! - Pre-release and build metadata handling
7
8use crate::changeset::BumpType;
9use crate::config::ReleasePackagesConfig;
10use crate::error::{Error, Result};
11use serde::{Deserialize, Serialize};
12use std::cmp::Ordering;
13use std::collections::HashMap;
14use std::fmt;
15use std::str::FromStr;
16
17/// A semantic version following the `SemVer` 2.0.0 specification.
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct Version {
20    /// Major version number.
21    pub major: u64,
22    /// Minor version number.
23    pub minor: u64,
24    /// Patch version number.
25    pub patch: u64,
26    /// Pre-release identifier (e.g., "alpha", "beta.1").
27    pub prerelease: Option<String>,
28    /// Build metadata (e.g., "20230101", "commit.abc123").
29    pub build: Option<String>,
30}
31
32impl Version {
33    /// Create a new version.
34    #[must_use]
35    pub const fn new(major: u64, minor: u64, patch: u64) -> Self {
36        Self {
37            major,
38            minor,
39            patch,
40            prerelease: None,
41            build: None,
42        }
43    }
44
45    /// Create a version with a pre-release identifier.
46    #[must_use]
47    pub fn with_prerelease(mut self, prerelease: impl Into<String>) -> Self {
48        self.prerelease = Some(prerelease.into());
49        self
50    }
51
52    /// Create a version with build metadata.
53    #[must_use]
54    pub fn with_build(mut self, build: impl Into<String>) -> Self {
55        self.build = Some(build.into());
56        self
57    }
58
59    /// Apply a bump type to this version.
60    #[must_use]
61    pub fn bump(&self, bump_type: BumpType) -> Self {
62        match bump_type {
63            BumpType::Major => Self::new(self.major + 1, 0, 0),
64            BumpType::Minor => Self::new(self.major, self.minor + 1, 0),
65            BumpType::Patch => Self::new(self.major, self.minor, self.patch + 1),
66            BumpType::None => self.clone(),
67        }
68    }
69
70    /// Check if this is a pre-release version.
71    #[must_use]
72    pub fn is_prerelease(&self) -> bool {
73        self.prerelease.is_some()
74    }
75
76    /// Check if this is the initial development version (0.x.x).
77    #[must_use]
78    pub fn is_initial_development(&self) -> bool {
79        self.major == 0
80    }
81}
82
83impl Default for Version {
84    fn default() -> Self {
85        Self::new(0, 0, 0)
86    }
87}
88
89impl FromStr for Version {
90    type Err = Error;
91
92    fn from_str(s: &str) -> Result<Self> {
93        let s = s.trim();
94        // Remove leading 'v' if present
95        let s = s.strip_prefix('v').unwrap_or(s);
96
97        // Split off build metadata
98        let (version_pre, build) = match s.split_once('+') {
99            Some((v, b)) => (v, Some(b.to_string())),
100            None => (s, None),
101        };
102
103        // Split off prerelease
104        let (version, prerelease) = match version_pre.split_once('-') {
105            Some((v, p)) => (v, Some(p.to_string())),
106            None => (version_pre, None),
107        };
108
109        // Parse major.minor.patch
110        let parts: Vec<&str> = version.split('.').collect();
111        if parts.len() != 3 {
112            return Err(Error::invalid_version(s));
113        }
114
115        let major = parts[0]
116            .parse()
117            .map_err(|_| Error::invalid_version(format!("Invalid major version: {}", parts[0])))?;
118        let minor = parts[1]
119            .parse()
120            .map_err(|_| Error::invalid_version(format!("Invalid minor version: {}", parts[1])))?;
121        let patch = parts[2]
122            .parse()
123            .map_err(|_| Error::invalid_version(format!("Invalid patch version: {}", parts[2])))?;
124
125        Ok(Self {
126            major,
127            minor,
128            patch,
129            prerelease,
130            build,
131        })
132    }
133}
134
135impl fmt::Display for Version {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
138        if let Some(ref pre) = self.prerelease {
139            write!(f, "-{pre}")?;
140        }
141        if let Some(ref build) = self.build {
142            write!(f, "+{build}")?;
143        }
144        Ok(())
145    }
146}
147
148impl PartialOrd for Version {
149    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
150        Some(self.cmp(other))
151    }
152}
153
154impl Ord for Version {
155    fn cmp(&self, other: &Self) -> Ordering {
156        // Compare major, minor, patch
157        match self.major.cmp(&other.major) {
158            Ordering::Equal => {}
159            ord => return ord,
160        }
161        match self.minor.cmp(&other.minor) {
162            Ordering::Equal => {}
163            ord => return ord,
164        }
165        match self.patch.cmp(&other.patch) {
166            Ordering::Equal => {}
167            ord => return ord,
168        }
169
170        // Pre-release versions have lower precedence
171        match (&self.prerelease, &other.prerelease) {
172            (None, None) => Ordering::Equal,
173            (Some(_), None) => Ordering::Less,
174            (None, Some(_)) => Ordering::Greater,
175            (Some(a), Some(b)) => a.cmp(b),
176        }
177        // Build metadata is ignored in comparison
178    }
179}
180
181/// Calculator for determining new versions based on changesets.
182pub struct VersionCalculator {
183    /// Current versions of packages.
184    current_versions: HashMap<String, Version>,
185    /// Package grouping configuration.
186    packages_config: ReleasePackagesConfig,
187}
188
189impl VersionCalculator {
190    /// Create a new version calculator.
191    #[must_use]
192    pub fn new(
193        current_versions: HashMap<String, Version>,
194        packages_config: ReleasePackagesConfig,
195    ) -> Self {
196        Self {
197            current_versions,
198            packages_config,
199        }
200    }
201
202    /// Calculate new versions based on bump types.
203    ///
204    /// This applies the package grouping rules:
205    /// - Fixed groups: all packages get the same version (highest bump)
206    /// - Linked groups: all packages are bumped together
207    /// - Independent: packages are bumped individually
208    #[must_use]
209    pub fn calculate(&self, bumps: &HashMap<String, BumpType>) -> HashMap<String, Version> {
210        let mut new_versions = HashMap::new();
211        let mut processed: std::collections::HashSet<String> = std::collections::HashSet::new();
212
213        // Process each package with a bump
214        for (package, &bump) in bumps {
215            if processed.contains(package) || bump == BumpType::None {
216                continue;
217            }
218
219            // Check if in a fixed group
220            if let Some(group) = self.packages_config.get_fixed_group(package) {
221                self.process_fixed_group(group, bumps, &mut new_versions);
222                for p in group {
223                    processed.insert(p.clone());
224                }
225            }
226            // Check if in a linked group
227            else if let Some(group) = self.packages_config.get_linked_group(package) {
228                self.process_linked_group(group, bumps, &mut new_versions);
229                for p in group {
230                    processed.insert(p.clone());
231                }
232            }
233            // Independent package
234            else {
235                self.process_independent(package, bump, &mut new_versions);
236                processed.insert(package.clone());
237            }
238        }
239
240        new_versions
241    }
242
243    /// Process a fixed group (all packages get the same version).
244    fn process_fixed_group(
245        &self,
246        group: &[String],
247        bumps: &HashMap<String, BumpType>,
248        new_versions: &mut HashMap<String, Version>,
249    ) {
250        // Find the highest bump in the group
251        let max_bump = group
252            .iter()
253            .filter_map(|p| bumps.get(p))
254            .fold(BumpType::None, |acc, &b| acc.max(b));
255
256        if max_bump == BumpType::None {
257            return;
258        }
259
260        // Find the highest current version in the group
261        let max_version = group
262            .iter()
263            .filter_map(|p| self.current_versions.get(p))
264            .max()
265            .cloned()
266            .unwrap_or_default();
267
268        // Apply the bump and set for all packages
269        let new_version = max_version.bump(max_bump);
270        for package in group {
271            new_versions.insert(package.clone(), new_version.clone());
272        }
273    }
274
275    /// Process a linked group (all packages are bumped together but can have different versions).
276    fn process_linked_group(
277        &self,
278        group: &[String],
279        bumps: &HashMap<String, BumpType>,
280        new_versions: &mut HashMap<String, Version>,
281    ) {
282        // Find the highest bump in the group
283        let max_bump = group
284            .iter()
285            .filter_map(|p| bumps.get(p))
286            .fold(BumpType::None, |acc, &b| acc.max(b));
287
288        if max_bump == BumpType::None {
289            return;
290        }
291
292        // Each package is bumped by the max bump from its own current version
293        for package in group {
294            let current = self
295                .current_versions
296                .get(package)
297                .cloned()
298                .unwrap_or_default();
299            new_versions.insert(package.clone(), current.bump(max_bump));
300        }
301    }
302
303    /// Process an independent package.
304    fn process_independent(
305        &self,
306        package: &str,
307        bump: BumpType,
308        new_versions: &mut HashMap<String, Version>,
309    ) {
310        let current = self
311            .current_versions
312            .get(package)
313            .cloned()
314            .unwrap_or_default();
315        new_versions.insert(package.to_string(), current.bump(bump));
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_version_new() {
325        let v = Version::new(1, 2, 3);
326        assert_eq!(v.major, 1);
327        assert_eq!(v.minor, 2);
328        assert_eq!(v.patch, 3);
329        assert!(v.prerelease.is_none());
330        assert!(v.build.is_none());
331    }
332
333    #[test]
334    fn test_version_with_prerelease() {
335        let v = Version::new(1, 0, 0).with_prerelease("alpha.1");
336        assert_eq!(v.prerelease, Some("alpha.1".to_string()));
337    }
338
339    #[test]
340    fn test_version_with_build() {
341        let v = Version::new(1, 0, 0).with_build("commit.abc123");
342        assert_eq!(v.build, Some("commit.abc123".to_string()));
343    }
344
345    #[test]
346    fn test_version_parse() {
347        let v: Version = "1.2.3".parse().unwrap();
348        assert_eq!(v, Version::new(1, 2, 3));
349
350        let v: Version = "v1.2.3".parse().unwrap();
351        assert_eq!(v, Version::new(1, 2, 3));
352
353        let v: Version = "1.2.3-beta.1".parse().unwrap();
354        assert_eq!(v.major, 1);
355        assert_eq!(v.prerelease, Some("beta.1".to_string()));
356
357        let v: Version = "1.2.3+build.123".parse().unwrap();
358        assert_eq!(v.build, Some("build.123".to_string()));
359
360        let v: Version = "1.2.3-rc.1+build.456".parse().unwrap();
361        assert_eq!(v.prerelease, Some("rc.1".to_string()));
362        assert_eq!(v.build, Some("build.456".to_string()));
363    }
364
365    #[test]
366    fn test_version_parse_invalid() {
367        assert!("1.2".parse::<Version>().is_err());
368        assert!("1.2.3.4".parse::<Version>().is_err());
369        assert!("a.b.c".parse::<Version>().is_err());
370    }
371
372    #[test]
373    fn test_version_display() {
374        assert_eq!(Version::new(1, 2, 3).to_string(), "1.2.3");
375        assert_eq!(
376            Version::new(1, 2, 3).with_prerelease("alpha").to_string(),
377            "1.2.3-alpha"
378        );
379        assert_eq!(
380            Version::new(1, 2, 3).with_build("123").to_string(),
381            "1.2.3+123"
382        );
383        assert_eq!(
384            Version::new(1, 2, 3)
385                .with_prerelease("beta")
386                .with_build("456")
387                .to_string(),
388            "1.2.3-beta+456"
389        );
390    }
391
392    #[test]
393    fn test_version_bump() {
394        let v = Version::new(1, 2, 3);
395        assert_eq!(v.bump(BumpType::Patch), Version::new(1, 2, 4));
396        assert_eq!(v.bump(BumpType::Minor), Version::new(1, 3, 0));
397        assert_eq!(v.bump(BumpType::Major), Version::new(2, 0, 0));
398        assert_eq!(v.bump(BumpType::None), Version::new(1, 2, 3));
399    }
400
401    #[test]
402    fn test_version_ordering() {
403        assert!(Version::new(2, 0, 0) > Version::new(1, 0, 0));
404        assert!(Version::new(1, 1, 0) > Version::new(1, 0, 0));
405        assert!(Version::new(1, 0, 1) > Version::new(1, 0, 0));
406
407        // Pre-release has lower precedence
408        assert!(Version::new(1, 0, 0) > Version::new(1, 0, 0).with_prerelease("alpha"));
409    }
410
411    #[test]
412    fn test_version_is_prerelease() {
413        assert!(!Version::new(1, 0, 0).is_prerelease());
414        assert!(
415            Version::new(1, 0, 0)
416                .with_prerelease("alpha")
417                .is_prerelease()
418        );
419    }
420
421    #[test]
422    fn test_version_is_initial_development() {
423        assert!(Version::new(0, 1, 0).is_initial_development());
424        assert!(!Version::new(1, 0, 0).is_initial_development());
425    }
426
427    #[test]
428    fn test_version_calculator_independent() {
429        let current = HashMap::from([
430            ("pkg-a".to_string(), Version::new(1, 0, 0)),
431            ("pkg-b".to_string(), Version::new(2, 0, 0)),
432        ]);
433        let config = ReleasePackagesConfig::default();
434        let calc = VersionCalculator::new(current, config);
435
436        let bumps = HashMap::from([
437            ("pkg-a".to_string(), BumpType::Minor),
438            ("pkg-b".to_string(), BumpType::Patch),
439        ]);
440
441        let new_versions = calc.calculate(&bumps);
442        assert_eq!(new_versions.get("pkg-a"), Some(&Version::new(1, 1, 0)));
443        assert_eq!(new_versions.get("pkg-b"), Some(&Version::new(2, 0, 1)));
444    }
445
446    #[test]
447    fn test_version_calculator_fixed_group() {
448        let current = HashMap::from([
449            ("pkg-a".to_string(), Version::new(1, 0, 0)),
450            ("pkg-b".to_string(), Version::new(1, 0, 0)),
451        ]);
452        let config = ReleasePackagesConfig {
453            fixed: vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]],
454            linked: vec![],
455        };
456        let calc = VersionCalculator::new(current, config);
457
458        // Only pkg-a has a bump, but both should be updated
459        let bumps = HashMap::from([("pkg-a".to_string(), BumpType::Minor)]);
460
461        let new_versions = calc.calculate(&bumps);
462        assert_eq!(new_versions.get("pkg-a"), Some(&Version::new(1, 1, 0)));
463        assert_eq!(new_versions.get("pkg-b"), Some(&Version::new(1, 1, 0)));
464    }
465
466    #[test]
467    fn test_version_calculator_fixed_group_max_bump() {
468        let current = HashMap::from([
469            ("pkg-a".to_string(), Version::new(1, 0, 0)),
470            ("pkg-b".to_string(), Version::new(1, 0, 0)),
471        ]);
472        let config = ReleasePackagesConfig {
473            fixed: vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]],
474            linked: vec![],
475        };
476        let calc = VersionCalculator::new(current, config);
477
478        // Different bumps - should use the highest
479        let bumps = HashMap::from([
480            ("pkg-a".to_string(), BumpType::Patch),
481            ("pkg-b".to_string(), BumpType::Minor),
482        ]);
483
484        let new_versions = calc.calculate(&bumps);
485        // Both get Minor bump (the higher one)
486        assert_eq!(new_versions.get("pkg-a"), Some(&Version::new(1, 1, 0)));
487        assert_eq!(new_versions.get("pkg-b"), Some(&Version::new(1, 1, 0)));
488    }
489
490    #[test]
491    fn test_version_calculator_linked_group() {
492        let current = HashMap::from([
493            ("pkg-a".to_string(), Version::new(1, 0, 0)),
494            ("pkg-b".to_string(), Version::new(2, 0, 0)),
495        ]);
496        let config = ReleasePackagesConfig {
497            fixed: vec![],
498            linked: vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]],
499        };
500        let calc = VersionCalculator::new(current, config);
501
502        let bumps = HashMap::from([("pkg-a".to_string(), BumpType::Minor)]);
503
504        let new_versions = calc.calculate(&bumps);
505        // Both are bumped by minor, but from their own versions
506        assert_eq!(new_versions.get("pkg-a"), Some(&Version::new(1, 1, 0)));
507        assert_eq!(new_versions.get("pkg-b"), Some(&Version::new(2, 1, 0)));
508    }
509}