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, Pre1BumpMapping, ReleaseUnitDisposition};
11use semver::Version;
12use std::collections::{BTreeMap, 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}
25
26impl ReleaseUnitVersion {
27    /// Compute release-unit versions from a current tag and aggregate bump.
28    pub fn new(current: Version, bump: Bump) -> Self {
29        Self::new_with_mapping(current, bump, Pre1BumpMapping::Compatibility)
30    }
31
32    /// Compute release-unit versions with an explicit pre-1.0 interpretation.
33    pub fn new_with_mapping(current: Version, bump: Bump, mapping: Pre1BumpMapping) -> Self {
34        let next = bump_version_with_mapping(&current, bump, mapping);
35        Self {
36            current,
37            next,
38            bump,
39        }
40    }
41}
42
43/// Aggregate intent bumps by taking the maximum significance for each release unit.
44pub fn aggregate_bumps<'a>(
45    intents: impl IntoIterator<Item = &'a BTreeMap<String, Bump>>,
46) -> BTreeMap<String, Bump> {
47    let mut aggregate: BTreeMap<String, Bump> = BTreeMap::new();
48    for intent in intents {
49        for (release_unit, bump) in intent {
50            aggregate
51                .entry(release_unit.clone())
52                .and_modify(|existing| *existing = (*existing).max(*bump))
53                .or_insert(*bump);
54        }
55    }
56    aggregate
57}
58
59/// Propagate the configured minimum bump to internal dependents in a shared ecosystem.
60pub fn effective_bumps(
61    config: &Config,
62    declared: &BTreeMap<String, Bump>,
63) -> BTreeMap<String, Bump> {
64    let mut effective = declared
65        .iter()
66        .filter(|(id, _)| config.release_units.contains_key(*id))
67        .map(|(id, bump)| (id.clone(), *bump))
68        .collect::<BTreeMap<_, _>>();
69    for id in config.release_units.keys() {
70        effective.entry(id.clone()).or_default();
71    }
72
73    loop {
74        let mut changed = false;
75        for (id, release_unit) in &config.release_units {
76            for dependency in &release_unit.depends_on {
77                if effective[dependency] == Bump::None || !share_ecosystem(config, id, dependency) {
78                    continue;
79                }
80                let required = config.settings.internal_dependency_bump;
81                if effective[id] < required {
82                    effective.insert(id.clone(), required);
83                    changed = true;
84                }
85            }
86        }
87        for group in &config.fixed {
88            let group_bump = group
89                .iter()
90                .map(|id| effective[id])
91                .max()
92                .unwrap_or(Bump::None);
93            if group_bump != Bump::None {
94                for id in group {
95                    if effective[id] < group_bump {
96                        effective.insert(id.clone(), group_bump);
97                        changed = true;
98                    }
99                }
100            }
101        }
102        for group in &config.linked {
103            let group_bump = group
104                .iter()
105                .map(|id| effective[id])
106                .max()
107                .unwrap_or(Bump::None);
108            if group_bump != Bump::None {
109                for id in group {
110                    if effective[id] != Bump::None && effective[id] < group_bump {
111                        effective.insert(id.clone(), group_bump);
112                        changed = true;
113                    }
114                }
115            }
116        }
117        if !changed {
118            break;
119        }
120    }
121    effective
122}
123
124/// Resolve current and next versions after dependency and release-group constraints.
125pub fn resolve_versions(
126    config: &Config,
127    declared: &BTreeMap<String, Bump>,
128    current: &BTreeMap<String, Version>,
129) -> Result<BTreeMap<String, ReleaseUnitVersion>> {
130    let effective = effective_bumps(config, declared);
131    for (id, bump) in &effective {
132        if *bump != Bump::None
133            && config.release_units[id].disposition == ReleaseUnitDisposition::Suspended
134        {
135            return Err(Error::Validation(format!(
136                "release requires suspended release unit {id}"
137            )));
138        }
139    }
140
141    let mapping = config.settings.pre_1_0_bump_mapping;
142    let mut resolved = BTreeMap::new();
143    let mut grouped = BTreeMap::<String, (&str, usize)>::new();
144    for (index, group) in config.fixed.iter().enumerate() {
145        for id in group {
146            grouped.insert(id.clone(), ("fixed", index));
147        }
148    }
149    for (index, group) in config.linked.iter().enumerate() {
150        for id in group {
151            grouped.insert(id.clone(), ("linked", index));
152        }
153    }
154
155    for (id, release_unit) in &config.release_units {
156        if grouped.contains_key(id) {
157            continue;
158        }
159        let current_version = current.get(id).ok_or_else(|| {
160            Error::Validation(format!("missing current version for release unit {id}"))
161        })?;
162        resolved.insert(
163            id.clone(),
164            ReleaseUnitVersion::new_with_mapping(current_version.clone(), effective[id], mapping),
165        );
166        if release_unit.disposition == ReleaseUnitDisposition::Suspended {
167            debug_assert_eq!(effective[id], Bump::None);
168        }
169    }
170
171    for group in &config.fixed {
172        resolve_group(group, true, &effective, current, mapping, &mut resolved)?;
173    }
174    for group in &config.linked {
175        resolve_group(group, false, &effective, current, mapping, &mut resolved)?;
176    }
177    Ok(resolved)
178}
179
180fn resolve_group(
181    group: &[String],
182    fixed: bool,
183    effective: &BTreeMap<String, Bump>,
184    current: &BTreeMap<String, Version>,
185    mapping: Pre1BumpMapping,
186    resolved: &mut BTreeMap<String, ReleaseUnitVersion>,
187) -> Result<()> {
188    let highest_current = group
189        .iter()
190        .map(|id| {
191            current.get(id).ok_or_else(|| {
192                Error::Validation(format!("missing current version for release unit {id}"))
193            })
194        })
195        .collect::<Result<Vec<_>>>()?
196        .into_iter()
197        .max()
198        .expect("validated groups are non-empty")
199        .clone();
200    let highest_bump = group
201        .iter()
202        .map(|id| effective[id])
203        .max()
204        .unwrap_or(Bump::None);
205    let shared_next = bump_version_with_mapping(&highest_current, highest_bump, mapping);
206    for id in group {
207        let member_current = current[id].clone();
208        let releases = highest_bump != Bump::None && (fixed || effective[id] != Bump::None);
209        resolved.insert(
210            id.clone(),
211            ReleaseUnitVersion {
212                current: member_current.clone(),
213                next: if releases {
214                    shared_next.clone()
215                } else {
216                    member_current
217                },
218                bump: if releases { highest_bump } else { Bump::None },
219            },
220        );
221    }
222    Ok(())
223}
224
225fn share_ecosystem(config: &Config, left: &str, right: &str) -> bool {
226    config.release_units[left]
227        .projections
228        .iter()
229        .any(|left_projection| {
230            left_projection.adapter.ecosystem().is_some()
231                && config.release_units[right]
232                    .projections
233                    .iter()
234                    .any(|right_projection| {
235                        left_projection.adapter.ecosystem() == right_projection.adapter.ecosystem()
236                    })
237        })
238}
239
240/// Apply compatibility-significance mapping before 1.0 and strict SemVer after it.
241///
242/// For a `0.x.y` version, `major` advances the compatibility boundary to
243/// `0.(x+1).0`, while both `minor` and `patch` advance to `0.x.(y+1)`. This
244/// matches caret-range compatibility semantics across npm, Cargo, and Pub.
245/// Graduation to `1.0.0` is deliberately never inferred from an intent: a
246/// human tags `1.0.0` explicitly, and subsequent releases compute from that
247/// tag using strict SemVer semantics.
248pub fn bump_version(current: &Version, bump: Bump) -> Version {
249    bump_version_with_mapping(current, bump, Pre1BumpMapping::Compatibility)
250}
251
252/// Apply a bump under the selected pre-1.0 interpretation contract.
253pub fn bump_version_with_mapping(
254    current: &Version,
255    bump: Bump,
256    mapping: Pre1BumpMapping,
257) -> Version {
258    let mut next = current.clone();
259    next.pre = semver::Prerelease::EMPTY;
260    next.build = semver::BuildMetadata::EMPTY;
261    if next.major == 0 && mapping == Pre1BumpMapping::Compatibility {
262        match bump {
263            Bump::None => {}
264            Bump::Major => {
265                next.minor += 1;
266                next.patch = 0;
267            }
268            Bump::Minor | Bump::Patch => next.patch += 1,
269        }
270        return next;
271    }
272    match bump {
273        Bump::None => {}
274        Bump::Patch => next.patch += 1,
275        Bump::Minor => {
276            next.minor += 1;
277            next.patch = 0;
278        }
279        Bump::Major => {
280            next.major += 1;
281            next.minor = 0;
282            next.patch = 0;
283        }
284    }
285    next
286}
287
288/// Read release-unit versions and heights from Git using tagver traversal semantics.
289pub struct VersionRepository {
290    repository: tagver::Repository,
291}
292
293impl VersionRepository {
294    /// Discover a Git repository from `path`.
295    pub fn discover(path: &Path) -> Result<Self> {
296        let repository =
297            tagver::Repository::discover(path).map_err(|error| Error::Git(error.to_string()))?;
298        Ok(Self { repository })
299    }
300
301    /// Find the latest final SemVer matching a release-unit tag template.
302    pub fn current_version(&self, release_unit_id: &str, template: &str) -> Result<Version> {
303        let matches = self.matching_tags(release_unit_id, template)?;
304        let (version, _) = self.walk_to_tag(&matches, false)?;
305        Ok(version.unwrap_or_else(|| Version::new(0, 0, 0)))
306    }
307
308    /// Find the latest final SemVer before tags created at `excluded_target`.
309    pub fn current_version_before(
310        &self,
311        release_unit_id: &str,
312        template: &str,
313        excluded_target: gix::ObjectId,
314    ) -> Result<Version> {
315        let mut matches = self.matching_tags(release_unit_id, template)?;
316        matches.remove(&excluded_target);
317        let (version, _) = self.walk_to_tag(&matches, false)?;
318        Ok(version.unwrap_or_else(|| Version::new(0, 0, 0)))
319    }
320
321    /// Return whether at least one matching tag exists on the repository history.
322    pub fn has_matching_tag(&self, release_unit_id: &str, template: &str) -> Result<bool> {
323        Ok(!self.matching_tags(release_unit_id, template)?.is_empty())
324    }
325
326    /// Count commits on the first-parent chain since the latest matching tag.
327    pub fn height(&self, release_unit_id: &str, template: &str) -> Result<u64> {
328        let matches = self.matching_tags(release_unit_id, template)?;
329        let (_, height) = self.walk_to_tag(&matches, true)?;
330        Ok(height)
331    }
332
333    /// Return all matching versions, used to derive channel iterations.
334    pub fn all_versions(&self, release_unit_id: &str, template: &str) -> Result<Vec<Version>> {
335        let tags = self.matching_tags(release_unit_id, template)?;
336        let mut versions: Vec<_> = tags.into_values().flatten().collect();
337        versions.sort();
338        versions.dedup();
339        Ok(versions)
340    }
341
342    /// Return matching versions excluding tags created at one target commit.
343    pub fn all_versions_before(
344        &self,
345        release_unit_id: &str,
346        template: &str,
347        excluded_target: gix::ObjectId,
348    ) -> Result<Vec<Version>> {
349        let mut tags = self.matching_tags(release_unit_id, template)?;
350        tags.remove(&excluded_target);
351        let mut versions: Vec<_> = tags.into_values().flatten().collect();
352        versions.sort();
353        versions.dedup();
354        Ok(versions)
355    }
356
357    fn matching_tags(
358        &self,
359        release_unit_id: &str,
360        template: &str,
361    ) -> Result<HashMap<gix::ObjectId, Vec<Version>>> {
362        let version_marker = "{version}";
363        let rendered = template.replace("{id}", release_unit_id);
364        let (prefix, suffix) = rendered.split_once(version_marker).ok_or_else(|| {
365            Error::Validation(format!(
366                "invalid tag template for release unit {release_unit_id}"
367            ))
368        })?;
369
370        let repository = self.repository.inner();
371        let references = repository
372            .references()
373            .map_err(|error| Error::Git(format!("failed to read references: {error}")))?;
374        let tag_references = references
375            .tags()
376            .map_err(|error| Error::Git(format!("failed to read tags: {error}")))?;
377        let mut matches: HashMap<gix::ObjectId, Vec<Version>> = HashMap::new();
378
379        for mut reference in tag_references.flatten() {
380            let name = reference.name().shorten().to_string();
381            let Some(version_text) = name
382                .strip_prefix(prefix)
383                .and_then(|rest| rest.strip_suffix(suffix))
384            else {
385                continue;
386            };
387            let Ok(version) = Version::parse(version_text) else {
388                continue;
389            };
390            let Ok(target) = reference.peel_to_id() else {
391                continue;
392            };
393            matches.entry(target.detach()).or_default().push(version);
394        }
395
396        for versions in matches.values_mut() {
397            versions.sort_by(|left, right| right.cmp(left));
398        }
399        Ok(matches)
400    }
401
402    fn walk_to_tag(
403        &self,
404        matches: &HashMap<gix::ObjectId, Vec<Version>>,
405        include_prerelease: bool,
406    ) -> Result<(Option<Version>, u64)> {
407        let repository = self.repository.inner();
408        let mut head = repository
409            .head()
410            .map_err(|error| Error::Git(format!("failed to read HEAD: {error}")))?;
411        let Some(mut current) = head
412            .try_peel_to_id()
413            .map_err(|error| Error::Git(format!("failed to resolve HEAD: {error}")))?
414            .map(|id| id.detach())
415        else {
416            return Ok((None, 0));
417        };
418        let mut height = 0;
419
420        loop {
421            if let Some(versions) = matches.get(&current) {
422                if let Some(version) = versions
423                    .iter()
424                    .find(|version| include_prerelease || version.pre.is_empty())
425                {
426                    return Ok((Some(version.clone()), height));
427                }
428            }
429
430            let commit = repository
431                .find_object(current)
432                .map_err(|error| Error::Git(format!("failed to find commit: {error}")))?
433                .try_into_commit()
434                .map_err(|error| Error::Git(format!("tag target is not a commit: {error}")))?;
435            let Some(parent) = commit.parent_ids().next() else {
436                return Ok((None, height));
437            };
438            current = parent.detach();
439            height += 1;
440        }
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn maximum_bump_wins() {
450        let first = BTreeMap::from([
451            ("one".to_owned(), Bump::Patch),
452            ("two".to_owned(), Bump::Minor),
453        ]);
454        let second = BTreeMap::from([
455            ("one".to_owned(), Bump::Major),
456            ("two".to_owned(), Bump::Patch),
457        ]);
458        let aggregate = aggregate_bumps([&first, &second]);
459        assert_eq!(aggregate["one"], Bump::Major);
460        assert_eq!(aggregate["two"], Bump::Minor);
461    }
462
463    #[test]
464    fn compatibility_significance_pre_1_0_mapping() {
465        let current = Version::parse("0.4.1").expect("valid version");
466        assert_eq!(bump_version(&current, Bump::Major), Version::new(0, 5, 0));
467        assert_eq!(bump_version(&current, Bump::Minor), Version::new(0, 4, 2));
468        assert_eq!(bump_version(&current, Bump::Patch), Version::new(0, 4, 2));
469    }
470
471    #[test]
472    fn strict_semver_mapping_at_1_0_and_later() {
473        let current = Version::parse("1.4.1").expect("valid version");
474        assert_eq!(bump_version(&current, Bump::Major), Version::new(2, 0, 0));
475        assert_eq!(bump_version(&current, Bump::Minor), Version::new(1, 5, 0));
476        assert_eq!(bump_version(&current, Bump::Patch), Version::new(1, 4, 2));
477    }
478
479    #[test]
480    fn bump_removes_prerelease_and_build_metadata() {
481        let current = Version::parse("1.2.3-beta.2+build.1").expect("valid version");
482        assert_eq!(bump_version(&current, Bump::Patch), Version::new(1, 2, 4));
483    }
484
485    #[test]
486    fn dependency_bumps_propagate_only_in_shared_ecosystems() {
487        let config = Config::from_yaml(
488            r#"
489contract: contract-1
490release-units:
491  library:
492    path: library
493    projections:
494      - { adapter: npm, file: package.json, mode: committed }
495    tags:
496      primary: { role: primary, template: '{id}@{version}' }
497  application:
498    path: application
499    depends-on: [library]
500    projections:
501      - { adapter: npm, file: package.json, mode: committed }
502    tags:
503      primary: { role: primary, template: '{id}@{version}' }
504  unrelated:
505    path: unrelated
506    depends-on: [library]
507    projections:
508      - { adapter: cargo, file: Cargo.toml, mode: committed }
509    tags:
510      primary: { role: primary, template: '{id}@{version}' }
511"#,
512        )
513        .expect("valid config");
514        let declared = BTreeMap::from([("library".to_owned(), Bump::Minor)]);
515        let effective = effective_bumps(&config, &declared);
516        assert_eq!(effective["application"], Bump::Patch);
517        assert_eq!(effective["unrelated"], Bump::None);
518    }
519
520    fn group_config(group: &str, mapping: &str, suspended: bool) -> Config {
521        Config::from_yaml(&format!(
522            r#"
523contract: contract-1
524settings:
525  pre-1-0-bump-mapping: {mapping}
526  internal-dependency-bump: patch
527{group}
528release-units:
529  package-a:
530    path: package-a
531    projections: [{{ adapter: npm, file: package.json, mode: committed }}]
532    tags:
533      primary: {{ role: primary, template: 'package-a@{{version}}' }}
534  package-b:
535    path: package-b
536    {disposition}
537    projections: [{{ adapter: npm, file: package.json, mode: committed }}]
538    tags:
539      primary: {{ role: primary, template: 'package-b@{{version}}' }}
540  application:
541    path: application
542    depends-on: [package-a]
543    projections: [{{ adapter: npm, file: package.json, mode: committed }}]
544    tags:
545      primary: {{ role: primary, template: 'application@{{version}}' }}
546"#,
547            disposition = if suspended {
548                "disposition: suspended"
549            } else {
550                ""
551            }
552        ))
553        .expect("valid group config")
554    }
555
556    fn mixed_current() -> BTreeMap<String, Version> {
557        BTreeMap::from([
558            ("package-a".to_owned(), Version::new(0, 2, 4)),
559            ("package-b".to_owned(), Version::new(0, 4, 1)),
560            ("application".to_owned(), Version::new(1, 0, 0)),
561        ])
562    }
563
564    #[test]
565    fn fixed_group_releases_every_member_at_one_version() {
566        let config = group_config("fixed: [[package-a, package-b]]", "component", false);
567        let declared = BTreeMap::from([
568            ("package-a".to_owned(), Bump::Patch),
569            ("package-b".to_owned(), Bump::Minor),
570        ]);
571        let resolved = resolve_versions(&config, &declared, &mixed_current()).expect("resolved");
572        assert_eq!(resolved["package-a"].next, Version::new(0, 5, 0));
573        assert_eq!(resolved["package-b"].next, Version::new(0, 5, 0));
574        assert_eq!(resolved["package-a"].bump, Bump::Minor);
575    }
576
577    #[test]
578    fn linked_group_releases_only_affected_members_from_highest_current() {
579        let config = group_config("linked: [[package-a, package-b]]", "component", false);
580        let declared = BTreeMap::from([("package-a".to_owned(), Bump::Patch)]);
581        let resolved = resolve_versions(&config, &declared, &mixed_current()).expect("resolved");
582        assert_eq!(resolved["package-a"].next, Version::new(0, 4, 2));
583        assert_eq!(resolved["package-b"].next, Version::new(0, 4, 1));
584        assert_eq!(resolved["package-b"].bump, Bump::None);
585    }
586
587    #[test]
588    fn release_groups_honor_both_pre_1_0_mappings() {
589        let declared = BTreeMap::from([("package-a".to_owned(), Bump::Minor)]);
590        let component = resolve_versions(
591            &group_config("linked: [[package-a, package-b]]", "component", false),
592            &declared,
593            &mixed_current(),
594        )
595        .expect("component");
596        let compatibility = resolve_versions(
597            &group_config("linked: [[package-a, package-b]]", "compatibility", false),
598            &declared,
599            &mixed_current(),
600        )
601        .expect("compatibility");
602        assert_eq!(component["package-a"].next, Version::new(0, 5, 0));
603        assert_eq!(compatibility["package-a"].next, Version::new(0, 4, 2));
604    }
605
606    #[test]
607    fn dependency_propagation_participates_in_group_fixed_point() {
608        let config = group_config("fixed: [[package-b, application]]", "component", false);
609        let declared = BTreeMap::from([("package-a".to_owned(), Bump::Minor)]);
610        let resolved = resolve_versions(&config, &declared, &mixed_current()).expect("resolved");
611        assert_eq!(resolved["application"].bump, Bump::Patch);
612        assert_eq!(resolved["package-b"].bump, Bump::Patch);
613        assert_eq!(resolved["application"].next, Version::new(1, 0, 1));
614        assert_eq!(resolved["package-b"].next, Version::new(1, 0, 1));
615    }
616
617    #[test]
618    fn suspended_group_member_blocks_only_when_required() {
619        let fixed = group_config("fixed: [[package-a, package-b]]", "component", true);
620        let linked = group_config("linked: [[package-a, package-b]]", "component", true);
621        let declared = BTreeMap::from([("package-a".to_owned(), Bump::Patch)]);
622        assert!(resolve_versions(&fixed, &declared, &mixed_current())
623            .expect_err("fixed member blocks")
624            .to_string()
625            .contains("suspended release unit package-b"));
626        resolve_versions(&linked, &declared, &mixed_current())
627            .expect("unaffected linked suspended member does not block");
628    }
629}