Skip to main content

intentional_core/
version.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Tag-derived current versions and intent-derived next versions.
7
8use 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/// Current and next version of a release unit.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ReleaseUnitVersion {
18    /// Latest final version tag on the first-parent history.
19    pub current: Version,
20    /// Current version after the aggregate intent bump.
21    pub next: Version,
22    /// Aggregate intent bump.
23    pub bump: Bump,
24    /// Whether this release graduates the release unit to `1.0.0`.
25    pub graduates: bool,
26}
27
28impl ReleaseUnitVersion {
29    /// Compute release-unit versions from a current tag and aggregate bump.
30    pub fn new(current: Version, bump: Bump) -> Self {
31        let next = bump_version(&current, bump);
32        Self {
33            current,
34            next,
35            bump,
36            graduates: false,
37        }
38    }
39}
40
41/// Aggregate intent bumps by taking the maximum significance for each release unit.
42pub 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
57/// Collect every release unit that at least one intent graduates.
58pub fn aggregate_graduations<'a>(
59    intents: impl IntoIterator<Item = &'a BTreeSet<String>>,
60) -> BTreeSet<String> {
61    intents.into_iter().flatten().cloned().collect()
62}
63
64/// Propagate the configured minimum bump to internal dependents in a shared ecosystem.
65pub 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
129/// Resolve current and next versions after dependency and release-group constraints.
130///
131/// A graduating release unit releases exactly `1.0.0`. In a fixed group, one
132/// graduating member graduates every member. In a linked group, it graduates
133/// the releasing members. Graduation is valid only while the version it
134/// applies to is `0.x`.
135pub 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
144/// Resolve versions with another reading of bump names. Initialization uses
145/// [`component_bump`] to compute what a component-style tool would release.
146pub(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            // The group shares one version line, so its highest current
281            // version is the one that graduates.
282            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
322/// Apply a bump that states change significance.
323///
324/// For a `0.x.y` version, `major` advances the compatibility boundary to
325/// `0.(x+1).0`, while both `minor` and `patch` advance to `0.x.(y+1)`. This
326/// matches caret-range compatibility semantics across npm, Cargo, and Pub.
327/// At `1.0.0` and later, ordinary SemVer applies. No bump reaches `1.0.0`
328/// from `0.x`: only an intent that sets `graduate` does, through
329/// [`resolve_versions`].
330pub 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
361/// Increment the named version component, as Changesets and `contract-1`
362/// component configs read bump names.
363pub(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
383/// Read release-unit versions and heights from Git using tagver traversal semantics.
384pub struct VersionRepository {
385    repository: tagver::Repository,
386}
387
388impl VersionRepository {
389    /// Discover a Git repository from `path`.
390    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    /// Find the latest final SemVer matching a release-unit tag template.
397    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    /// Find the latest final SemVer before tags created at `excluded_target`.
404    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    /// Return whether at least one matching tag exists on the repository history.
417    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    /// Count commits on the first-parent chain since the latest matching tag.
422    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    /// Return all matching versions, used to derive channel iterations.
429    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    /// Return matching versions excluding tags created at one target commit.
438    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(&current) {
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(&current, Bump::Major), Version::new(0, 5, 0));
562        assert_eq!(bump_version(&current, Bump::Minor), Version::new(0, 4, 2));
563        assert_eq!(bump_version(&current, 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(&current, Bump::Major), Version::new(2, 0, 0));
570        assert_eq!(bump_version(&current, Bump::Minor), Version::new(1, 5, 0));
571        assert_eq!(bump_version(&current, 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(&current, 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        // One shared ecosystem adapter is enough, whatever else a unit projects.
638        assert_eq!(effective["bundle"], Bump::Patch);
639        // Generic adapters carry no dependency ranges, so they never propagate.
640        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}