Skip to main content

changeset_operations/
planner.rs

1use std::collections::{HashMap, HashSet};
2
3use changeset_core::{BumpType, Changeset, PackageInfo, PrereleaseSpec, ZeroVersionBehavior};
4use changeset_version::{
5    VersionError, calculate_new_version, calculate_new_version_with_zero_behavior, is_zero_version,
6    max_bump_type,
7};
8use gset::Getset;
9use indexmap::IndexMap;
10
11use crate::types::{PackageReleaseConfig, PackageVersion};
12
13#[derive(Debug, Clone, Getset)]
14pub struct ReleasePlan {
15    #[getset(get, vis = "pub")]
16    releases: Vec<PackageVersion>,
17    #[getset(get, vis = "pub")]
18    unknown_packages: Vec<String>,
19}
20
21impl ReleasePlan {
22    pub(crate) fn new(releases: Vec<PackageVersion>, unknown_packages: Vec<String>) -> Self {
23        Self {
24            releases,
25            unknown_packages,
26        }
27    }
28}
29
30pub struct VersionPlanner;
31
32impl VersionPlanner {
33    /// # Errors
34    ///
35    /// Returns `VersionError` if version calculation fails.
36    pub fn plan_releases(
37        changesets: &[Changeset],
38        packages: &[PackageInfo],
39    ) -> Result<ReleasePlan, VersionError> {
40        Self::plan_releases_with_prerelease(changesets, packages, None)
41    }
42
43    /// # Errors
44    ///
45    /// Returns `VersionError` if version calculation fails.
46    pub fn plan_releases_with_prerelease(
47        changesets: &[Changeset],
48        packages: &[PackageInfo],
49        prerelease: Option<&PrereleaseSpec>,
50    ) -> Result<ReleasePlan, VersionError> {
51        let package_lookup: IndexMap<_, _> =
52            packages.iter().map(|p| (p.name().clone(), p)).collect();
53        let bumps_by_package = Self::aggregate_bumps(changesets);
54        let graduates = Self::collect_graduates(changesets);
55
56        let mut releases = Vec::new();
57        let mut unknown_packages = Vec::new();
58
59        for (name, bumps) in &bumps_by_package {
60            let bump_type = max_bump_type(bumps);
61            let should_graduate = graduates.contains(name);
62
63            if Self::should_skip_package(bump_type, prerelease, should_graduate) {
64                continue;
65            }
66
67            if let Some(pkg) = package_lookup.get(name) {
68                let (new_version, effective_bump) = Self::compute_version_and_bump(
69                    pkg.version(),
70                    bump_type,
71                    prerelease,
72                    should_graduate,
73                )?;
74                releases.push(PackageVersion::new(
75                    name.clone(),
76                    pkg.version().clone(),
77                    new_version,
78                    effective_bump,
79                    false,
80                ));
81            } else {
82                unknown_packages.push(name.clone());
83            }
84        }
85
86        Ok(ReleasePlan::new(releases, unknown_packages))
87    }
88
89    /// # Errors
90    ///
91    /// Returns `VersionError` if version calculation fails.
92    pub fn plan_graduation(packages: &[PackageInfo]) -> Result<ReleasePlan, VersionError> {
93        let mut releases = Vec::new();
94
95        for pkg in packages {
96            if changeset_version::is_prerelease(pkg.version()) {
97                let new_version = calculate_new_version(pkg.version(), None, None)?;
98                releases.push(PackageVersion::new(
99                    pkg.name().clone(),
100                    pkg.version().clone(),
101                    new_version,
102                    BumpType::Patch,
103                    false,
104                ));
105            }
106        }
107
108        Ok(ReleasePlan::new(releases, Vec::new()))
109    }
110
111    /// # Errors
112    ///
113    /// Returns `VersionError` if version calculation fails.
114    pub fn plan_releases_with_behavior(
115        changesets: &[Changeset],
116        packages: &[PackageInfo],
117        prerelease: Option<&PrereleaseSpec>,
118        zero_behavior: ZeroVersionBehavior,
119    ) -> Result<ReleasePlan, VersionError> {
120        let package_lookup: IndexMap<_, _> =
121            packages.iter().map(|p| (p.name().clone(), p)).collect();
122        let bumps_by_package = Self::aggregate_bumps(changesets);
123        let graduates = Self::collect_graduates(changesets);
124
125        let mut releases = Vec::new();
126        let mut unknown_packages = Vec::new();
127
128        for (name, bumps) in &bumps_by_package {
129            let bump_type = max_bump_type(bumps);
130            let should_graduate = graduates.contains(name);
131
132            if Self::should_skip_package(bump_type, prerelease, should_graduate) {
133                continue;
134            }
135
136            if let Some(pkg) = package_lookup.get(name) {
137                let (new_version, effective_bump) =
138                    Self::compute_version_and_bump_with_zero_behavior(
139                        pkg.version(),
140                        bump_type,
141                        prerelease,
142                        zero_behavior,
143                        should_graduate,
144                    )?;
145                releases.push(PackageVersion::new(
146                    name.clone(),
147                    pkg.version().clone(),
148                    new_version,
149                    effective_bump,
150                    false,
151                ));
152            } else {
153                unknown_packages.push(name.clone());
154            }
155        }
156
157        Ok(ReleasePlan::new(releases, unknown_packages))
158    }
159
160    /// # Errors
161    ///
162    /// Returns `VersionError` if version calculation fails.
163    pub fn plan_zero_graduation(
164        packages: &[PackageInfo],
165        prerelease: Option<&PrereleaseSpec>,
166    ) -> Result<ReleasePlan, VersionError> {
167        let mut releases = Vec::new();
168
169        for pkg in packages {
170            if is_zero_version(pkg.version()) {
171                let new_version = calculate_new_version_with_zero_behavior(
172                    pkg.version(),
173                    None,
174                    prerelease,
175                    ZeroVersionBehavior::EffectiveMinor,
176                    true,
177                )?;
178                releases.push(PackageVersion::new(
179                    pkg.name().clone(),
180                    pkg.version().clone(),
181                    new_version,
182                    BumpType::Major,
183                    false,
184                ));
185            }
186        }
187
188        Ok(ReleasePlan::new(releases, Vec::new()))
189    }
190
191    /// # Errors
192    ///
193    /// Returns `VersionError` if version calculation fails.
194    pub fn plan_releases_per_package(
195        changesets: &[Changeset],
196        packages: &[PackageInfo],
197        per_package_config: &HashMap<String, PackageReleaseConfig>,
198        zero_behavior: ZeroVersionBehavior,
199    ) -> Result<ReleasePlan, VersionError> {
200        let package_lookup: IndexMap<_, _> =
201            packages.iter().map(|p| (p.name().clone(), p)).collect();
202        let bumps_by_package = Self::aggregate_bumps(changesets);
203        let changeset_graduates = Self::collect_graduates(changesets);
204
205        let mut releases = Vec::new();
206        let mut unknown_packages = Vec::new();
207
208        for (name, bumps) in &bumps_by_package {
209            let bump_type = max_bump_type(bumps);
210            let config = per_package_config.get(name);
211
212            let prerelease = config.and_then(|c| c.prerelease());
213            let should_graduate = config.is_some_and(PackageReleaseConfig::graduate_zero)
214                || changeset_graduates.contains(name);
215
216            if Self::should_skip_package(bump_type, prerelease, should_graduate) {
217                continue;
218            }
219
220            if let Some(pkg) = package_lookup.get(name) {
221                let (new_version, effective_bump) =
222                    Self::compute_version_and_bump_with_zero_behavior(
223                        pkg.version(),
224                        bump_type,
225                        prerelease,
226                        zero_behavior,
227                        should_graduate,
228                    )?;
229                releases.push(PackageVersion::new(
230                    name.clone(),
231                    pkg.version().clone(),
232                    new_version,
233                    effective_bump,
234                    false,
235                ));
236            } else {
237                unknown_packages.push(name.clone());
238            }
239        }
240
241        for (name, config) in per_package_config {
242            if bumps_by_package.contains_key(name) {
243                continue;
244            }
245
246            if config.prerelease().is_none() && !config.graduate_zero() {
247                continue;
248            }
249
250            if let Some(pkg) = package_lookup.get(name) {
251                let (new_version, effective_bump) =
252                    Self::compute_version_and_bump_with_zero_behavior(
253                        pkg.version(),
254                        None,
255                        config.prerelease(),
256                        zero_behavior,
257                        config.graduate_zero(),
258                    )?;
259                releases.push(PackageVersion::new(
260                    name.clone(),
261                    pkg.version().clone(),
262                    new_version,
263                    effective_bump,
264                    false,
265                ));
266            }
267        }
268
269        Ok(ReleasePlan::new(releases, unknown_packages))
270    }
271
272    fn effective_bump_type(aggregated_bump: Option<BumpType>, should_graduate: bool) -> BumpType {
273        if should_graduate {
274            BumpType::Major
275        } else {
276            aggregated_bump.unwrap_or(BumpType::Patch)
277        }
278    }
279
280    fn compute_version_and_bump(
281        current: &semver::Version,
282        bump_type: Option<BumpType>,
283        prerelease: Option<&PrereleaseSpec>,
284        should_graduate: bool,
285    ) -> Result<(semver::Version, BumpType), VersionError> {
286        let new_version = if should_graduate {
287            calculate_new_version_with_zero_behavior(
288                current,
289                bump_type,
290                prerelease,
291                ZeroVersionBehavior::default(),
292                true,
293            )?
294        } else {
295            calculate_new_version(current, bump_type, prerelease)?
296        };
297        Ok((
298            new_version,
299            Self::effective_bump_type(bump_type, should_graduate),
300        ))
301    }
302
303    fn compute_version_and_bump_with_zero_behavior(
304        current: &semver::Version,
305        bump_type: Option<BumpType>,
306        prerelease: Option<&PrereleaseSpec>,
307        zero_behavior: ZeroVersionBehavior,
308        should_graduate: bool,
309    ) -> Result<(semver::Version, BumpType), VersionError> {
310        let new_version = calculate_new_version_with_zero_behavior(
311            current,
312            bump_type,
313            prerelease,
314            zero_behavior,
315            should_graduate,
316        )?;
317        Ok((
318            new_version,
319            Self::effective_bump_type(bump_type, should_graduate),
320        ))
321    }
322
323    fn should_skip_package(
324        bump_type: Option<BumpType>,
325        prerelease: Option<&PrereleaseSpec>,
326        should_graduate: bool,
327    ) -> bool {
328        let is_noop_or_absent = bump_type.is_none() || bump_type.is_some_and(|b| b.is_noop());
329        is_noop_or_absent && prerelease.is_none() && !should_graduate
330    }
331
332    fn collect_graduates(changesets: &[Changeset]) -> HashSet<String> {
333        changesets
334            .iter()
335            .filter(|c| c.graduate())
336            .flat_map(|c| c.releases().iter().map(|r| r.name().clone()))
337            .collect()
338    }
339
340    #[must_use]
341    pub fn aggregate_bumps(changesets: &[Changeset]) -> IndexMap<String, Vec<BumpType>> {
342        let mut bumps_by_package: IndexMap<String, Vec<BumpType>> = IndexMap::new();
343
344        for changeset in changesets {
345            for release in changeset.releases() {
346                bumps_by_package
347                    .entry(release.name().clone())
348                    .or_default()
349                    .push(release.bump_type());
350            }
351        }
352
353        bumps_by_package
354    }
355
356    #[must_use]
357    pub fn partition_packages(
358        changesets: &[Changeset],
359        packages: &[PackageInfo],
360    ) -> (HashSet<String>, Vec<PackageInfo>) {
361        let packages_with_changesets: HashSet<String> = changesets
362            .iter()
363            .flat_map(|c| c.releases().iter().map(|r| r.name().clone()))
364            .collect();
365
366        let unchanged_packages: Vec<PackageInfo> = packages
367            .iter()
368            .filter(|p| !packages_with_changesets.contains(p.name()))
369            .cloned()
370            .collect();
371
372        (packages_with_changesets, unchanged_packages)
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use changeset_core::{ChangeCategory, PackageRelease};
380    use semver::Version;
381    use std::path::PathBuf;
382
383    use crate::types::PackageReleaseConfigBuilder;
384
385    fn make_package(name: &str, version: &str) -> PackageInfo {
386        PackageInfo::new(
387            name.to_string(),
388            version.parse().expect("valid version"),
389            PathBuf::from(format!("/mock/crates/{name}")),
390        )
391    }
392
393    fn make_changeset(package_name: &str, bump: BumpType, summary: &str) -> Changeset {
394        Changeset::new(
395            summary.to_string(),
396            vec![PackageRelease::new(package_name.to_string(), bump)],
397            ChangeCategory::Changed,
398        )
399    }
400
401    fn make_multi_changeset(releases: Vec<(&str, BumpType)>, summary: &str) -> Changeset {
402        Changeset::new(
403            summary.to_string(),
404            releases
405                .into_iter()
406                .map(|(name, bump)| PackageRelease::new(name.to_string(), bump))
407                .collect(),
408            ChangeCategory::Changed,
409        )
410    }
411
412    #[test]
413    fn plan_releases_empty_changesets_returns_empty_plan() {
414        let packages = vec![make_package("my-crate", "1.0.0")];
415
416        let plan = VersionPlanner::plan_releases(&[], &packages).expect("plan_releases");
417
418        assert!(plan.releases().is_empty());
419        assert!(plan.unknown_packages().is_empty());
420    }
421
422    #[test]
423    fn plan_releases_single_package_single_bump() {
424        let packages = vec![make_package("my-crate", "1.0.0")];
425        let changesets = vec![make_changeset("my-crate", BumpType::Patch, "Fix bug")];
426
427        let plan = VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
428
429        assert_eq!(plan.releases().len(), 1);
430        assert!(plan.unknown_packages().is_empty());
431
432        let release = &plan.releases()[0];
433        assert_eq!(release.name(), "my-crate");
434        assert_eq!(*release.current_version(), Version::new(1, 0, 0));
435        assert_eq!(*release.new_version(), Version::new(1, 0, 1));
436        assert_eq!(release.bump_type(), BumpType::Patch);
437    }
438
439    #[test]
440    fn plan_releases_single_package_takes_max_bump() {
441        let packages = vec![make_package("my-crate", "1.0.0")];
442        let changesets = vec![
443            make_changeset("my-crate", BumpType::Patch, "Fix bug"),
444            make_changeset("my-crate", BumpType::Minor, "Add feature"),
445            make_changeset("my-crate", BumpType::Patch, "Another fix"),
446        ];
447
448        let plan = VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
449
450        assert_eq!(plan.releases().len(), 1);
451        let release = &plan.releases()[0];
452        assert_eq!(*release.new_version(), Version::new(1, 1, 0));
453        assert_eq!(release.bump_type(), BumpType::Minor);
454    }
455
456    #[test]
457    fn plan_releases_multiple_packages_independent_bumps() {
458        let packages = vec![
459            make_package("crate-a", "1.0.0"),
460            make_package("crate-b", "2.5.3"),
461        ];
462        let changesets = vec![
463            make_changeset("crate-a", BumpType::Minor, "Add feature to A"),
464            make_changeset("crate-b", BumpType::Major, "Breaking change in B"),
465        ];
466
467        let plan = VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
468
469        assert_eq!(plan.releases().len(), 2);
470        assert!(plan.unknown_packages().is_empty());
471
472        let release_a = plan
473            .releases()
474            .iter()
475            .find(|r| r.name() == "crate-a")
476            .expect("crate-a should be in releases");
477        assert_eq!(*release_a.new_version(), Version::new(1, 1, 0));
478
479        let release_b = plan
480            .releases()
481            .iter()
482            .find(|r| r.name() == "crate-b")
483            .expect("crate-b should be in releases");
484        assert_eq!(*release_b.new_version(), Version::new(3, 0, 0));
485    }
486
487    #[test]
488    fn plan_releases_unknown_package_collected_not_errored() {
489        let packages = vec![make_package("known-crate", "1.0.0")];
490        let changesets = vec![make_changeset("unknown-crate", BumpType::Patch, "Fix")];
491
492        let plan = VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
493
494        assert!(plan.releases().is_empty());
495        assert_eq!(plan.unknown_packages(), &["unknown-crate"]);
496    }
497
498    #[test]
499    fn plan_releases_mixed_known_and_unknown_packages() {
500        let packages = vec![make_package("known-crate", "1.0.0")];
501        let changesets = vec![make_multi_changeset(
502            vec![
503                ("known-crate", BumpType::Minor),
504                ("unknown-crate", BumpType::Patch),
505            ],
506            "Mixed changes",
507        )];
508
509        let plan = VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
510
511        assert_eq!(plan.releases().len(), 1);
512        assert_eq!(plan.releases()[0].name(), "known-crate");
513        assert_eq!(plan.unknown_packages(), &["unknown-crate"]);
514    }
515
516    #[test]
517    fn aggregate_bumps_collects_all_bump_types() {
518        let changesets = vec![
519            make_changeset("crate-a", BumpType::Patch, "Fix"),
520            make_changeset("crate-a", BumpType::Minor, "Feature"),
521            make_changeset("crate-b", BumpType::Major, "Breaking"),
522        ];
523
524        let bumps = VersionPlanner::aggregate_bumps(&changesets);
525
526        assert_eq!(bumps["crate-a"], vec![BumpType::Patch, BumpType::Minor]);
527        assert_eq!(bumps["crate-b"], vec![BumpType::Major]);
528    }
529
530    #[test]
531    fn partition_packages_identifies_changed_and_unchanged() {
532        let packages = vec![
533            make_package("changed", "1.0.0"),
534            make_package("unchanged", "2.0.0"),
535        ];
536        let changesets = vec![make_changeset("changed", BumpType::Patch, "Fix")];
537
538        let (with_changesets, without) = VersionPlanner::partition_packages(&changesets, &packages);
539
540        assert!(with_changesets.contains("changed"));
541        assert!(!with_changesets.contains("unchanged"));
542        assert_eq!(without.len(), 1);
543        assert_eq!(without[0].name(), "unchanged");
544    }
545
546    #[test]
547    fn plan_releases_handles_prerelease_versions() {
548        let packages = vec![make_package("my-crate", "1.0.0-alpha.1")];
549        let changesets = vec![make_changeset("my-crate", BumpType::Patch, "Fix")];
550
551        let plan = VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
552
553        assert_eq!(plan.releases().len(), 1);
554        let release = &plan.releases()[0];
555        assert_eq!(
556            *release.current_version(),
557            "1.0.0-alpha.1".parse::<Version>().expect("valid")
558        );
559        assert!(release.new_version() > release.current_version());
560    }
561
562    #[test]
563    fn plan_releases_handles_build_metadata() {
564        let packages = vec![make_package("my-crate", "1.0.0+build.123")];
565        let changesets = vec![make_changeset("my-crate", BumpType::Minor, "Feature")];
566
567        let plan = VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
568
569        assert_eq!(plan.releases().len(), 1);
570        let release = &plan.releases()[0];
571        assert_eq!(
572            *release.current_version(),
573            "1.0.0+build.123".parse::<Version>().expect("valid")
574        );
575    }
576
577    #[test]
578    fn plan_releases_with_zero_major_version() {
579        let packages = vec![make_package("my-crate", "0.1.0")];
580        let changesets = vec![make_changeset("my-crate", BumpType::Major, "Breaking")];
581
582        let plan = VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
583
584        assert_eq!(plan.releases().len(), 1);
585        let release = &plan.releases()[0];
586        assert_eq!(*release.current_version(), Version::new(0, 1, 0));
587        assert_eq!(*release.new_version(), Version::new(1, 0, 0));
588    }
589
590    #[test]
591    fn plan_releases_with_prerelease_creates_alpha_version() {
592        let packages = vec![make_package("my-crate", "1.0.0")];
593        let changesets = vec![make_changeset("my-crate", BumpType::Patch, "Fix")];
594
595        let plan = VersionPlanner::plan_releases_with_prerelease(
596            &changesets,
597            &packages,
598            Some(&PrereleaseSpec::Alpha),
599        )
600        .expect("plan_releases_with_prerelease");
601
602        assert_eq!(plan.releases().len(), 1);
603        let release = &plan.releases()[0];
604        assert_eq!(
605            *release.new_version(),
606            "1.0.1-alpha.1".parse::<Version>().expect("valid")
607        );
608    }
609
610    #[test]
611    fn plan_releases_with_prerelease_increments_existing() {
612        let packages = vec![make_package("my-crate", "1.0.1-alpha.2")];
613        let changesets = vec![make_changeset("my-crate", BumpType::Patch, "Fix")];
614
615        let plan = VersionPlanner::plan_releases_with_prerelease(
616            &changesets,
617            &packages,
618            Some(&PrereleaseSpec::Alpha),
619        )
620        .expect("plan_releases_with_prerelease");
621
622        assert_eq!(plan.releases().len(), 1);
623        let release = &plan.releases()[0];
624        assert_eq!(
625            *release.new_version(),
626            "1.0.1-alpha.3".parse::<Version>().expect("valid")
627        );
628    }
629
630    #[test]
631    fn plan_graduation_creates_stable_from_prerelease() {
632        let packages = vec![
633            make_package("crate-a", "1.0.1-rc.1"),
634            make_package("crate-b", "2.0.0"),
635        ];
636
637        let plan = VersionPlanner::plan_graduation(&packages).expect("plan_graduation");
638
639        assert_eq!(plan.releases().len(), 1);
640        let release = &plan.releases()[0];
641        assert_eq!(release.name(), "crate-a");
642        assert_eq!(*release.new_version(), Version::new(1, 0, 1));
643    }
644
645    #[test]
646    fn plan_graduation_empty_for_all_stable() {
647        let packages = vec![
648            make_package("crate-a", "1.0.0"),
649            make_package("crate-b", "2.0.0"),
650        ];
651
652        let plan = VersionPlanner::plan_graduation(&packages).expect("plan_graduation");
653
654        assert!(plan.releases().is_empty());
655    }
656
657    mod zero_version_behavior_tests {
658        use super::*;
659
660        #[test]
661        fn effective_minor_converts_major_to_minor() {
662            let packages = vec![make_package("my-crate", "0.1.2")];
663            let changesets = vec![make_changeset("my-crate", BumpType::Major, "Breaking")];
664
665            let plan = VersionPlanner::plan_releases_with_behavior(
666                &changesets,
667                &packages,
668                None,
669                ZeroVersionBehavior::EffectiveMinor,
670            )
671            .expect("plan_releases_with_behavior");
672
673            assert_eq!(plan.releases().len(), 1);
674            let release = &plan.releases()[0];
675            assert_eq!(*release.new_version(), Version::new(0, 2, 0));
676        }
677
678        #[test]
679        fn effective_minor_converts_minor_to_patch() {
680            let packages = vec![make_package("my-crate", "0.1.2")];
681            let changesets = vec![make_changeset("my-crate", BumpType::Minor, "Feature")];
682
683            let plan = VersionPlanner::plan_releases_with_behavior(
684                &changesets,
685                &packages,
686                None,
687                ZeroVersionBehavior::EffectiveMinor,
688            )
689            .expect("plan_releases_with_behavior");
690
691            assert_eq!(plan.releases().len(), 1);
692            let release = &plan.releases()[0];
693            assert_eq!(*release.new_version(), Version::new(0, 1, 3));
694        }
695
696        #[test]
697        fn auto_promote_major_becomes_1_0_0() {
698            let packages = vec![make_package("my-crate", "0.1.2")];
699            let changesets = vec![make_changeset("my-crate", BumpType::Major, "Breaking")];
700
701            let plan = VersionPlanner::plan_releases_with_behavior(
702                &changesets,
703                &packages,
704                None,
705                ZeroVersionBehavior::AutoPromoteOnMajor,
706            )
707            .expect("plan_releases_with_behavior");
708
709            assert_eq!(plan.releases().len(), 1);
710            let release = &plan.releases()[0];
711            assert_eq!(*release.new_version(), Version::new(1, 0, 0));
712        }
713
714        #[test]
715        fn auto_promote_minor_stays_minor() {
716            let packages = vec![make_package("my-crate", "0.1.2")];
717            let changesets = vec![make_changeset("my-crate", BumpType::Minor, "Feature")];
718
719            let plan = VersionPlanner::plan_releases_with_behavior(
720                &changesets,
721                &packages,
722                None,
723                ZeroVersionBehavior::AutoPromoteOnMajor,
724            )
725            .expect("plan_releases_with_behavior");
726
727            assert_eq!(plan.releases().len(), 1);
728            let release = &plan.releases()[0];
729            assert_eq!(*release.new_version(), Version::new(0, 2, 0));
730        }
731
732        #[test]
733        fn stable_version_unaffected_by_behavior() {
734            let packages = vec![make_package("my-crate", "1.2.3")];
735            let changesets = vec![make_changeset("my-crate", BumpType::Major, "Breaking")];
736
737            let plan = VersionPlanner::plan_releases_with_behavior(
738                &changesets,
739                &packages,
740                None,
741                ZeroVersionBehavior::EffectiveMinor,
742            )
743            .expect("plan_releases_with_behavior");
744
745            assert_eq!(plan.releases().len(), 1);
746            let release = &plan.releases()[0];
747            assert_eq!(*release.new_version(), Version::new(2, 0, 0));
748        }
749
750        #[test]
751        fn with_prerelease_tag() {
752            let packages = vec![make_package("my-crate", "0.1.2")];
753            let changesets = vec![make_changeset("my-crate", BumpType::Major, "Breaking")];
754
755            let plan = VersionPlanner::plan_releases_with_behavior(
756                &changesets,
757                &packages,
758                Some(&PrereleaseSpec::Alpha),
759                ZeroVersionBehavior::EffectiveMinor,
760            )
761            .expect("plan_releases_with_behavior");
762
763            assert_eq!(plan.releases().len(), 1);
764            let release = &plan.releases()[0];
765            assert_eq!(
766                *release.new_version(),
767                "0.2.0-alpha.1".parse::<Version>().expect("valid")
768            );
769        }
770
771        #[test]
772        fn auto_promote_none_bump_excludes_package() {
773            let packages = vec![make_package("my-crate", "0.1.2")];
774            let changesets = vec![make_changeset("my-crate", BumpType::None, "internal")];
775
776            let plan = VersionPlanner::plan_releases_with_behavior(
777                &changesets,
778                &packages,
779                None,
780                ZeroVersionBehavior::AutoPromoteOnMajor,
781            )
782            .expect("plan_releases_with_behavior");
783
784            assert!(
785                plan.releases().is_empty(),
786                "package with only None bumps should not appear in releases"
787            );
788        }
789    }
790
791    mod zero_graduation_tests {
792        use super::*;
793
794        #[test]
795        fn graduates_zero_version_to_1_0_0() {
796            let packages = vec![
797                make_package("crate-a", "0.5.3"),
798                make_package("crate-b", "1.0.0"),
799            ];
800
801            let plan = VersionPlanner::plan_zero_graduation(&packages, None)
802                .expect("plan_zero_graduation");
803
804            assert_eq!(plan.releases().len(), 1);
805            let release = &plan.releases()[0];
806            assert_eq!(release.name(), "crate-a");
807            assert_eq!(*release.new_version(), Version::new(1, 0, 0));
808        }
809
810        #[test]
811        fn graduates_with_prerelease() {
812            let packages = vec![make_package("crate-a", "0.5.3")];
813
814            let plan =
815                VersionPlanner::plan_zero_graduation(&packages, Some(&PrereleaseSpec::Alpha))
816                    .expect("plan_zero_graduation");
817
818            assert_eq!(plan.releases().len(), 1);
819            let release = &plan.releases()[0];
820            assert_eq!(
821                *release.new_version(),
822                "1.0.0-alpha.1".parse::<Version>().expect("valid")
823            );
824        }
825
826        #[test]
827        fn empty_for_all_stable() {
828            let packages = vec![
829                make_package("crate-a", "1.0.0"),
830                make_package("crate-b", "2.5.0"),
831            ];
832
833            let plan = VersionPlanner::plan_zero_graduation(&packages, None)
834                .expect("plan_zero_graduation");
835
836            assert!(plan.releases().is_empty());
837        }
838
839        #[test]
840        fn multiple_zero_versions() {
841            let packages = vec![
842                make_package("crate-a", "0.1.0"),
843                make_package("crate-b", "0.5.3"),
844                make_package("crate-c", "1.0.0"),
845            ];
846
847            let plan = VersionPlanner::plan_zero_graduation(&packages, None)
848                .expect("plan_zero_graduation");
849
850            assert_eq!(plan.releases().len(), 2);
851            for release in plan.releases() {
852                assert_eq!(*release.new_version(), Version::new(1, 0, 0));
853            }
854        }
855    }
856
857    mod changeset_graduate_field_tests {
858        use super::*;
859
860        fn make_graduating_changeset(package_name: &str, bump: BumpType) -> Changeset {
861            Changeset::new(
862                "Graduate to 1.0".to_string(),
863                vec![PackageRelease::new(package_name.to_string(), bump)],
864                ChangeCategory::Changed,
865            )
866            .with_graduate(true)
867        }
868
869        #[test]
870        fn graduate_field_triggers_graduation() {
871            let packages = vec![make_package("my-crate", "0.5.3")];
872            let changesets = vec![make_graduating_changeset("my-crate", BumpType::Major)];
873
874            let plan = VersionPlanner::plan_releases_with_behavior(
875                &changesets,
876                &packages,
877                None,
878                ZeroVersionBehavior::EffectiveMinor,
879            )
880            .expect("plan_releases_with_behavior");
881
882            assert_eq!(plan.releases().len(), 1);
883            let release = &plan.releases()[0];
884            assert_eq!(*release.new_version(), Version::new(1, 0, 0));
885        }
886
887        #[test]
888        fn graduate_field_ignored_for_stable_returns_error() {
889            let packages = vec![make_package("my-crate", "1.2.3")];
890            let changesets = vec![make_graduating_changeset("my-crate", BumpType::Major)];
891
892            let result = VersionPlanner::plan_releases_with_behavior(
893                &changesets,
894                &packages,
895                None,
896                ZeroVersionBehavior::EffectiveMinor,
897            );
898
899            assert!(result.is_err());
900        }
901
902        #[test]
903        fn mixed_graduate_and_regular_changesets() {
904            let packages = vec![
905                make_package("graduating", "0.5.0"),
906                make_package("regular", "0.3.0"),
907            ];
908            let changesets = vec![
909                make_graduating_changeset("graduating", BumpType::Major),
910                make_changeset("regular", BumpType::Major, "Breaking"),
911            ];
912
913            let plan = VersionPlanner::plan_releases_with_behavior(
914                &changesets,
915                &packages,
916                None,
917                ZeroVersionBehavior::EffectiveMinor,
918            )
919            .expect("plan_releases_with_behavior");
920
921            assert_eq!(plan.releases().len(), 2);
922
923            let graduating = plan
924                .releases()
925                .iter()
926                .find(|r| r.name() == "graduating")
927                .expect("graduating should be in releases");
928            assert_eq!(graduating.new_version(), &Version::new(1, 0, 0));
929
930            let regular = plan
931                .releases()
932                .iter()
933                .find(|r| r.name() == "regular")
934                .expect("regular should be in releases");
935            assert_eq!(regular.new_version(), &Version::new(0, 4, 0));
936        }
937    }
938
939    mod graduation_with_none_bump {
940        use super::*;
941
942        fn make_graduating_changeset(package_name: &str, bump: BumpType) -> Changeset {
943            Changeset::new(
944                "Graduate to 1.0".to_string(),
945                vec![PackageRelease::new(package_name.to_string(), bump)],
946                ChangeCategory::Changed,
947            )
948            .with_graduate(true)
949        }
950
951        #[test]
952        fn graduate_field_with_none_bump_still_graduates() {
953            let packages = vec![make_package("my-crate", "0.5.3")];
954            let changesets = vec![make_graduating_changeset("my-crate", BumpType::None)];
955
956            let plan = VersionPlanner::plan_releases_with_behavior(
957                &changesets,
958                &packages,
959                None,
960                ZeroVersionBehavior::EffectiveMinor,
961            )
962            .expect("plan_releases_with_behavior");
963
964            assert_eq!(plan.releases().len(), 1);
965            let release = &plan.releases()[0];
966            assert_eq!(*release.new_version(), Version::new(1, 0, 0));
967        }
968
969        #[test]
970        fn per_package_graduate_with_none_bump_still_graduates() {
971            let packages = vec![make_package("my-crate", "0.5.3")];
972            let changesets = vec![make_graduating_changeset("my-crate", BumpType::None)];
973
974            let mut config = HashMap::new();
975            config.insert(
976                "my-crate".to_string(),
977                PackageReleaseConfigBuilder::default()
978                    .graduate_zero(true)
979                    .build()
980                    .expect("all fields have defaults"),
981            );
982
983            let plan = VersionPlanner::plan_releases_per_package(
984                &changesets,
985                &packages,
986                &config,
987                ZeroVersionBehavior::EffectiveMinor,
988            )
989            .expect("plan_releases_per_package");
990
991            assert_eq!(plan.releases().len(), 1);
992            let release = &plan.releases()[0];
993            assert_eq!(*release.new_version(), Version::new(1, 0, 0));
994        }
995
996        #[test]
997        fn prerelease_plan_graduate_with_none_bump_still_graduates() {
998            let packages = vec![make_package("my-crate", "0.5.3")];
999            let changesets = vec![make_graduating_changeset("my-crate", BumpType::None)];
1000
1001            let plan = VersionPlanner::plan_releases_with_prerelease(&changesets, &packages, None)
1002                .expect("plan_releases_with_prerelease");
1003
1004            assert_eq!(plan.releases().len(), 1);
1005            let release = &plan.releases()[0];
1006            assert_eq!(*release.new_version(), Version::new(1, 0, 0));
1007            assert_eq!(release.bump_type(), BumpType::Major);
1008        }
1009
1010        #[test]
1011        fn prerelease_plan_graduate_with_none_bump_and_prerelease_spec() {
1012            let packages = vec![make_package("my-crate", "0.5.3")];
1013            let changesets = vec![make_graduating_changeset("my-crate", BumpType::None)];
1014
1015            let plan = VersionPlanner::plan_releases_with_prerelease(
1016                &changesets,
1017                &packages,
1018                Some(&PrereleaseSpec::Alpha),
1019            )
1020            .expect("plan_releases_with_prerelease");
1021
1022            assert_eq!(plan.releases().len(), 1);
1023            let release = &plan.releases()[0];
1024            assert_eq!(
1025                *release.new_version(),
1026                "1.0.0-alpha.1".parse::<Version>().expect("valid")
1027            );
1028            assert_eq!(release.bump_type(), BumpType::Major);
1029        }
1030    }
1031
1032    mod per_package_config_tests {
1033        use super::*;
1034
1035        #[test]
1036        fn per_package_prerelease_applies_to_specific_crate() {
1037            let packages = vec![
1038                make_package("crate-a", "1.0.0"),
1039                make_package("crate-b", "1.0.0"),
1040            ];
1041            let changesets = vec![
1042                make_changeset("crate-a", BumpType::Patch, "Fix A"),
1043                make_changeset("crate-b", BumpType::Patch, "Fix B"),
1044            ];
1045
1046            let mut config = HashMap::new();
1047            config.insert(
1048                "crate-a".to_string(),
1049                PackageReleaseConfigBuilder::default()
1050                    .prerelease(Some(PrereleaseSpec::Alpha))
1051                    .build()
1052                    .expect("all fields have defaults"),
1053            );
1054
1055            let plan = VersionPlanner::plan_releases_per_package(
1056                &changesets,
1057                &packages,
1058                &config,
1059                ZeroVersionBehavior::EffectiveMinor,
1060            )
1061            .expect("plan_releases_per_package");
1062
1063            let release_a = plan
1064                .releases()
1065                .iter()
1066                .find(|r| r.name() == "crate-a")
1067                .expect("crate-a should be in releases");
1068            let release_b = plan
1069                .releases()
1070                .iter()
1071                .find(|r| r.name() == "crate-b")
1072                .expect("crate-b should be in releases");
1073
1074            assert_eq!(
1075                release_a.new_version(),
1076                &"1.0.1-alpha.1".parse::<Version>().expect("valid")
1077            );
1078            assert_eq!(release_b.new_version(), &Version::new(1, 0, 1));
1079        }
1080
1081        #[test]
1082        fn per_package_graduation_applies_to_specific_crate() {
1083            let packages = vec![
1084                make_package("crate-a", "0.5.0"),
1085                make_package("crate-b", "0.3.0"),
1086            ];
1087            let changesets = vec![
1088                make_changeset("crate-a", BumpType::Minor, "Feature A"),
1089                make_changeset("crate-b", BumpType::Minor, "Feature B"),
1090            ];
1091
1092            let mut config = HashMap::new();
1093            config.insert(
1094                "crate-a".to_string(),
1095                PackageReleaseConfigBuilder::default()
1096                    .graduate_zero(true)
1097                    .build()
1098                    .expect("all fields have defaults"),
1099            );
1100
1101            let plan = VersionPlanner::plan_releases_per_package(
1102                &changesets,
1103                &packages,
1104                &config,
1105                ZeroVersionBehavior::EffectiveMinor,
1106            )
1107            .expect("plan_releases_per_package");
1108
1109            let release_a = plan
1110                .releases()
1111                .iter()
1112                .find(|r| r.name() == "crate-a")
1113                .expect("crate-a should be in releases");
1114            let release_b = plan
1115                .releases()
1116                .iter()
1117                .find(|r| r.name() == "crate-b")
1118                .expect("crate-b should be in releases");
1119
1120            assert_eq!(release_a.new_version(), &Version::new(1, 0, 0));
1121            assert_eq!(release_b.new_version(), &Version::new(0, 3, 1));
1122        }
1123
1124        #[test]
1125        fn graduation_without_changesets() {
1126            let packages = vec![make_package("crate-a", "0.5.0")];
1127            let changesets: Vec<Changeset> = vec![];
1128
1129            let mut config = HashMap::new();
1130            config.insert(
1131                "crate-a".to_string(),
1132                PackageReleaseConfigBuilder::default()
1133                    .graduate_zero(true)
1134                    .build()
1135                    .expect("all fields have defaults"),
1136            );
1137
1138            let plan = VersionPlanner::plan_releases_per_package(
1139                &changesets,
1140                &packages,
1141                &config,
1142                ZeroVersionBehavior::EffectiveMinor,
1143            )
1144            .expect("plan_releases_per_package");
1145
1146            assert_eq!(plan.releases().len(), 1);
1147            assert_eq!(plan.releases()[0].new_version(), &Version::new(1, 0, 0));
1148        }
1149
1150        #[test]
1151        fn graduation_with_prerelease_creates_prerelease_1_0_0() {
1152            let packages = vec![make_package("crate-a", "0.5.0")];
1153            let changesets = vec![make_changeset("crate-a", BumpType::Minor, "Feature")];
1154
1155            let mut config = HashMap::new();
1156            config.insert(
1157                "crate-a".to_string(),
1158                PackageReleaseConfigBuilder::default()
1159                    .prerelease(Some(PrereleaseSpec::Rc))
1160                    .graduate_zero(true)
1161                    .build()
1162                    .expect("all fields have defaults"),
1163            );
1164
1165            let plan = VersionPlanner::plan_releases_per_package(
1166                &changesets,
1167                &packages,
1168                &config,
1169                ZeroVersionBehavior::EffectiveMinor,
1170            )
1171            .expect("plan_releases_per_package");
1172
1173            assert_eq!(plan.releases().len(), 1);
1174            assert_eq!(
1175                plan.releases()[0].new_version(),
1176                &"1.0.0-rc.1".parse::<Version>().expect("valid")
1177            );
1178        }
1179
1180        #[test]
1181        fn empty_config_uses_defaults() {
1182            let packages = vec![make_package("crate-a", "1.0.0")];
1183            let changesets = vec![make_changeset("crate-a", BumpType::Patch, "Fix")];
1184            let config = HashMap::new();
1185
1186            let plan = VersionPlanner::plan_releases_per_package(
1187                &changesets,
1188                &packages,
1189                &config,
1190                ZeroVersionBehavior::EffectiveMinor,
1191            )
1192            .expect("plan_releases_per_package");
1193
1194            assert_eq!(plan.releases().len(), 1);
1195            assert_eq!(plan.releases()[0].new_version(), &Version::new(1, 0, 1));
1196        }
1197
1198        #[test]
1199        fn mixed_prerelease_and_stable_releases() {
1200            let packages = vec![
1201                make_package("alpha-crate", "1.0.0"),
1202                make_package("beta-crate", "2.0.0"),
1203                make_package("stable-crate", "3.0.0"),
1204            ];
1205            let changesets = vec![
1206                make_changeset("alpha-crate", BumpType::Minor, "Feature"),
1207                make_changeset("beta-crate", BumpType::Patch, "Fix"),
1208                make_changeset("stable-crate", BumpType::Major, "Breaking"),
1209            ];
1210
1211            let mut config = HashMap::new();
1212            config.insert(
1213                "alpha-crate".to_string(),
1214                PackageReleaseConfigBuilder::default()
1215                    .prerelease(Some(PrereleaseSpec::Alpha))
1216                    .build()
1217                    .expect("all fields have defaults"),
1218            );
1219            config.insert(
1220                "beta-crate".to_string(),
1221                PackageReleaseConfigBuilder::default()
1222                    .prerelease(Some(PrereleaseSpec::Beta))
1223                    .build()
1224                    .expect("all fields have defaults"),
1225            );
1226
1227            let plan = VersionPlanner::plan_releases_per_package(
1228                &changesets,
1229                &packages,
1230                &config,
1231                ZeroVersionBehavior::EffectiveMinor,
1232            )
1233            .expect("plan_releases_per_package");
1234
1235            assert_eq!(plan.releases().len(), 3);
1236
1237            let alpha = plan
1238                .releases()
1239                .iter()
1240                .find(|r| r.name() == "alpha-crate")
1241                .expect("alpha-crate should be in releases");
1242            let beta = plan
1243                .releases()
1244                .iter()
1245                .find(|r| r.name() == "beta-crate")
1246                .expect("beta-crate should be in releases");
1247            let stable = plan
1248                .releases()
1249                .iter()
1250                .find(|r| r.name() == "stable-crate")
1251                .expect("stable-crate should be in releases");
1252
1253            assert_eq!(
1254                alpha.new_version(),
1255                &"1.1.0-alpha.1".parse::<Version>().expect("valid")
1256            );
1257            assert_eq!(
1258                beta.new_version(),
1259                &"2.0.1-beta.1".parse::<Version>().expect("valid")
1260            );
1261            assert_eq!(stable.new_version(), &Version::new(4, 0, 0));
1262        }
1263
1264        #[test]
1265        fn changeset_graduate_field_combined_with_config() {
1266            let packages = vec![make_package("crate-a", "0.5.0")];
1267
1268            let changesets = vec![
1269                Changeset::new(
1270                    "Graduate".to_string(),
1271                    vec![PackageRelease::new("crate-a".to_string(), BumpType::Major)],
1272                    ChangeCategory::Changed,
1273                )
1274                .with_graduate(true),
1275            ];
1276
1277            let mut config = HashMap::new();
1278            config.insert(
1279                "crate-a".to_string(),
1280                PackageReleaseConfigBuilder::default()
1281                    .prerelease(Some(PrereleaseSpec::Rc))
1282                    .build()
1283                    .expect("all fields have defaults"),
1284            );
1285
1286            let plan = VersionPlanner::plan_releases_per_package(
1287                &changesets,
1288                &packages,
1289                &config,
1290                ZeroVersionBehavior::EffectiveMinor,
1291            )
1292            .expect("plan_releases_per_package");
1293
1294            assert_eq!(plan.releases().len(), 1);
1295            assert_eq!(
1296                plan.releases()[0].new_version(),
1297                &"1.0.0-rc.1".parse::<Version>().expect("valid")
1298            );
1299        }
1300
1301        #[test]
1302        fn config_graduation_without_changeset_graduation() {
1303            let packages = vec![make_package("crate-a", "0.5.0")];
1304            let changesets = vec![make_changeset("crate-a", BumpType::Minor, "Feature")];
1305
1306            let mut config = HashMap::new();
1307            config.insert(
1308                "crate-a".to_string(),
1309                PackageReleaseConfigBuilder::default()
1310                    .graduate_zero(true)
1311                    .build()
1312                    .expect("all fields have defaults"),
1313            );
1314
1315            let plan = VersionPlanner::plan_releases_per_package(
1316                &changesets,
1317                &packages,
1318                &config,
1319                ZeroVersionBehavior::EffectiveMinor,
1320            )
1321            .expect("plan_releases_per_package");
1322
1323            assert_eq!(plan.releases().len(), 1);
1324            assert_eq!(plan.releases()[0].new_version(), &Version::new(1, 0, 0));
1325        }
1326
1327        #[test]
1328        fn unknown_package_in_changeset_collected() {
1329            let packages = vec![make_package("known", "1.0.0")];
1330            let changesets = vec![make_changeset("unknown", BumpType::Patch, "Fix")];
1331            let config = HashMap::new();
1332
1333            let plan = VersionPlanner::plan_releases_per_package(
1334                &changesets,
1335                &packages,
1336                &config,
1337                ZeroVersionBehavior::EffectiveMinor,
1338            )
1339            .expect("plan_releases_per_package");
1340
1341            assert!(plan.releases().is_empty());
1342            assert_eq!(plan.unknown_packages(), &["unknown"]);
1343        }
1344
1345        #[test]
1346        fn config_without_changesets_ignored_for_unknown_package() {
1347            let packages = vec![make_package("known", "1.0.0")];
1348            let changesets: Vec<Changeset> = vec![];
1349
1350            let mut config = HashMap::new();
1351            config.insert(
1352                "unknown".to_string(),
1353                PackageReleaseConfigBuilder::default()
1354                    .prerelease(Some(PrereleaseSpec::Alpha))
1355                    .build()
1356                    .expect("all fields have defaults"),
1357            );
1358
1359            let plan = VersionPlanner::plan_releases_per_package(
1360                &changesets,
1361                &packages,
1362                &config,
1363                ZeroVersionBehavior::EffectiveMinor,
1364            )
1365            .expect("plan_releases_per_package");
1366
1367            assert!(plan.releases().is_empty());
1368        }
1369
1370        #[test]
1371        fn prerelease_only_config_without_changesets() {
1372            let packages = vec![make_package("crate-a", "1.0.0")];
1373            let changesets: Vec<Changeset> = vec![];
1374
1375            let mut config = HashMap::new();
1376            config.insert(
1377                "crate-a".to_string(),
1378                PackageReleaseConfigBuilder::default()
1379                    .prerelease(Some(PrereleaseSpec::Alpha))
1380                    .build()
1381                    .expect("all fields have defaults"),
1382            );
1383
1384            let plan = VersionPlanner::plan_releases_per_package(
1385                &changesets,
1386                &packages,
1387                &config,
1388                ZeroVersionBehavior::EffectiveMinor,
1389            )
1390            .expect("plan_releases_per_package");
1391
1392            assert_eq!(plan.releases().len(), 1);
1393            assert_eq!(
1394                plan.releases()[0].new_version(),
1395                &"1.0.1-alpha.1".parse::<Version>().expect("valid")
1396            );
1397        }
1398
1399        #[test]
1400        fn zero_behavior_applied_with_config() {
1401            let packages = vec![make_package("crate-a", "0.5.0")];
1402            let changesets = vec![make_changeset("crate-a", BumpType::Major, "Breaking")];
1403            let config = HashMap::new();
1404
1405            let plan = VersionPlanner::plan_releases_per_package(
1406                &changesets,
1407                &packages,
1408                &config,
1409                ZeroVersionBehavior::EffectiveMinor,
1410            )
1411            .expect("plan_releases_per_package");
1412
1413            assert_eq!(plan.releases().len(), 1);
1414            assert_eq!(plan.releases()[0].new_version(), &Version::new(0, 6, 0));
1415        }
1416
1417        #[test]
1418        fn auto_promote_behavior_with_config() {
1419            let packages = vec![make_package("crate-a", "0.5.0")];
1420            let changesets = vec![make_changeset("crate-a", BumpType::Major, "Breaking")];
1421            let config = HashMap::new();
1422
1423            let plan = VersionPlanner::plan_releases_per_package(
1424                &changesets,
1425                &packages,
1426                &config,
1427                ZeroVersionBehavior::AutoPromoteOnMajor,
1428            )
1429            .expect("plan_releases_per_package");
1430
1431            assert_eq!(plan.releases().len(), 1);
1432            assert_eq!(plan.releases()[0].new_version(), &Version::new(1, 0, 0));
1433        }
1434
1435        #[test]
1436        fn package_with_no_changesets_and_no_config_not_included() {
1437            let packages = vec![
1438                make_package("with-changeset", "1.0.0"),
1439                make_package("no-changeset", "2.0.0"),
1440            ];
1441            let changesets = vec![make_changeset("with-changeset", BumpType::Patch, "Fix")];
1442            let config = HashMap::new();
1443
1444            let plan = VersionPlanner::plan_releases_per_package(
1445                &changesets,
1446                &packages,
1447                &config,
1448                ZeroVersionBehavior::EffectiveMinor,
1449            )
1450            .expect("plan_releases_per_package");
1451
1452            assert_eq!(plan.releases().len(), 1);
1453            assert_eq!(plan.releases()[0].name(), "with-changeset");
1454            assert!(plan.unknown_packages().is_empty());
1455        }
1456
1457        #[test]
1458        fn graduation_only_config_without_changeset() {
1459            let packages = vec![make_package("crate-a", "0.5.0")];
1460            let changesets: Vec<Changeset> = vec![];
1461
1462            let mut config = HashMap::new();
1463            config.insert(
1464                "crate-a".to_string(),
1465                PackageReleaseConfigBuilder::default()
1466                    .graduate_zero(true)
1467                    .build()
1468                    .expect("all fields have defaults"),
1469            );
1470
1471            let plan = VersionPlanner::plan_releases_per_package(
1472                &changesets,
1473                &packages,
1474                &config,
1475                ZeroVersionBehavior::EffectiveMinor,
1476            )
1477            .expect("plan_releases_per_package");
1478
1479            assert_eq!(plan.releases().len(), 1);
1480            assert_eq!(plan.releases()[0].new_version(), &Version::new(1, 0, 0));
1481        }
1482
1483        #[test]
1484        fn config_with_neither_prerelease_nor_graduation_not_included() {
1485            let packages = vec![make_package("crate-a", "1.0.0")];
1486            let changesets: Vec<Changeset> = vec![];
1487
1488            let mut config = HashMap::new();
1489            config.insert(
1490                "crate-a".to_string(),
1491                PackageReleaseConfigBuilder::default()
1492                    .build()
1493                    .expect("all fields have defaults"),
1494            );
1495
1496            let plan = VersionPlanner::plan_releases_per_package(
1497                &changesets,
1498                &packages,
1499                &config,
1500                ZeroVersionBehavior::EffectiveMinor,
1501            )
1502            .expect("plan_releases_per_package");
1503
1504            assert!(plan.releases().is_empty());
1505        }
1506
1507        #[test]
1508        fn all_packages_graduating_simultaneously() {
1509            let packages = vec![
1510                make_package("crate-a", "0.1.0"),
1511                make_package("crate-b", "0.5.0"),
1512                make_package("crate-c", "0.9.0"),
1513            ];
1514            let changesets: Vec<Changeset> = vec![];
1515
1516            let mut config = HashMap::new();
1517            for pkg in &packages {
1518                config.insert(
1519                    pkg.name().clone(),
1520                    PackageReleaseConfigBuilder::default()
1521                        .graduate_zero(true)
1522                        .build()
1523                        .expect("all fields have defaults"),
1524                );
1525            }
1526
1527            let plan = VersionPlanner::plan_releases_per_package(
1528                &changesets,
1529                &packages,
1530                &config,
1531                ZeroVersionBehavior::EffectiveMinor,
1532            )
1533            .expect("plan_releases_per_package");
1534
1535            assert_eq!(plan.releases().len(), 3);
1536            for release in plan.releases() {
1537                assert_eq!(
1538                    *release.new_version(),
1539                    Version::new(1, 0, 0),
1540                    "{} should graduate to 1.0.0",
1541                    release.name()
1542                );
1543            }
1544        }
1545
1546        #[test]
1547        fn prerelease_graduation_with_prerelease_tag() {
1548            let packages = vec![make_package("crate-a", "0.5.0")];
1549            let changesets: Vec<Changeset> = vec![];
1550
1551            let mut config = HashMap::new();
1552            config.insert(
1553                "crate-a".to_string(),
1554                PackageReleaseConfigBuilder::default()
1555                    .prerelease(Some(PrereleaseSpec::Beta))
1556                    .graduate_zero(true)
1557                    .build()
1558                    .expect("all fields have defaults"),
1559            );
1560
1561            let plan = VersionPlanner::plan_releases_per_package(
1562                &changesets,
1563                &packages,
1564                &config,
1565                ZeroVersionBehavior::EffectiveMinor,
1566            )
1567            .expect("plan_releases_per_package");
1568
1569            assert_eq!(plan.releases().len(), 1);
1570            assert_eq!(
1571                plan.releases()[0].new_version(),
1572                &"1.0.0-beta.1".parse::<Version>().expect("valid")
1573            );
1574        }
1575    }
1576
1577    mod auto_promote_zero_behavior {
1578        use super::*;
1579
1580        #[test]
1581        fn auto_promote_with_major_bump_graduates() {
1582            let packages = vec![make_package("my-crate", "0.1.2")];
1583            let changesets = vec![make_changeset("my-crate", BumpType::Major, "Breaking")];
1584
1585            let plan = VersionPlanner::plan_releases_with_behavior(
1586                &changesets,
1587                &packages,
1588                None,
1589                ZeroVersionBehavior::AutoPromoteOnMajor,
1590            )
1591            .expect("plan_releases_with_behavior");
1592
1593            assert_eq!(plan.releases().len(), 1);
1594            assert_eq!(plan.releases()[0].new_version(), &Version::new(1, 0, 0));
1595        }
1596
1597        #[test]
1598        fn auto_promote_with_minor_bump_stays_zero() {
1599            let packages = vec![make_package("my-crate", "0.1.2")];
1600            let changesets = vec![make_changeset("my-crate", BumpType::Minor, "Feature")];
1601
1602            let plan = VersionPlanner::plan_releases_with_behavior(
1603                &changesets,
1604                &packages,
1605                None,
1606                ZeroVersionBehavior::AutoPromoteOnMajor,
1607            )
1608            .expect("plan_releases_with_behavior");
1609
1610            assert_eq!(plan.releases().len(), 1);
1611            assert_eq!(plan.releases()[0].new_version(), &Version::new(0, 2, 0));
1612        }
1613
1614        #[test]
1615        fn auto_promote_with_patch_bump_stays_zero() {
1616            let packages = vec![make_package("my-crate", "0.1.2")];
1617            let changesets = vec![make_changeset("my-crate", BumpType::Patch, "Fix")];
1618
1619            let plan = VersionPlanner::plan_releases_with_behavior(
1620                &changesets,
1621                &packages,
1622                None,
1623                ZeroVersionBehavior::AutoPromoteOnMajor,
1624            )
1625            .expect("plan_releases_with_behavior");
1626
1627            assert_eq!(plan.releases().len(), 1);
1628            assert_eq!(plan.releases()[0].new_version(), &Version::new(0, 1, 3));
1629        }
1630    }
1631
1632    mod unknown_packages {
1633        use super::*;
1634
1635        #[test]
1636        fn multiple_unknown_packages_collected() {
1637            let packages = vec![make_package("known", "1.0.0")];
1638            let changesets = vec![make_multi_changeset(
1639                vec![
1640                    ("unknown1", BumpType::Patch),
1641                    ("unknown2", BumpType::Minor),
1642                    ("unknown3", BumpType::Major),
1643                ],
1644                "Changes to unknown packages",
1645            )];
1646
1647            let plan =
1648                VersionPlanner::plan_releases(&changesets, &packages).expect("plan_releases");
1649
1650            assert!(plan.releases().is_empty());
1651            assert_eq!(plan.unknown_packages().len(), 3);
1652            assert!(plan.unknown_packages().contains(&"unknown1".to_string()));
1653            assert!(plan.unknown_packages().contains(&"unknown2".to_string()));
1654            assert!(plan.unknown_packages().contains(&"unknown3".to_string()));
1655        }
1656
1657        #[test]
1658        fn per_package_config_for_nonexistent_is_silently_ignored() {
1659            let packages = vec![make_package("known", "1.0.0")];
1660            let changesets: Vec<Changeset> = vec![];
1661
1662            let mut config = HashMap::new();
1663            config.insert(
1664                "nonexistent".to_string(),
1665                PackageReleaseConfigBuilder::default()
1666                    .prerelease(Some(PrereleaseSpec::Alpha))
1667                    .build()
1668                    .expect("all fields have defaults"),
1669            );
1670
1671            let plan = VersionPlanner::plan_releases_per_package(
1672                &changesets,
1673                &packages,
1674                &config,
1675                ZeroVersionBehavior::EffectiveMinor,
1676            )
1677            .expect("plan_releases_per_package");
1678
1679            assert!(plan.releases().is_empty());
1680            assert!(
1681                plan.unknown_packages().is_empty(),
1682                "config for nonexistent packages does not add to unknown_packages"
1683            );
1684        }
1685    }
1686
1687    mod none_bump_type {
1688        use super::*;
1689
1690        #[test]
1691        fn all_none_bumps_exclude_package_from_releases() {
1692            let packages = vec![make_package("my-crate", "1.0.0")];
1693            let changesets = vec![make_changeset("my-crate", BumpType::None, "internal")];
1694
1695            let plan =
1696                VersionPlanner::plan_releases(&changesets, &packages).expect("should succeed");
1697
1698            assert!(
1699                plan.releases().is_empty(),
1700                "package with only None bumps should not appear in releases"
1701            );
1702        }
1703
1704        #[test]
1705        fn none_bumps_still_tracked_in_aggregate() {
1706            let changesets = vec![make_changeset("my-crate", BumpType::None, "internal")];
1707
1708            let bumps = VersionPlanner::aggregate_bumps(&changesets);
1709
1710            assert!(bumps.contains_key("my-crate"));
1711            assert_eq!(bumps["my-crate"], vec![BumpType::None]);
1712        }
1713
1714        #[test]
1715        fn mixed_none_and_patch_uses_patch() {
1716            let packages = vec![make_package("my-crate", "1.0.0")];
1717            let changesets = vec![
1718                make_changeset("my-crate", BumpType::None, "internal"),
1719                make_changeset("my-crate", BumpType::Patch, "fix bug"),
1720            ];
1721
1722            let plan =
1723                VersionPlanner::plan_releases(&changesets, &packages).expect("should succeed");
1724
1725            assert_eq!(plan.releases().len(), 1);
1726            assert_eq!(plan.releases()[0].bump_type(), BumpType::Patch);
1727            assert_eq!(
1728                plan.releases()[0].new_version(),
1729                &Version::parse("1.0.1").expect("valid version")
1730            );
1731        }
1732
1733        #[test]
1734        fn all_none_excluded_from_behavior_plan() {
1735            let packages = vec![make_package("my-crate", "0.1.0")];
1736            let changesets = vec![make_changeset("my-crate", BumpType::None, "internal")];
1737
1738            let plan = VersionPlanner::plan_releases_with_behavior(
1739                &changesets,
1740                &packages,
1741                None,
1742                ZeroVersionBehavior::EffectiveMinor,
1743            )
1744            .expect("should succeed");
1745
1746            assert!(plan.releases().is_empty());
1747        }
1748
1749        #[test]
1750        fn all_none_excluded_from_per_package_plan() {
1751            let packages = vec![make_package("my-crate", "1.0.0")];
1752            let changesets = vec![make_changeset("my-crate", BumpType::None, "internal")];
1753
1754            let plan = VersionPlanner::plan_releases_per_package(
1755                &changesets,
1756                &packages,
1757                &HashMap::new(),
1758                ZeroVersionBehavior::EffectiveMinor,
1759            )
1760            .expect("should succeed");
1761
1762            assert!(plan.releases().is_empty());
1763        }
1764    }
1765}