Skip to main content

semifold_core/
planner.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use semver::VersionReq;
4
5use crate::{
6    BumpLevel, ChangesetId, PackageId, PackageRelease, PlanWarning, ReleaseChannel, ReleasePlan,
7    ReleasePlanError, ReleaseReason, VersionMap, VersionSource, VersionSourceId, VersioningError,
8    WorkspaceGraph, WorkspaceGraphError, bump_version,
9};
10
11/// Parsed changeset facts required by release planning.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct ChangesetInput {
14    pub id: ChangesetId,
15    pub releases: BTreeMap<PackageId, BumpLevel>,
16}
17
18/// Per-package channel and adapter-approved dependency propagation rules.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct PackageReleasePolicy {
21    pub channel: ReleaseChannel,
22    /// One-shot override for the stable base when first entering a named channel.
23    pub channel_bump: Option<BumpLevel>,
24    pub propagating_dependencies: BTreeMap<PackageId, Option<VersionReq>>,
25}
26
27pub type ReleasePolicies = BTreeMap<PackageId, PackageReleasePolicy>;
28
29/// Pure release-domain planner.
30pub struct ReleasePlanner;
31
32impl ReleasePlanner {
33    pub fn plan(
34        graph: &WorkspaceGraph,
35        changesets: &[ChangesetInput],
36        policies: &ReleasePolicies,
37    ) -> Result<ReleasePlan, ReleasePlannerError> {
38        Self::validate_policies(graph, policies)?;
39        let shared_version_groups = Self::shared_version_groups(graph);
40        Self::validate_shared_version_groups(graph, policies, &shared_version_groups)?;
41
42        let mut levels = graph
43            .packages()
44            .map(|package| (package.id.clone(), BumpLevel::Unchanged))
45            .collect::<BTreeMap<_, _>>();
46        let mut reasons = BTreeMap::<PackageId, BTreeSet<ReleaseReason>>::new();
47        for changeset in changesets {
48            for (package, bump) in &changeset.releases {
49                let level = levels.get_mut(package).ok_or_else(|| {
50                    ReleasePlannerError::UnknownChangesetPackage {
51                        changeset: changeset.id.clone(),
52                        package: package.clone(),
53                    }
54                })?;
55                *level = (*level).max(*bump);
56                if *bump != BumpLevel::Unchanged {
57                    reasons
58                        .entry(package.clone())
59                        .or_default()
60                        .insert(ReleaseReason::Changeset {
61                            changeset: changeset.id.clone(),
62                        });
63                }
64            }
65        }
66
67        Self::propagate_shared_versions(&shared_version_groups, &mut levels, &mut reasons)?;
68
69        let dependents = Self::dependents(policies);
70        let mut pending = levels
71            .iter()
72            .filter_map(|(package, bump)| {
73                (*bump != BumpLevel::Unchanged).then_some(package.clone())
74            })
75            .collect::<BTreeSet<_>>();
76        while let Some(dependency) = pending.pop_first() {
77            let snapshot = graph.package(&dependency).ok_or_else(|| {
78                ReleasePlannerError::WorkspacePackageMissing {
79                    package: dependency.clone(),
80                }
81            })?;
82            let level = levels.get(&dependency).copied().ok_or_else(|| {
83                ReleasePlannerError::ReleaseLevelMissing {
84                    package: dependency.clone(),
85                }
86            })?;
87            let policy = policies.get(&dependency).ok_or_else(|| {
88                ReleasePlannerError::MissingPackagePolicy {
89                    package: dependency.clone(),
90                }
91            })?;
92            let dependency_next =
93                bump_with_policy(&snapshot.version, level, policy).map_err(|source| {
94                    ReleasePlannerError::InvalidVersionTransition {
95                        package: dependency.clone(),
96                        source,
97                    }
98                })?;
99
100            for (dependent, requirement) in dependents.get(&dependency).into_iter().flatten() {
101                if requirement
102                    .as_ref()
103                    .is_some_and(|requirement| requirement.matches(&dependency_next))
104                {
105                    continue;
106                }
107                reasons.entry(dependent.clone()).or_default().insert(
108                    ReleaseReason::DependencyPropagation {
109                        dependency: dependency.clone(),
110                        next_version: dependency_next.clone(),
111                    },
112                );
113                let level = levels.get_mut(dependent).ok_or_else(|| {
114                    ReleasePlannerError::ReleaseLevelMissing {
115                        package: dependent.clone(),
116                    }
117                })?;
118                if *level == BumpLevel::Unchanged {
119                    *level = BumpLevel::Patch;
120                    pending.insert(dependent.clone());
121                    pending.extend(Self::propagate_shared_versions(
122                        &shared_version_groups,
123                        &mut levels,
124                        &mut reasons,
125                    )?);
126                }
127            }
128        }
129
130        let mut versions = VersionMap::new();
131        let mut packages = Vec::new();
132        let mut warnings = Vec::new();
133        for snapshot in graph.packages() {
134            let bump = levels.get(&snapshot.id).copied().ok_or_else(|| {
135                ReleasePlannerError::ReleaseLevelMissing {
136                    package: snapshot.id.clone(),
137                }
138            })?;
139            let policy = policies.get(&snapshot.id).ok_or_else(|| {
140                ReleasePlannerError::MissingPackagePolicy {
141                    package: snapshot.id.clone(),
142                }
143            })?;
144            let next_version =
145                bump_with_policy(&snapshot.version, bump, policy).map_err(|source| {
146                    ReleasePlannerError::InvalidVersionTransition {
147                        package: snapshot.id.clone(),
148                        source,
149                    }
150                })?;
151            versions.insert(snapshot.id.clone(), next_version.clone());
152            if bump == BumpLevel::Unchanged {
153                continue;
154            }
155            if matches!(policy.channel, ReleaseChannel::Stable)
156                && !snapshot.version.pre.is_empty()
157                && !matches!(bump, BumpLevel::Patch)
158            {
159                warnings.push(PlanWarning::NonPatchBumpOnPrerelease {
160                    package: snapshot.id.clone(),
161                    requested: bump,
162                });
163            }
164            packages.push(PackageRelease {
165                id: snapshot.id.clone(),
166                ecosystem: snapshot.ecosystem.clone(),
167                current_version: snapshot.version.clone(),
168                next_version,
169                bump,
170                reasons: reasons
171                    .remove(&snapshot.id)
172                    .unwrap_or_default()
173                    .into_iter()
174                    .collect(),
175            });
176        }
177
178        let order = graph
179            .topological_order()?
180            .into_iter()
181            .filter(|package| {
182                levels
183                    .get(package)
184                    .is_some_and(|level| *level != BumpLevel::Unchanged)
185            })
186            .collect();
187        ReleasePlan::new(
188            packages,
189            versions,
190            order,
191            changesets
192                .iter()
193                .map(|changeset| changeset.id.clone())
194                .collect(),
195            warnings,
196            vec![],
197        )
198        .map_err(ReleasePlannerError::InvalidPlan)
199    }
200
201    fn validate_policies(
202        graph: &WorkspaceGraph,
203        policies: &ReleasePolicies,
204    ) -> Result<(), ReleasePlannerError> {
205        for package in graph.packages() {
206            let policy = policies.get(&package.id).ok_or_else(|| {
207                ReleasePlannerError::MissingPackagePolicy {
208                    package: package.id.clone(),
209                }
210            })?;
211            for dependency in policy.propagating_dependencies.keys() {
212                if graph.package(dependency).is_none() {
213                    return Err(ReleasePlannerError::UnknownPolicyDependency {
214                        package: package.id.clone(),
215                        dependency: dependency.clone(),
216                    });
217                }
218                if !package
219                    .dependencies
220                    .iter()
221                    .any(|candidate| &candidate.package == dependency)
222                {
223                    return Err(ReleasePlannerError::PolicyDependencyIsNotGraphEdge {
224                        package: package.id.clone(),
225                        dependency: dependency.clone(),
226                    });
227                }
228            }
229        }
230        if let Some(package) = policies
231            .keys()
232            .find(|package| graph.package(package).is_none())
233        {
234            return Err(ReleasePlannerError::UnknownPolicyPackage {
235                package: package.clone(),
236            });
237        }
238        Ok(())
239    }
240
241    fn dependents(
242        policies: &ReleasePolicies,
243    ) -> BTreeMap<PackageId, Vec<(PackageId, Option<VersionReq>)>> {
244        let mut dependents = BTreeMap::<PackageId, Vec<_>>::new();
245        for (dependent, policy) in policies {
246            for (dependency, requirement) in &policy.propagating_dependencies {
247                dependents
248                    .entry(dependency.clone())
249                    .or_default()
250                    .push((dependent.clone(), requirement.clone()));
251            }
252        }
253        dependents
254    }
255
256    fn shared_version_groups(graph: &WorkspaceGraph) -> BTreeMap<VersionSourceId, Vec<PackageId>> {
257        let mut groups = BTreeMap::<VersionSourceId, Vec<PackageId>>::new();
258        for package in graph.packages() {
259            if let VersionSource::Shared { source } = &package.version_source {
260                groups
261                    .entry(source.clone())
262                    .or_default()
263                    .push(package.id.clone());
264            }
265        }
266        groups
267    }
268
269    fn validate_shared_version_groups(
270        graph: &WorkspaceGraph,
271        policies: &ReleasePolicies,
272        groups: &BTreeMap<VersionSourceId, Vec<PackageId>>,
273    ) -> Result<(), ReleasePlannerError> {
274        for (source, packages) in groups {
275            let Some(first_id) = packages.first() else {
276                continue;
277            };
278            let first = graph.package(first_id).ok_or_else(|| {
279                ReleasePlannerError::WorkspacePackageMissing {
280                    package: first_id.clone(),
281                }
282            })?;
283            let first_policy = policies.get(first_id).ok_or_else(|| {
284                ReleasePlannerError::MissingPackagePolicy {
285                    package: first_id.clone(),
286                }
287            })?;
288            for package_id in packages.iter().skip(1) {
289                let package = graph.package(package_id).ok_or_else(|| {
290                    ReleasePlannerError::WorkspacePackageMissing {
291                        package: package_id.clone(),
292                    }
293                })?;
294                if package.version != first.version {
295                    return Err(ReleasePlannerError::SharedVersionMismatch {
296                        version_source: source.clone(),
297                        first_package: first.id.clone(),
298                        first_version: Box::new(first.version.clone()),
299                        package: package.id.clone(),
300                        version: Box::new(package.version.clone()),
301                    });
302                }
303                let policy = policies.get(package_id).ok_or_else(|| {
304                    ReleasePlannerError::MissingPackagePolicy {
305                        package: package_id.clone(),
306                    }
307                })?;
308                if policy.channel != first_policy.channel {
309                    return Err(ReleasePlannerError::SharedVersionChannelMismatch {
310                        version_source: source.clone(),
311                        first_package: first.id.clone(),
312                        package: package.id.clone(),
313                    });
314                }
315                if policy.channel_bump != first_policy.channel_bump {
316                    return Err(ReleasePlannerError::SharedVersionChannelBumpMismatch {
317                        version_source: source.clone(),
318                        first_package: first.id.clone(),
319                        package: package.id.clone(),
320                    });
321                }
322            }
323        }
324        Ok(())
325    }
326
327    fn propagate_shared_versions(
328        groups: &BTreeMap<VersionSourceId, Vec<PackageId>>,
329        levels: &mut BTreeMap<PackageId, BumpLevel>,
330        reasons: &mut BTreeMap<PackageId, BTreeSet<ReleaseReason>>,
331    ) -> Result<BTreeSet<PackageId>, ReleasePlannerError> {
332        let mut changed = BTreeSet::new();
333        for (source, packages) in groups {
334            let bump = packages
335                .iter()
336                .try_fold(BumpLevel::Unchanged, |highest, package| {
337                    levels
338                        .get(package)
339                        .copied()
340                        .map(|level| highest.max(level))
341                        .ok_or_else(|| ReleasePlannerError::ReleaseLevelMissing {
342                            package: package.clone(),
343                        })
344                })?;
345            if bump == BumpLevel::Unchanged {
346                continue;
347            }
348            for package in packages {
349                let level = levels.get_mut(package).ok_or_else(|| {
350                    ReleasePlannerError::ReleaseLevelMissing {
351                        package: package.clone(),
352                    }
353                })?;
354                if *level < bump {
355                    *level = bump;
356                    reasons.entry(package.clone()).or_default().insert(
357                        ReleaseReason::SharedVersionPropagation {
358                            source: source.clone(),
359                        },
360                    );
361                    changed.insert(package.clone());
362                }
363            }
364        }
365        Ok(changed)
366    }
367}
368
369fn bump_with_policy(
370    current: &semver::Version,
371    changeset_bump: BumpLevel,
372    policy: &PackageReleasePolicy,
373) -> Result<semver::Version, VersioningError> {
374    let bump = if current.pre.is_empty() && matches!(policy.channel, ReleaseChannel::Named(_)) {
375        policy.channel_bump.unwrap_or(changeset_bump)
376    } else {
377        changeset_bump
378    };
379    if bump == BumpLevel::Unchanged && changeset_bump != BumpLevel::Unchanged {
380        let mut next = current.clone();
381        if let ReleaseChannel::Named(name) = &policy.channel {
382            next.pre = semver::Prerelease::new(&format!("{name}.0")).map_err(|error| {
383                VersioningError::InvalidChannel {
384                    channel: name.clone(),
385                    reason: error.to_string(),
386                }
387            })?;
388        }
389        return Ok(next);
390    }
391    bump_version(current, bump, &policy.channel)
392}
393
394#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
395pub enum ReleasePlannerError {
396    #[error("workspace package disappeared during release planning: {package}")]
397    WorkspacePackageMissing { package: PackageId },
398    #[error("release level is missing for package: {package}")]
399    ReleaseLevelMissing { package: PackageId },
400    #[error("missing release policy for package: {package}")]
401    MissingPackagePolicy { package: PackageId },
402    #[error("release policy references unknown package: {package}")]
403    UnknownPolicyPackage { package: PackageId },
404    #[error("release policy for {package} references unknown dependency {dependency}")]
405    UnknownPolicyDependency {
406        package: PackageId,
407        dependency: PackageId,
408    },
409    #[error("release policy for {package} references non-dependency {dependency}")]
410    PolicyDependencyIsNotGraphEdge {
411        package: PackageId,
412        dependency: PackageId,
413    },
414    #[error("changeset {changeset} references unknown package {package}")]
415    UnknownChangesetPackage {
416        changeset: ChangesetId,
417        package: PackageId,
418    },
419    #[error("invalid version transition for {package}: {source}")]
420    InvalidVersionTransition {
421        package: PackageId,
422        source: VersioningError,
423    },
424    #[error(
425        "shared version source {version_source:?} resolves to {first_version} for {first_package}, but {version} for {package}"
426    )]
427    SharedVersionMismatch {
428        version_source: VersionSourceId,
429        first_package: PackageId,
430        first_version: Box<semver::Version>,
431        package: PackageId,
432        version: Box<semver::Version>,
433    },
434    #[error(
435        "packages {first_package} and {package} use shared version source {version_source:?} but different release channels"
436    )]
437    SharedVersionChannelMismatch {
438        version_source: VersionSourceId,
439        first_package: PackageId,
440        package: PackageId,
441    },
442    #[error(
443        "packages {first_package} and {package} use shared version source {version_source:?} but different channel bump overrides"
444    )]
445    SharedVersionChannelBumpMismatch {
446        version_source: VersionSourceId,
447        first_package: PackageId,
448        package: PackageId,
449    },
450    #[error(transparent)]
451    Workspace(#[from] WorkspaceGraphError),
452    #[error("invalid release plan: {0}")]
453    InvalidPlan(ReleasePlanError),
454}
455
456#[cfg(test)]
457mod tests {
458    use camino::Utf8PathBuf;
459    use semver::{Version, VersionReq};
460
461    use super::*;
462    use crate::{
463        Dependency, DependencyKind, DependencySource, EcosystemId, PackageSnapshot, VersionSource,
464    };
465
466    fn package(id: &str, version: &str, dependencies: &[&str]) -> PackageSnapshot {
467        PackageSnapshot {
468            id: PackageId::new(id),
469            manifest_name: id.to_string(),
470            version: Version::parse(version).unwrap(),
471            version_source: VersionSource::PackageManifest,
472            ecosystem: EcosystemId::RUST,
473            path: Utf8PathBuf::from(format!("crates/{id}")),
474            publishable: true,
475            dependencies: dependencies
476                .iter()
477                .map(|dependency| Dependency {
478                    package: PackageId::new(*dependency),
479                    kind: DependencyKind::Runtime,
480                    requirement: None,
481                    source: DependencySource::Manifest,
482                })
483                .collect(),
484        }
485    }
486
487    fn shared_package(id: &str, version: &str, publishable: bool) -> PackageSnapshot {
488        let mut package = package(id, version, &[]);
489        package.version_source = VersionSource::Shared {
490            source: VersionSourceId {
491                manifest: "Cargo.toml".into(),
492                field: "workspace.package.version".to_string(),
493            },
494        };
495        package.publishable = publishable;
496        package
497    }
498
499    fn policies(packages: &[(&str, ReleaseChannel)]) -> ReleasePolicies {
500        packages
501            .iter()
502            .map(|(package, channel)| {
503                (
504                    PackageId::new(*package),
505                    PackageReleasePolicy {
506                        channel: channel.clone(),
507                        channel_bump: None,
508                        propagating_dependencies: BTreeMap::new(),
509                    },
510                )
511            })
512            .collect()
513    }
514
515    fn changeset(id: &str, releases: &[(&str, BumpLevel)]) -> ChangesetInput {
516        ChangesetInput {
517            id: ChangesetId::new(id),
518            releases: releases
519                .iter()
520                .map(|(package, bump)| (PackageId::new(*package), *bump))
521                .collect(),
522        }
523    }
524
525    #[test]
526    fn merges_changesets_and_keeps_a_complete_version_map() {
527        let graph = WorkspaceGraph::new(vec![
528            package("app", "1.0.0", &[]),
529            package("unchanged", "2.0.0", &[]),
530        ])
531        .unwrap();
532        let policies = policies(&[
533            ("app", ReleaseChannel::Stable),
534            ("unchanged", ReleaseChannel::Stable),
535        ]);
536
537        let plan = ReleasePlanner::plan(
538            &graph,
539            &[
540                changeset("patch", &[("app", BumpLevel::Patch)]),
541                changeset("feature", &[("app", BumpLevel::Minor)]),
542            ],
543            &policies,
544        )
545        .unwrap();
546
547        let app = plan.package(&PackageId::new("app")).unwrap();
548        assert_eq!(app.bump, BumpLevel::Minor);
549        assert_eq!(app.next_version, Version::new(1, 1, 0));
550        assert_eq!(app.reasons.len(), 2);
551        assert_eq!(
552            plan.versions()[&PackageId::new("unchanged")],
553            Version::new(2, 0, 0)
554        );
555        assert_eq!(
556            plan.consumed_changesets(),
557            [ChangesetId::new("feature"), ChangesetId::new("patch")]
558        );
559    }
560
561    #[test]
562    fn shared_version_groups_take_the_highest_bump_and_release_every_member() {
563        let graph = WorkspaceGraph::new(vec![
564            shared_package("public", "1.2.3", true),
565            shared_package("private", "1.2.3", false),
566        ])
567        .unwrap();
568        let policies = policies(&[
569            ("public", ReleaseChannel::Stable),
570            ("private", ReleaseChannel::Stable),
571        ]);
572
573        let plan = ReleasePlanner::plan(
574            &graph,
575            &[
576                changeset("public-fix", &[("public", BumpLevel::Patch)]),
577                changeset("private-feature", &[("private", BumpLevel::Minor)]),
578            ],
579            &policies,
580        )
581        .unwrap();
582
583        assert_eq!(plan.packages().len(), 2);
584        for package in ["public", "private"] {
585            let release = plan.package(&PackageId::new(package)).unwrap();
586            assert_eq!(release.bump, BumpLevel::Minor);
587            assert_eq!(release.next_version, Version::new(1, 3, 0));
588        }
589        assert!(
590            plan.package(&PackageId::new("public"))
591                .unwrap()
592                .reasons
593                .iter()
594                .any(|reason| matches!(reason, ReleaseReason::SharedVersionPropagation { .. }))
595        );
596    }
597
598    #[test]
599    fn shared_version_groups_reject_inconsistent_channels_and_overrides() {
600        let graph = WorkspaceGraph::new(vec![
601            shared_package("a", "1.2.3", true),
602            shared_package("b", "1.2.3", true),
603        ])
604        .unwrap();
605        let channels = policies(&[
606            ("a", ReleaseChannel::Stable),
607            ("b", ReleaseChannel::Named("alpha".to_string())),
608        ]);
609
610        assert!(matches!(
611            ReleasePlanner::plan(&graph, &[], &channels),
612            Err(ReleasePlannerError::SharedVersionChannelMismatch { .. })
613        ));
614
615        let mut overrides = policies(&[
616            ("a", ReleaseChannel::Named("alpha".to_string())),
617            ("b", ReleaseChannel::Named("alpha".to_string())),
618        ]);
619        overrides
620            .get_mut(&PackageId::new("a"))
621            .unwrap()
622            .channel_bump = Some(BumpLevel::Unchanged);
623        overrides
624            .get_mut(&PackageId::new("b"))
625            .unwrap()
626            .channel_bump = Some(BumpLevel::Minor);
627        assert!(matches!(
628            ReleasePlanner::plan(&graph, &[], &overrides),
629            Err(ReleasePlannerError::SharedVersionChannelBumpMismatch { .. })
630        ));
631    }
632
633    #[test]
634    fn channel_bump_overrides_only_the_first_named_channel_base() {
635        let graph = WorkspaceGraph::new(vec![package("app", "0.1.0", &[])]).unwrap();
636        for (override_bump, expected) in [
637            (BumpLevel::Unchanged, "0.1.0-alpha.0"),
638            (BumpLevel::Patch, "0.1.1-alpha.0"),
639            (BumpLevel::Minor, "0.2.0-alpha.0"),
640            (BumpLevel::Major, "1.0.0-alpha.0"),
641        ] {
642            let mut policies = policies(&[("app", ReleaseChannel::Named("alpha".into()))]);
643            policies
644                .get_mut(&PackageId::new("app"))
645                .unwrap()
646                .channel_bump = Some(override_bump);
647            let plan = ReleasePlanner::plan(
648                &graph,
649                &[changeset("feature", &[("app", BumpLevel::Patch)])],
650                &policies,
651            )
652            .unwrap();
653            assert_eq!(
654                plan.versions()[&PackageId::new("app")].to_string(),
655                expected
656            );
657        }
658    }
659
660    #[test]
661    fn propagates_transitively_only_when_constraints_stop_matching() {
662        let graph = WorkspaceGraph::new(vec![
663            package("app", "1.0.0", &["middle"]),
664            package("middle", "1.0.0", &["core"]),
665            package("core", "1.0.0", &[]),
666        ])
667        .unwrap();
668        let mut policies = policies(&[
669            ("app", ReleaseChannel::Stable),
670            ("middle", ReleaseChannel::Stable),
671            ("core", ReleaseChannel::Stable),
672        ]);
673        policies
674            .get_mut(&PackageId::new("middle"))
675            .unwrap()
676            .propagating_dependencies
677            .insert(
678                PackageId::new("core"),
679                Some(VersionReq::parse("~1.0.0").unwrap()),
680            );
681        policies
682            .get_mut(&PackageId::new("app"))
683            .unwrap()
684            .propagating_dependencies
685            .insert(
686                PackageId::new("middle"),
687                Some(VersionReq::parse("=1.0.0").unwrap()),
688            );
689
690        let plan = ReleasePlanner::plan(
691            &graph,
692            &[changeset("core-feature", &[("core", BumpLevel::Minor)])],
693            &policies,
694        )
695        .unwrap();
696
697        assert_eq!(plan.order(), ["core", "middle", "app"].map(PackageId::new));
698        assert_eq!(
699            plan.package(&PackageId::new("middle")).unwrap().bump,
700            BumpLevel::Patch
701        );
702        assert_eq!(
703            plan.package(&PackageId::new("app")).unwrap().bump,
704            BumpLevel::Patch
705        );
706    }
707
708    #[test]
709    fn does_not_propagate_when_the_next_version_matches() {
710        let graph = WorkspaceGraph::new(vec![
711            package("app", "1.0.0", &["core"]),
712            package("core", "1.0.0", &[]),
713        ])
714        .unwrap();
715        let mut policies = policies(&[
716            ("app", ReleaseChannel::Stable),
717            ("core", ReleaseChannel::Stable),
718        ]);
719        policies
720            .get_mut(&PackageId::new("app"))
721            .unwrap()
722            .propagating_dependencies
723            .insert(
724                PackageId::new("core"),
725                Some(VersionReq::parse("^1").unwrap()),
726            );
727
728        let plan = ReleasePlanner::plan(
729            &graph,
730            &[changeset("core-feature", &[("core", BumpLevel::Minor)])],
731            &policies,
732        )
733        .unwrap();
734
735        assert!(plan.package(&PackageId::new("app")).is_none());
736        assert_eq!(plan.order(), [PackageId::new("core")]);
737    }
738
739    #[test]
740    fn keeps_explicit_bump_and_adds_dependency_reason() {
741        let graph = WorkspaceGraph::new(vec![
742            package("app", "1.0.0", &["core"]),
743            package("core", "1.0.0", &[]),
744        ])
745        .unwrap();
746        let mut policies = policies(&[
747            ("app", ReleaseChannel::Stable),
748            ("core", ReleaseChannel::Stable),
749        ]);
750        policies
751            .get_mut(&PackageId::new("app"))
752            .unwrap()
753            .propagating_dependencies
754            .insert(PackageId::new("core"), None);
755
756        let plan = ReleasePlanner::plan(
757            &graph,
758            &[
759                changeset("core-feature", &[("core", BumpLevel::Minor)]),
760                changeset("app-breaking", &[("app", BumpLevel::Major)]),
761            ],
762            &policies,
763        )
764        .unwrap();
765
766        let app = plan.package(&PackageId::new("app")).unwrap();
767        assert_eq!(app.bump, BumpLevel::Major);
768        assert!(app.reasons.iter().any(|reason| matches!(
769            reason,
770            ReleaseReason::DependencyPropagation { dependency, .. }
771                if dependency == &PackageId::new("core")
772        )));
773    }
774
775    #[test]
776    fn emits_prerelease_warning_and_is_deterministic() {
777        let graph = WorkspaceGraph::new(vec![package("app", "2.0.0-alpha.1", &[])]).unwrap();
778        let policies = policies(&[("app", ReleaseChannel::Stable)]);
779        let first = ReleasePlanner::plan(
780            &graph,
781            &[
782                changeset("minor", &[("app", BumpLevel::Minor)]),
783                changeset("major", &[("app", BumpLevel::Major)]),
784            ],
785            &policies,
786        )
787        .unwrap();
788        let second = ReleasePlanner::plan(
789            &graph,
790            &[
791                changeset("major", &[("app", BumpLevel::Major)]),
792                changeset("minor", &[("app", BumpLevel::Minor)]),
793            ],
794            &policies,
795        )
796        .unwrap();
797
798        assert_eq!(first, second);
799        assert_eq!(
800            first.warnings(),
801            [PlanWarning::NonPatchBumpOnPrerelease {
802                package: PackageId::new("app"),
803                requested: BumpLevel::Major,
804            }]
805        );
806        assert_eq!(
807            first.package(&PackageId::new("app")).unwrap().next_version,
808            Version::new(2, 0, 0)
809        );
810    }
811
812    #[test]
813    fn rejects_missing_package_policy() {
814        let graph = WorkspaceGraph::new(vec![package("app", "1.0.0", &[])]).unwrap();
815        let error = ReleasePlanner::plan(&graph, &[], &BTreeMap::new()).unwrap_err();
816
817        assert_eq!(
818            error,
819            ReleasePlannerError::MissingPackagePolicy {
820                package: PackageId::new("app"),
821            }
822        );
823    }
824
825    #[test]
826    fn rejects_changesets_for_unknown_packages() {
827        let graph = WorkspaceGraph::new(vec![package("app", "1.0.0", &[])]).unwrap();
828        let policies = policies(&[("app", ReleaseChannel::Stable)]);
829        let error = ReleasePlanner::plan(
830            &graph,
831            &[changeset("unknown", &[("missing", BumpLevel::Patch)])],
832            &policies,
833        )
834        .unwrap_err();
835
836        assert_eq!(
837            error,
838            ReleasePlannerError::UnknownChangesetPackage {
839                changeset: ChangesetId::new("unknown"),
840                package: PackageId::new("missing"),
841            }
842        );
843    }
844}