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