1use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::model::{Bump, ReleaseUnitDisposition};
11use semver::Version;
12use std::collections::{BTreeMap, BTreeSet, HashMap};
13use std::path::Path;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ReleaseUnitVersion {
18 pub current: Version,
20 pub next: Version,
22 pub bump: Bump,
24 pub graduates: bool,
26}
27
28impl ReleaseUnitVersion {
29 pub fn new(current: Version, bump: Bump) -> Self {
31 let next = bump_version(¤t, bump);
32 Self {
33 current,
34 next,
35 bump,
36 graduates: false,
37 }
38 }
39}
40
41pub fn aggregate_bumps<'a>(
43 intents: impl IntoIterator<Item = &'a BTreeMap<String, Bump>>,
44) -> BTreeMap<String, Bump> {
45 let mut aggregate: BTreeMap<String, Bump> = BTreeMap::new();
46 for intent in intents {
47 for (release_unit, bump) in intent {
48 aggregate
49 .entry(release_unit.clone())
50 .and_modify(|existing| *existing = (*existing).max(*bump))
51 .or_insert(*bump);
52 }
53 }
54 aggregate
55}
56
57pub fn aggregate_graduations<'a>(
59 intents: impl IntoIterator<Item = &'a BTreeSet<String>>,
60) -> BTreeSet<String> {
61 intents.into_iter().flatten().cloned().collect()
62}
63
64pub fn effective_bumps(
66 config: &Config,
67 declared: &BTreeMap<String, Bump>,
68) -> BTreeMap<String, Bump> {
69 let mut effective = declared
70 .iter()
71 .filter(|(id, _)| config.release_units.contains_key(*id))
72 .map(|(id, bump)| (id.clone(), *bump))
73 .collect::<BTreeMap<_, _>>();
74 for id in config.release_units.keys() {
75 effective.entry(id.clone()).or_default();
76 }
77
78 loop {
79 let mut changed = false;
80 for (id, release_unit) in &config.release_units {
81 for dependency in &release_unit.depends_on {
82 if effective[dependency] == Bump::None || !share_ecosystem(config, id, dependency) {
83 continue;
84 }
85 let required = config.settings.internal_dependency_bump;
86 if effective[id] < required {
87 effective.insert(id.clone(), required);
88 changed = true;
89 }
90 }
91 }
92 for group in &config.fixed {
93 let group_bump = group
94 .iter()
95 .map(|id| effective[id])
96 .max()
97 .unwrap_or(Bump::None);
98 if group_bump != Bump::None {
99 for id in group {
100 if effective[id] < group_bump {
101 effective.insert(id.clone(), group_bump);
102 changed = true;
103 }
104 }
105 }
106 }
107 for group in &config.linked {
108 let group_bump = group
109 .iter()
110 .map(|id| effective[id])
111 .max()
112 .unwrap_or(Bump::None);
113 if group_bump != Bump::None {
114 for id in group {
115 if effective[id] != Bump::None && effective[id] < group_bump {
116 effective.insert(id.clone(), group_bump);
117 changed = true;
118 }
119 }
120 }
121 }
122 if !changed {
123 break;
124 }
125 }
126 effective
127}
128
129pub fn resolve_versions(
136 config: &Config,
137 declared: &BTreeMap<String, Bump>,
138 graduating: &BTreeSet<String>,
139 current: &BTreeMap<String, Version>,
140) -> Result<BTreeMap<String, ReleaseUnitVersion>> {
141 resolve_versions_with(config, declared, graduating, current, bump_version)
142}
143
144pub(crate) fn resolve_versions_with(
147 config: &Config,
148 declared: &BTreeMap<String, Bump>,
149 graduating: &BTreeSet<String>,
150 current: &BTreeMap<String, Version>,
151 bump: fn(&Version, Bump) -> Version,
152) -> Result<BTreeMap<String, ReleaseUnitVersion>> {
153 let effective = effective_bumps(config, declared);
154 for (id, bump) in &effective {
155 if *bump != Bump::None
156 && config.release_units[id].disposition == ReleaseUnitDisposition::Suspended
157 {
158 return Err(Error::Validation(format!(
159 "release requires suspended release unit {id}"
160 )));
161 }
162 }
163 for id in graduating {
164 if config.release_units.contains_key(id) && effective[id] != Bump::Major {
165 return Err(Error::Validation(format!(
166 "release unit {id} graduates, which requires bump major"
167 )));
168 }
169 }
170
171 let mut resolved = BTreeMap::new();
172 let mut grouped = BTreeMap::<String, (&str, usize)>::new();
173 for (index, group) in config.fixed.iter().enumerate() {
174 for id in group {
175 grouped.insert(id.clone(), ("fixed", index));
176 }
177 }
178 for (index, group) in config.linked.iter().enumerate() {
179 for id in group {
180 grouped.insert(id.clone(), ("linked", index));
181 }
182 }
183
184 for (id, release_unit) in &config.release_units {
185 if grouped.contains_key(id) {
186 continue;
187 }
188 let current_version = current.get(id).ok_or_else(|| {
189 Error::Validation(format!("missing current version for release unit {id}"))
190 })?;
191 let versions = if graduating.contains(id) {
192 graduation(id, current_version)?
193 } else {
194 ReleaseUnitVersion {
195 current: current_version.clone(),
196 next: bump(current_version, effective[id]),
197 bump: effective[id],
198 graduates: false,
199 }
200 };
201 resolved.insert(id.clone(), versions);
202 if release_unit.disposition == ReleaseUnitDisposition::Suspended {
203 debug_assert_eq!(effective[id], Bump::None);
204 }
205 }
206
207 for group in &config.fixed {
208 resolve_group(
209 group,
210 true,
211 &effective,
212 graduating,
213 current,
214 bump,
215 &mut resolved,
216 )?;
217 }
218 for group in &config.linked {
219 resolve_group(
220 group,
221 false,
222 &effective,
223 graduating,
224 current,
225 bump,
226 &mut resolved,
227 )?;
228 }
229 Ok(resolved)
230}
231
232fn graduation(id: &str, current: &Version) -> Result<ReleaseUnitVersion> {
233 ensure_graduation_source(id, current)?;
234 Ok(ReleaseUnitVersion {
235 current: current.clone(),
236 next: Version::new(1, 0, 0),
237 bump: Bump::Major,
238 graduates: true,
239 })
240}
241
242fn ensure_graduation_source(id: &str, version: &Version) -> Result<()> {
243 if version.major != 0 {
244 return Err(Error::Validation(format!(
245 "release unit {id} graduates, but its version is {version}; graduate is valid only at 0.x"
246 )));
247 }
248 Ok(())
249}
250
251fn resolve_group(
252 group: &[String],
253 fixed: bool,
254 effective: &BTreeMap<String, Bump>,
255 graduating: &BTreeSet<String>,
256 current: &BTreeMap<String, Version>,
257 bump: fn(&Version, Bump) -> Version,
258 resolved: &mut BTreeMap<String, ReleaseUnitVersion>,
259) -> Result<()> {
260 let highest_current = group
261 .iter()
262 .map(|id| {
263 current.get(id).ok_or_else(|| {
264 Error::Validation(format!("missing current version for release unit {id}"))
265 })
266 })
267 .collect::<Result<Vec<_>>>()?
268 .into_iter()
269 .max()
270 .expect("validated groups are non-empty")
271 .clone();
272 let highest_bump = group
273 .iter()
274 .map(|id| effective[id])
275 .max()
276 .unwrap_or(Bump::None);
277 let graduate = group.iter().find(|id| graduating.contains(*id));
278 let shared_next = match graduate {
279 Some(id) => {
280 ensure_graduation_source(id, &highest_current)?;
283 Version::new(1, 0, 0)
284 }
285 None => bump(&highest_current, highest_bump),
286 };
287 for id in group {
288 let member_current = current[id].clone();
289 let releases = highest_bump != Bump::None && (fixed || effective[id] != Bump::None);
290 resolved.insert(
291 id.clone(),
292 ReleaseUnitVersion {
293 current: member_current.clone(),
294 next: if releases {
295 shared_next.clone()
296 } else {
297 member_current
298 },
299 bump: if releases { highest_bump } else { Bump::None },
300 graduates: releases && graduate.is_some(),
301 },
302 );
303 }
304 Ok(())
305}
306
307fn share_ecosystem(config: &Config, left: &str, right: &str) -> bool {
308 config.release_units[left]
309 .projections
310 .iter()
311 .any(|left_projection| {
312 left_projection.adapter.ecosystem().is_some()
313 && config.release_units[right]
314 .projections
315 .iter()
316 .any(|right_projection| {
317 left_projection.adapter.ecosystem() == right_projection.adapter.ecosystem()
318 })
319 })
320}
321
322pub fn bump_version(current: &Version, bump: Bump) -> Version {
331 let mut next = current.clone();
332 next.pre = semver::Prerelease::EMPTY;
333 next.build = semver::BuildMetadata::EMPTY;
334 if next.major == 0 {
335 match bump {
336 Bump::None => {}
337 Bump::Major => {
338 next.minor += 1;
339 next.patch = 0;
340 }
341 Bump::Minor | Bump::Patch => next.patch += 1,
342 }
343 return next;
344 }
345 match bump {
346 Bump::None => {}
347 Bump::Patch => next.patch += 1,
348 Bump::Minor => {
349 next.minor += 1;
350 next.patch = 0;
351 }
352 Bump::Major => {
353 next.major += 1;
354 next.minor = 0;
355 next.patch = 0;
356 }
357 }
358 next
359}
360
361pub(crate) fn component_bump(current: &Version, bump: Bump) -> Version {
364 let mut next = current.clone();
365 next.pre = semver::Prerelease::EMPTY;
366 next.build = semver::BuildMetadata::EMPTY;
367 match bump {
368 Bump::None => {}
369 Bump::Patch => next.patch += 1,
370 Bump::Minor => {
371 next.minor += 1;
372 next.patch = 0;
373 }
374 Bump::Major => {
375 next.major += 1;
376 next.minor = 0;
377 next.patch = 0;
378 }
379 }
380 next
381}
382
383pub struct VersionRepository {
385 repository: tagver::Repository,
386}
387
388impl VersionRepository {
389 pub fn discover(path: &Path) -> Result<Self> {
391 let repository =
392 tagver::Repository::discover(path).map_err(|error| Error::Git(error.to_string()))?;
393 Ok(Self { repository })
394 }
395
396 pub fn current_version(&self, release_unit_id: &str, template: &str) -> Result<Version> {
398 let matches = self.matching_tags(release_unit_id, template)?;
399 let (version, _) = self.walk_to_tag(&matches, false)?;
400 Ok(version.unwrap_or_else(|| Version::new(0, 0, 0)))
401 }
402
403 pub fn current_version_before(
405 &self,
406 release_unit_id: &str,
407 template: &str,
408 excluded_target: gix::ObjectId,
409 ) -> Result<Version> {
410 let mut matches = self.matching_tags(release_unit_id, template)?;
411 matches.remove(&excluded_target);
412 let (version, _) = self.walk_to_tag(&matches, false)?;
413 Ok(version.unwrap_or_else(|| Version::new(0, 0, 0)))
414 }
415
416 pub fn has_matching_tag(&self, release_unit_id: &str, template: &str) -> Result<bool> {
418 Ok(!self.matching_tags(release_unit_id, template)?.is_empty())
419 }
420
421 pub fn height(&self, release_unit_id: &str, template: &str) -> Result<u64> {
423 let matches = self.matching_tags(release_unit_id, template)?;
424 let (_, height) = self.walk_to_tag(&matches, true)?;
425 Ok(height)
426 }
427
428 pub fn all_versions(&self, release_unit_id: &str, template: &str) -> Result<Vec<Version>> {
430 let tags = self.matching_tags(release_unit_id, template)?;
431 let mut versions: Vec<_> = tags.into_values().flatten().collect();
432 versions.sort();
433 versions.dedup();
434 Ok(versions)
435 }
436
437 pub fn all_versions_before(
439 &self,
440 release_unit_id: &str,
441 template: &str,
442 excluded_target: gix::ObjectId,
443 ) -> Result<Vec<Version>> {
444 let mut tags = self.matching_tags(release_unit_id, template)?;
445 tags.remove(&excluded_target);
446 let mut versions: Vec<_> = tags.into_values().flatten().collect();
447 versions.sort();
448 versions.dedup();
449 Ok(versions)
450 }
451
452 fn matching_tags(
453 &self,
454 release_unit_id: &str,
455 template: &str,
456 ) -> Result<HashMap<gix::ObjectId, Vec<Version>>> {
457 let version_marker = "{version}";
458 let rendered = template.replace("{id}", release_unit_id);
459 let (prefix, suffix) = rendered.split_once(version_marker).ok_or_else(|| {
460 Error::Validation(format!(
461 "invalid tag template for release unit {release_unit_id}"
462 ))
463 })?;
464
465 let repository = self.repository.inner();
466 let references = repository
467 .references()
468 .map_err(|error| Error::Git(format!("failed to read references: {error}")))?;
469 let tag_references = references
470 .tags()
471 .map_err(|error| Error::Git(format!("failed to read tags: {error}")))?;
472 let mut matches: HashMap<gix::ObjectId, Vec<Version>> = HashMap::new();
473
474 for mut reference in tag_references.flatten() {
475 let name = reference.name().shorten().to_string();
476 let Some(version_text) = name
477 .strip_prefix(prefix)
478 .and_then(|rest| rest.strip_suffix(suffix))
479 else {
480 continue;
481 };
482 let Ok(version) = Version::parse(version_text) else {
483 continue;
484 };
485 let Ok(target) = reference.peel_to_id() else {
486 continue;
487 };
488 matches.entry(target.detach()).or_default().push(version);
489 }
490
491 for versions in matches.values_mut() {
492 versions.sort_by(|left, right| right.cmp(left));
493 }
494 Ok(matches)
495 }
496
497 fn walk_to_tag(
498 &self,
499 matches: &HashMap<gix::ObjectId, Vec<Version>>,
500 include_prerelease: bool,
501 ) -> Result<(Option<Version>, u64)> {
502 let repository = self.repository.inner();
503 let mut head = repository
504 .head()
505 .map_err(|error| Error::Git(format!("failed to read HEAD: {error}")))?;
506 let Some(mut current) = head
507 .try_peel_to_id()
508 .map_err(|error| Error::Git(format!("failed to resolve HEAD: {error}")))?
509 .map(|id| id.detach())
510 else {
511 return Ok((None, 0));
512 };
513 let mut height = 0;
514
515 loop {
516 if let Some(versions) = matches.get(¤t) {
517 if let Some(version) = versions
518 .iter()
519 .find(|version| include_prerelease || version.pre.is_empty())
520 {
521 return Ok((Some(version.clone()), height));
522 }
523 }
524
525 let commit = repository
526 .find_object(current)
527 .map_err(|error| Error::Git(format!("failed to find commit: {error}")))?
528 .try_into_commit()
529 .map_err(|error| Error::Git(format!("tag target is not a commit: {error}")))?;
530 let Some(parent) = commit.parent_ids().next() else {
531 return Ok((None, height));
532 };
533 current = parent.detach();
534 height += 1;
535 }
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn maximum_bump_wins() {
545 let first = BTreeMap::from([
546 ("one".to_owned(), Bump::Patch),
547 ("two".to_owned(), Bump::Minor),
548 ]);
549 let second = BTreeMap::from([
550 ("one".to_owned(), Bump::Major),
551 ("two".to_owned(), Bump::Patch),
552 ]);
553 let aggregate = aggregate_bumps([&first, &second]);
554 assert_eq!(aggregate["one"], Bump::Major);
555 assert_eq!(aggregate["two"], Bump::Minor);
556 }
557
558 #[test]
559 fn significance_before_1_0() {
560 let current = Version::parse("0.4.1").expect("valid version");
561 assert_eq!(bump_version(¤t, Bump::Major), Version::new(0, 5, 0));
562 assert_eq!(bump_version(¤t, Bump::Minor), Version::new(0, 4, 2));
563 assert_eq!(bump_version(¤t, Bump::Patch), Version::new(0, 4, 2));
564 }
565
566 #[test]
567 fn strict_semver_at_1_0_and_later() {
568 let current = Version::parse("1.4.1").expect("valid version");
569 assert_eq!(bump_version(¤t, Bump::Major), Version::new(2, 0, 0));
570 assert_eq!(bump_version(¤t, Bump::Minor), Version::new(1, 5, 0));
571 assert_eq!(bump_version(¤t, Bump::Patch), Version::new(1, 4, 2));
572 }
573
574 #[test]
575 fn bump_removes_prerelease_and_build_metadata() {
576 let current = Version::parse("1.2.3-beta.2+build.1").expect("valid version");
577 assert_eq!(bump_version(¤t, Bump::Patch), Version::new(1, 2, 4));
578 }
579
580 #[test]
581 fn dependency_bumps_propagate_only_in_shared_ecosystems() {
582 let config = Config::from_yaml(
583 r#"
584contract: contract-2
585release-units:
586 library:
587 path: library
588 projections:
589 - { adapter: npm, file: package.json, mode: committed }
590 tags:
591 primary: { role: primary, template: '{id}@{version}' }
592 application:
593 path: application
594 depends-on: [library]
595 projections:
596 - { adapter: npm, file: package.json, mode: committed }
597 tags:
598 primary: { role: primary, template: '{id}@{version}' }
599 unrelated:
600 path: unrelated
601 depends-on: [library]
602 projections:
603 - { adapter: cargo, file: Cargo.toml, mode: committed }
604 tags:
605 primary: { role: primary, template: '{id}@{version}' }
606 bundle:
607 path: bundle
608 depends-on: [library]
609 projections:
610 - { adapter: npm, file: package.json, mode: committed }
611 - { adapter: yaml, file: bundle.yml, pointer: /version, mode: committed }
612 tags:
613 primary: { role: primary, template: '{id}@{version}' }
614 manual:
615 path: manual
616 projections:
617 - { adapter: yaml, file: manual.yml, pointer: /version, mode: committed }
618 tags:
619 primary: { role: primary, template: '{id}@{version}' }
620 manual-index:
621 path: manual-index
622 depends-on: [manual]
623 projections:
624 - { adapter: yaml, file: index.yml, pointer: /version, mode: committed }
625 tags:
626 primary: { role: primary, template: '{id}@{version}' }
627"#,
628 )
629 .expect("valid config");
630 let declared = BTreeMap::from([
631 ("library".to_owned(), Bump::Minor),
632 ("manual".to_owned(), Bump::Minor),
633 ]);
634 let effective = effective_bumps(&config, &declared);
635 assert_eq!(effective["application"], Bump::Patch);
636 assert_eq!(effective["unrelated"], Bump::None);
637 assert_eq!(effective["bundle"], Bump::Patch);
639 assert_eq!(effective["manual-index"], Bump::None);
641 }
642
643 fn group_config(group: &str, suspended: bool) -> Config {
644 Config::from_yaml(&format!(
645 r#"
646contract: contract-2
647settings:
648 internal-dependency-bump: patch
649{group}
650release-units:
651 package-a:
652 path: package-a
653 projections: [{{ adapter: npm, file: package.json, mode: committed }}]
654 tags:
655 primary: {{ role: primary, template: 'package-a@{{version}}' }}
656 package-b:
657 path: package-b
658 {disposition}
659 projections: [{{ adapter: npm, file: package.json, mode: committed }}]
660 tags:
661 primary: {{ role: primary, template: 'package-b@{{version}}' }}
662 application:
663 path: application
664 depends-on: [package-a]
665 projections: [{{ adapter: npm, file: package.json, mode: committed }}]
666 tags:
667 primary: {{ role: primary, template: 'application@{{version}}' }}
668"#,
669 disposition = if suspended {
670 "disposition: suspended"
671 } else {
672 ""
673 }
674 ))
675 .expect("valid group config")
676 }
677
678 fn mixed_current() -> BTreeMap<String, Version> {
679 BTreeMap::from([
680 ("package-a".to_owned(), Version::new(0, 2, 4)),
681 ("package-b".to_owned(), Version::new(0, 4, 1)),
682 ("application".to_owned(), Version::new(1, 0, 0)),
683 ])
684 }
685
686 #[test]
687 fn fixed_group_releases_every_member_at_one_version() {
688 let config = group_config("fixed: [[package-a, package-b]]", false);
689 let declared = BTreeMap::from([
690 ("package-a".to_owned(), Bump::Patch),
691 ("package-b".to_owned(), Bump::Minor),
692 ]);
693 let resolved =
694 resolve_versions(&config, &declared, &none(), &mixed_current()).expect("resolved");
695 assert_eq!(resolved["package-a"].next, Version::new(0, 4, 2));
696 assert_eq!(resolved["package-b"].next, Version::new(0, 4, 2));
697 assert_eq!(resolved["package-a"].bump, Bump::Minor);
698 }
699
700 #[test]
701 fn linked_group_releases_only_affected_members_from_highest_current() {
702 let config = group_config("linked: [[package-a, package-b]]", false);
703 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Patch)]);
704 let resolved =
705 resolve_versions(&config, &declared, &none(), &mixed_current()).expect("resolved");
706 assert_eq!(resolved["package-a"].next, Version::new(0, 4, 2));
707 assert_eq!(resolved["package-b"].next, Version::new(0, 4, 1));
708 assert_eq!(resolved["package-b"].bump, Bump::None);
709 }
710
711 #[test]
712 fn release_groups_read_bumps_as_significance() {
713 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Minor)]);
714 let resolved = resolve_versions(
715 &group_config("linked: [[package-a, package-b]]", false),
716 &declared,
717 &none(),
718 &mixed_current(),
719 )
720 .expect("resolved");
721 assert_eq!(resolved["package-a"].next, Version::new(0, 4, 2));
722 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Major)]);
723 let resolved = resolve_versions(
724 &group_config("linked: [[package-a, package-b]]", false),
725 &declared,
726 &none(),
727 &mixed_current(),
728 )
729 .expect("resolved");
730 assert_eq!(resolved["package-a"].next, Version::new(0, 5, 0));
731 assert!(!resolved["package-a"].graduates);
732 }
733
734 #[test]
735 fn dependency_propagation_participates_in_group_fixed_point() {
736 let config = group_config("fixed: [[package-b, application]]", false);
737 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Minor)]);
738 let resolved =
739 resolve_versions(&config, &declared, &none(), &mixed_current()).expect("resolved");
740 assert_eq!(resolved["application"].bump, Bump::Patch);
741 assert_eq!(resolved["package-b"].bump, Bump::Patch);
742 assert_eq!(resolved["application"].next, Version::new(1, 0, 1));
743 assert_eq!(resolved["package-b"].next, Version::new(1, 0, 1));
744 }
745
746 #[test]
747 fn suspended_group_member_blocks_only_when_required() {
748 let fixed = group_config("fixed: [[package-a, package-b]]", true);
749 let linked = group_config("linked: [[package-a, package-b]]", true);
750 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Patch)]);
751 assert!(
752 resolve_versions(&fixed, &declared, &none(), &mixed_current())
753 .expect_err("fixed member blocks")
754 .to_string()
755 .contains("suspended release unit package-b")
756 );
757 resolve_versions(&linked, &declared, &none(), &mixed_current())
758 .expect("unaffected linked suspended member does not block");
759 }
760
761 fn none() -> BTreeSet<String> {
762 BTreeSet::new()
763 }
764
765 fn graduating(id: &str) -> BTreeSet<String> {
766 BTreeSet::from([id.to_owned()])
767 }
768
769 #[test]
770 fn graduation_releases_exactly_1_0_0() {
771 let config = group_config("", false);
772 let declared = BTreeMap::from([
773 ("package-b".to_owned(), Bump::Major),
774 ("package-a".to_owned(), Bump::Patch),
775 ]);
776 let resolved = resolve_versions(
777 &config,
778 &declared,
779 &graduating("package-b"),
780 &mixed_current(),
781 )
782 .expect("resolved");
783 assert_eq!(resolved["package-b"].next, Version::new(1, 0, 0));
784 assert!(resolved["package-b"].graduates);
785 assert_eq!(resolved["package-b"].bump, Bump::Major);
786 assert_eq!(resolved["package-a"].next, Version::new(0, 2, 5));
787 assert!(!resolved["package-a"].graduates);
788 }
789
790 #[test]
791 fn graduation_is_rejected_at_1_0_0_and_later() {
792 let config = group_config("", false);
793 let declared = BTreeMap::from([("application".to_owned(), Bump::Major)]);
794 let error = resolve_versions(
795 &config,
796 &declared,
797 &graduating("application"),
798 &mixed_current(),
799 )
800 .expect_err("graduation at 1.x rejected");
801 assert!(error.to_string().contains("graduate is valid only at 0.x"));
802 }
803
804 #[test]
805 fn graduation_requires_an_effective_major() {
806 let config = group_config("", false);
807 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Minor)]);
808 let error = resolve_versions(
809 &config,
810 &declared,
811 &graduating("package-a"),
812 &mixed_current(),
813 )
814 .expect_err("graduation without major rejected");
815 assert!(error.to_string().contains("requires bump major"));
816 }
817
818 #[test]
819 fn one_graduating_member_graduates_a_fixed_group() {
820 let config = group_config("fixed: [[package-a, package-b]]", false);
821 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Major)]);
822 let resolved = resolve_versions(
823 &config,
824 &declared,
825 &graduating("package-a"),
826 &mixed_current(),
827 )
828 .expect("resolved");
829 for id in ["package-a", "package-b"] {
830 assert_eq!(resolved[id].next, Version::new(1, 0, 0));
831 assert!(resolved[id].graduates);
832 }
833 }
834
835 #[test]
836 fn linked_group_graduates_only_releasing_members() {
837 let config = group_config("linked: [[package-a, package-b]]", false);
838 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Major)]);
839 let resolved = resolve_versions(
840 &config,
841 &declared,
842 &graduating("package-a"),
843 &mixed_current(),
844 )
845 .expect("resolved");
846 assert_eq!(resolved["package-a"].next, Version::new(1, 0, 0));
847 assert!(resolved["package-a"].graduates);
848 assert_eq!(resolved["package-b"].next, Version::new(0, 4, 1));
849 assert!(!resolved["package-b"].graduates);
850 }
851
852 #[test]
853 fn group_graduation_is_rejected_when_the_group_is_past_0_x() {
854 let config = group_config("linked: [[package-a, application]]", false);
855 let declared = BTreeMap::from([("package-a".to_owned(), Bump::Major)]);
856 let error = resolve_versions(
857 &config,
858 &declared,
859 &graduating("package-a"),
860 &mixed_current(),
861 )
862 .expect_err("group at 1.x cannot graduate");
863 assert!(error.to_string().contains("graduate is valid only at 0.x"));
864 }
865}