Skip to main content

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 const 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 const fn is_initial_development(&self) -> bool {
79        self.major == 0
80    }
81
82    /// Get adjusted bump type for pre-1.0 versions.
83    ///
84    /// In semver, 0.x.x versions are considered "initial development" where
85    /// the public API is not stable. Breaking changes in 0.x.x are conventionally
86    /// treated as minor bumps (0.1.0 → 0.2.0) rather than major bumps.
87    ///
88    /// This method remaps `BumpType::Major` to `BumpType::Minor` for pre-1.0 versions.
89    #[must_use]
90    pub fn adjusted_bump_type(&self, bump: BumpType) -> BumpType {
91        if self.is_initial_development() && bump == BumpType::Major {
92            BumpType::Minor
93        } else {
94            bump
95        }
96    }
97}
98
99impl Default for Version {
100    fn default() -> Self {
101        Self::new(0, 0, 0)
102    }
103}
104
105impl FromStr for Version {
106    type Err = Error;
107
108    fn from_str(s: &str) -> Result<Self> {
109        let s = s.trim();
110        // Remove leading 'v' if present
111        let s = s.strip_prefix('v').unwrap_or(s);
112
113        // Split off build metadata
114        let (version_pre, build) = match s.split_once('+') {
115            Some((v, b)) => (v, Some(b.to_string())),
116            None => (s, None),
117        };
118
119        // Split off prerelease
120        let (version, prerelease) = match version_pre.split_once('-') {
121            Some((v, p)) => (v, Some(p.to_string())),
122            None => (version_pre, None),
123        };
124
125        // Parse major.minor.patch
126        let parts: Vec<&str> = version.split('.').collect();
127        if parts.len() != 3 {
128            return Err(Error::invalid_version(s));
129        }
130
131        let major = parts[0]
132            .parse()
133            .map_err(|_| Error::invalid_version(format!("Invalid major version: {}", parts[0])))?;
134        let minor = parts[1]
135            .parse()
136            .map_err(|_| Error::invalid_version(format!("Invalid minor version: {}", parts[1])))?;
137        let patch = parts[2]
138            .parse()
139            .map_err(|_| Error::invalid_version(format!("Invalid patch version: {}", parts[2])))?;
140
141        Ok(Self {
142            major,
143            minor,
144            patch,
145            prerelease,
146            build,
147        })
148    }
149}
150
151impl fmt::Display for Version {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
154        if let Some(ref pre) = self.prerelease {
155            write!(f, "-{pre}")?;
156        }
157        if let Some(ref build) = self.build {
158            write!(f, "+{build}")?;
159        }
160        Ok(())
161    }
162}
163
164impl PartialOrd for Version {
165    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
166        Some(self.cmp(other))
167    }
168}
169
170impl Ord for Version {
171    fn cmp(&self, other: &Self) -> Ordering {
172        // Compare major, minor, patch
173        match self.major.cmp(&other.major) {
174            Ordering::Equal => {}
175            ord => return ord,
176        }
177        match self.minor.cmp(&other.minor) {
178            Ordering::Equal => {}
179            ord => return ord,
180        }
181        match self.patch.cmp(&other.patch) {
182            Ordering::Equal => {}
183            ord => return ord,
184        }
185
186        // Pre-release versions have lower precedence
187        match (&self.prerelease, &other.prerelease) {
188            (None, None) => Ordering::Equal,
189            (Some(_), None) => Ordering::Less,
190            (None, Some(_)) => Ordering::Greater,
191            (Some(a), Some(b)) => a.cmp(b),
192        }
193        // Build metadata is ignored in comparison
194    }
195}
196
197/// Calculator for determining new versions based on changesets.
198pub struct VersionCalculator {
199    /// Current versions of packages.
200    current_versions: HashMap<String, Version>,
201    /// Package grouping configuration.
202    packages_config: ReleasePackagesConfig,
203}
204
205impl VersionCalculator {
206    /// Create a new version calculator.
207    #[must_use]
208    pub const fn new(
209        current_versions: HashMap<String, Version>,
210        packages_config: ReleasePackagesConfig,
211    ) -> Self {
212        Self {
213            current_versions,
214            packages_config,
215        }
216    }
217
218    /// Calculate new versions based on bump types.
219    ///
220    /// This applies the package grouping rules:
221    /// - Fixed groups: all packages get the same version (highest bump)
222    /// - Linked groups: all packages are bumped together
223    /// - Independent: packages are bumped individually
224    #[must_use]
225    pub fn calculate(&self, bumps: &HashMap<String, BumpType>) -> HashMap<String, Version> {
226        let mut new_versions = HashMap::new();
227        let mut processed: std::collections::HashSet<String> = std::collections::HashSet::new();
228
229        // Process each package with a bump
230        for (package, &bump) in bumps {
231            if processed.contains(package) || bump == BumpType::None {
232                continue;
233            }
234
235            // Check if in a fixed group
236            if let Some(group) = self.packages_config.get_fixed_group(package) {
237                self.process_fixed_group(group, bumps, &mut new_versions);
238                for p in group {
239                    processed.insert(p.clone());
240                }
241            }
242            // Check if in a linked group
243            else if let Some(group) = self.packages_config.get_linked_group(package) {
244                self.process_linked_group(group, bumps, &mut new_versions);
245                for p in group {
246                    processed.insert(p.clone());
247                }
248            }
249            // Independent package
250            else {
251                self.process_independent(package, bump, &mut new_versions);
252                processed.insert(package.clone());
253            }
254        }
255
256        new_versions
257    }
258
259    /// Process a fixed group (all packages get the same version).
260    fn process_fixed_group(
261        &self,
262        group: &[String],
263        bumps: &HashMap<String, BumpType>,
264        new_versions: &mut HashMap<String, Version>,
265    ) {
266        // Find the highest bump in the group
267        let max_bump = group
268            .iter()
269            .filter_map(|p| bumps.get(p))
270            .fold(BumpType::None, |acc, &b| acc.max(b));
271
272        if max_bump == BumpType::None {
273            return;
274        }
275
276        // Find the highest current version in the group
277        let max_version = group
278            .iter()
279            .filter_map(|p| self.current_versions.get(p))
280            .max()
281            .cloned()
282            .unwrap_or_default();
283
284        // Apply the bump and set for all packages
285        let new_version = max_version.bump(max_bump);
286        for package in group {
287            new_versions.insert(package.clone(), new_version.clone());
288        }
289    }
290
291    /// Process a linked group (all packages are bumped together but can have different versions).
292    fn process_linked_group(
293        &self,
294        group: &[String],
295        bumps: &HashMap<String, BumpType>,
296        new_versions: &mut HashMap<String, Version>,
297    ) {
298        // Find the highest bump in the group
299        let max_bump = group
300            .iter()
301            .filter_map(|p| bumps.get(p))
302            .fold(BumpType::None, |acc, &b| acc.max(b));
303
304        if max_bump == BumpType::None {
305            return;
306        }
307
308        // Each package is bumped by the max bump from its own current version
309        for package in group {
310            let current = self
311                .current_versions
312                .get(package)
313                .cloned()
314                .unwrap_or_default();
315            new_versions.insert(package.clone(), current.bump(max_bump));
316        }
317    }
318
319    /// Process an independent package.
320    fn process_independent(
321        &self,
322        package: &str,
323        bump: BumpType,
324        new_versions: &mut HashMap<String, Version>,
325    ) {
326        let current = self
327            .current_versions
328            .get(package)
329            .cloned()
330            .unwrap_or_default();
331        new_versions.insert(package.to_string(), current.bump(bump));
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_version_new() {
341        let v = Version::new(1, 2, 3);
342        assert_eq!(v.major, 1);
343        assert_eq!(v.minor, 2);
344        assert_eq!(v.patch, 3);
345        assert!(v.prerelease.is_none());
346        assert!(v.build.is_none());
347    }
348
349    #[test]
350    fn test_version_with_prerelease() {
351        let v = Version::new(1, 0, 0).with_prerelease("alpha.1");
352        assert_eq!(v.prerelease, Some("alpha.1".to_string()));
353    }
354
355    #[test]
356    fn test_version_with_build() {
357        let v = Version::new(1, 0, 0).with_build("commit.abc123");
358        assert_eq!(v.build, Some("commit.abc123".to_string()));
359    }
360
361    #[test]
362    fn test_version_parse() {
363        let v: Version = "1.2.3".parse().unwrap();
364        assert_eq!(v, Version::new(1, 2, 3));
365
366        let v: Version = "v1.2.3".parse().unwrap();
367        assert_eq!(v, Version::new(1, 2, 3));
368
369        let v: Version = "1.2.3-beta.1".parse().unwrap();
370        assert_eq!(v.major, 1);
371        assert_eq!(v.prerelease, Some("beta.1".to_string()));
372
373        let v: Version = "1.2.3+build.123".parse().unwrap();
374        assert_eq!(v.build, Some("build.123".to_string()));
375
376        let v: Version = "1.2.3-rc.1+build.456".parse().unwrap();
377        assert_eq!(v.prerelease, Some("rc.1".to_string()));
378        assert_eq!(v.build, Some("build.456".to_string()));
379    }
380
381    #[test]
382    fn test_version_parse_invalid() {
383        assert!("1.2".parse::<Version>().is_err());
384        assert!("1.2.3.4".parse::<Version>().is_err());
385        assert!("a.b.c".parse::<Version>().is_err());
386    }
387
388    #[test]
389    fn test_version_display() {
390        assert_eq!(Version::new(1, 2, 3).to_string(), "1.2.3");
391        assert_eq!(
392            Version::new(1, 2, 3).with_prerelease("alpha").to_string(),
393            "1.2.3-alpha"
394        );
395        assert_eq!(
396            Version::new(1, 2, 3).with_build("123").to_string(),
397            "1.2.3+123"
398        );
399        assert_eq!(
400            Version::new(1, 2, 3)
401                .with_prerelease("beta")
402                .with_build("456")
403                .to_string(),
404            "1.2.3-beta+456"
405        );
406    }
407
408    #[test]
409    fn test_version_bump() {
410        let v = Version::new(1, 2, 3);
411        assert_eq!(v.bump(BumpType::Patch), Version::new(1, 2, 4));
412        assert_eq!(v.bump(BumpType::Minor), Version::new(1, 3, 0));
413        assert_eq!(v.bump(BumpType::Major), Version::new(2, 0, 0));
414        assert_eq!(v.bump(BumpType::None), Version::new(1, 2, 3));
415    }
416
417    #[test]
418    fn test_version_ordering() {
419        assert!(Version::new(2, 0, 0) > Version::new(1, 0, 0));
420        assert!(Version::new(1, 1, 0) > Version::new(1, 0, 0));
421        assert!(Version::new(1, 0, 1) > Version::new(1, 0, 0));
422
423        // Pre-release has lower precedence
424        assert!(Version::new(1, 0, 0) > Version::new(1, 0, 0).with_prerelease("alpha"));
425    }
426
427    #[test]
428    fn test_version_is_prerelease() {
429        assert!(!Version::new(1, 0, 0).is_prerelease());
430        assert!(
431            Version::new(1, 0, 0)
432                .with_prerelease("alpha")
433                .is_prerelease()
434        );
435    }
436
437    #[test]
438    fn test_version_is_initial_development() {
439        assert!(Version::new(0, 1, 0).is_initial_development());
440        assert!(!Version::new(1, 0, 0).is_initial_development());
441    }
442
443    #[test]
444    fn test_adjusted_bump_type_pre_1_0() {
445        // In pre-1.0 (0.x.x), Major bumps become Minor (breaking changes are minor bumps)
446        let v = Version::new(0, 16, 0);
447        assert_eq!(v.adjusted_bump_type(BumpType::Major), BumpType::Minor);
448        assert_eq!(v.adjusted_bump_type(BumpType::Minor), BumpType::Minor);
449        assert_eq!(v.adjusted_bump_type(BumpType::Patch), BumpType::Patch);
450        assert_eq!(v.adjusted_bump_type(BumpType::None), BumpType::None);
451    }
452
453    #[test]
454    fn test_adjusted_bump_type_post_1_0() {
455        // In post-1.0 (1.x.x+), Major bumps stay Major
456        let v = Version::new(1, 0, 0);
457        assert_eq!(v.adjusted_bump_type(BumpType::Major), BumpType::Major);
458        assert_eq!(v.adjusted_bump_type(BumpType::Minor), BumpType::Minor);
459        assert_eq!(v.adjusted_bump_type(BumpType::Patch), BumpType::Patch);
460        assert_eq!(v.adjusted_bump_type(BumpType::None), BumpType::None);
461
462        let v2 = Version::new(2, 5, 3);
463        assert_eq!(v2.adjusted_bump_type(BumpType::Major), BumpType::Major);
464    }
465
466    #[test]
467    fn test_version_calculator_independent() {
468        let current = HashMap::from([
469            ("pkg-a".to_string(), Version::new(1, 0, 0)),
470            ("pkg-b".to_string(), Version::new(2, 0, 0)),
471        ]);
472        let config = ReleasePackagesConfig::default();
473        let calc = VersionCalculator::new(current, config);
474
475        let bumps = HashMap::from([
476            ("pkg-a".to_string(), BumpType::Minor),
477            ("pkg-b".to_string(), BumpType::Patch),
478        ]);
479
480        let new_versions = calc.calculate(&bumps);
481        assert_eq!(new_versions.get("pkg-a"), Some(&Version::new(1, 1, 0)));
482        assert_eq!(new_versions.get("pkg-b"), Some(&Version::new(2, 0, 1)));
483    }
484
485    #[test]
486    fn test_version_calculator_fixed_group() {
487        let current = HashMap::from([
488            ("pkg-a".to_string(), Version::new(1, 0, 0)),
489            ("pkg-b".to_string(), Version::new(1, 0, 0)),
490        ]);
491        let config = ReleasePackagesConfig {
492            fixed: vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]],
493            ..Default::default()
494        };
495        let calc = VersionCalculator::new(current, config);
496
497        // Only pkg-a has a bump, but both should be updated
498        let bumps = HashMap::from([("pkg-a".to_string(), BumpType::Minor)]);
499
500        let new_versions = calc.calculate(&bumps);
501        assert_eq!(new_versions.get("pkg-a"), Some(&Version::new(1, 1, 0)));
502        assert_eq!(new_versions.get("pkg-b"), Some(&Version::new(1, 1, 0)));
503    }
504
505    #[test]
506    fn test_version_calculator_fixed_group_max_bump() {
507        let current = HashMap::from([
508            ("pkg-a".to_string(), Version::new(1, 0, 0)),
509            ("pkg-b".to_string(), Version::new(1, 0, 0)),
510        ]);
511        let config = ReleasePackagesConfig {
512            fixed: vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]],
513            ..Default::default()
514        };
515        let calc = VersionCalculator::new(current, config);
516
517        // Different bumps - should use the highest
518        let bumps = HashMap::from([
519            ("pkg-a".to_string(), BumpType::Patch),
520            ("pkg-b".to_string(), BumpType::Minor),
521        ]);
522
523        let new_versions = calc.calculate(&bumps);
524        // Both get Minor bump (the higher one)
525        assert_eq!(new_versions.get("pkg-a"), Some(&Version::new(1, 1, 0)));
526        assert_eq!(new_versions.get("pkg-b"), Some(&Version::new(1, 1, 0)));
527    }
528
529    #[test]
530    fn test_version_calculator_linked_group() {
531        let current = HashMap::from([
532            ("pkg-a".to_string(), Version::new(1, 0, 0)),
533            ("pkg-b".to_string(), Version::new(2, 0, 0)),
534        ]);
535        let config = ReleasePackagesConfig {
536            linked: vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]],
537            ..Default::default()
538        };
539        let calc = VersionCalculator::new(current, config);
540
541        let bumps = HashMap::from([("pkg-a".to_string(), BumpType::Minor)]);
542
543        let new_versions = calc.calculate(&bumps);
544        // Both are bumped by minor, but from their own versions
545        assert_eq!(new_versions.get("pkg-a"), Some(&Version::new(1, 1, 0)));
546        assert_eq!(new_versions.get("pkg-b"), Some(&Version::new(2, 1, 0)));
547    }
548}