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#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct ChangesetInput {
14 pub id: ChangesetId,
15 pub releases: BTreeMap<PackageId, BumpLevel>,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct PackageReleasePolicy {
21 pub channel: ReleaseChannel,
22 pub channel_bump: Option<BumpLevel>,
24 pub propagating_dependencies: BTreeMap<PackageId, Option<VersionReq>>,
25}
26
27pub type ReleasePolicies = BTreeMap<PackageId, PackageReleasePolicy>;
28
29pub 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}