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;
11use semver::Version;
12use std::collections::{BTreeMap, HashMap};
13use std::path::Path;
14
15/// Current and next version of a logical package.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PackageVersion {
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 PackageVersion {
27    /// Compute package versions from a current tag and aggregate bump.
28    pub fn new(current: Version, bump: Bump) -> Self {
29        let next = bump_version(&current, bump);
30        Self {
31            current,
32            next,
33            bump,
34        }
35    }
36}
37
38/// Aggregate intent bumps by taking the maximum significance for each package.
39pub fn aggregate_bumps<'a>(
40    intents: impl IntoIterator<Item = &'a BTreeMap<String, Bump>>,
41) -> BTreeMap<String, Bump> {
42    let mut aggregate: BTreeMap<String, Bump> = BTreeMap::new();
43    for intent in intents {
44        for (package, bump) in intent {
45            aggregate
46                .entry(package.clone())
47                .and_modify(|existing| *existing = (*existing).max(*bump))
48                .or_insert(*bump);
49        }
50    }
51    aggregate
52}
53
54/// Propagate the configured minimum bump to internal dependents in a shared ecosystem.
55pub fn effective_bumps(
56    config: &Config,
57    declared: &BTreeMap<String, Bump>,
58) -> BTreeMap<String, Bump> {
59    let mut effective = declared.clone();
60    for id in config.packages.keys() {
61        effective.entry(id.clone()).or_default();
62    }
63
64    loop {
65        let mut changed = false;
66        for (id, package) in &config.packages {
67            for dependency in &package.depends_on {
68                if effective[dependency] == Bump::None || !share_ecosystem(config, id, dependency) {
69                    continue;
70                }
71                let required = config.settings.internal_dependency_bump;
72                if effective[id] < required {
73                    effective.insert(id.clone(), required);
74                    changed = true;
75                }
76            }
77        }
78        if !changed {
79            break;
80        }
81    }
82    effective
83}
84
85fn share_ecosystem(config: &Config, left: &str, right: &str) -> bool {
86    config.packages[left]
87        .projections
88        .iter()
89        .any(|left_projection| {
90            left_projection.adapter.ecosystem().is_some()
91                && config.packages[right]
92                    .projections
93                    .iter()
94                    .any(|right_projection| {
95                        left_projection.adapter.ecosystem() == right_projection.adapter.ecosystem()
96                    })
97        })
98}
99
100/// Apply compatibility-significance mapping before 1.0 and strict SemVer after it.
101///
102/// For a `0.x.y` version, `major` advances the compatibility boundary to
103/// `0.(x+1).0`, while both `minor` and `patch` advance to `0.x.(y+1)`. This
104/// matches caret-range compatibility semantics across npm, Cargo, and Pub.
105/// Graduation to `1.0.0` is deliberately never inferred from an intent: a
106/// human tags `1.0.0` explicitly, and subsequent releases compute from that
107/// tag using strict SemVer semantics.
108pub fn bump_version(current: &Version, bump: Bump) -> Version {
109    let mut next = current.clone();
110    next.pre = semver::Prerelease::EMPTY;
111    next.build = semver::BuildMetadata::EMPTY;
112    if next.major == 0 {
113        match bump {
114            Bump::None => {}
115            Bump::Major => {
116                next.minor += 1;
117                next.patch = 0;
118            }
119            Bump::Minor | Bump::Patch => next.patch += 1,
120        }
121        return next;
122    }
123    match bump {
124        Bump::None => {}
125        Bump::Patch => next.patch += 1,
126        Bump::Minor => {
127            next.minor += 1;
128            next.patch = 0;
129        }
130        Bump::Major => {
131            next.major += 1;
132            next.minor = 0;
133            next.patch = 0;
134        }
135    }
136    next
137}
138
139/// Read package versions and heights from a Git repository using tagver traversal semantics.
140pub struct VersionRepository {
141    repository: tagver::Repository,
142}
143
144impl VersionRepository {
145    /// Discover a Git repository from `path`.
146    pub fn discover(path: &Path) -> Result<Self> {
147        let repository =
148            tagver::Repository::discover(path).map_err(|error| Error::Git(error.to_string()))?;
149        Ok(Self { repository })
150    }
151
152    /// Find the latest final SemVer matching a package tag template.
153    pub fn current_version(&self, package_id: &str, template: &str) -> Result<Version> {
154        let matches = self.matching_tags(package_id, template)?;
155        let (version, _) = self.walk_to_tag(&matches, false)?;
156        Ok(version.unwrap_or_else(|| Version::new(0, 0, 0)))
157    }
158
159    /// Count commits on the first-parent chain since the latest matching tag.
160    pub fn height(&self, package_id: &str, template: &str) -> Result<u64> {
161        let matches = self.matching_tags(package_id, template)?;
162        let (_, height) = self.walk_to_tag(&matches, true)?;
163        Ok(height)
164    }
165
166    /// Return all matching versions, used to derive channel iterations.
167    pub fn all_versions(&self, package_id: &str, template: &str) -> Result<Vec<Version>> {
168        let tags = self.matching_tags(package_id, template)?;
169        let mut versions: Vec<_> = tags.into_values().flatten().collect();
170        versions.sort();
171        versions.dedup();
172        Ok(versions)
173    }
174
175    fn matching_tags(
176        &self,
177        package_id: &str,
178        template: &str,
179    ) -> Result<HashMap<gix::ObjectId, Vec<Version>>> {
180        let version_marker = "{version}";
181        let rendered = template.replace("{id}", package_id);
182        let (prefix, suffix) = rendered.split_once(version_marker).ok_or_else(|| {
183            Error::Validation(format!("invalid tag template for package {package_id}"))
184        })?;
185
186        let repository = self.repository.inner();
187        let references = repository
188            .references()
189            .map_err(|error| Error::Git(format!("failed to read references: {error}")))?;
190        let tag_references = references
191            .tags()
192            .map_err(|error| Error::Git(format!("failed to read tags: {error}")))?;
193        let mut matches: HashMap<gix::ObjectId, Vec<Version>> = HashMap::new();
194
195        for mut reference in tag_references.flatten() {
196            let name = reference.name().shorten().to_string();
197            let Some(version_text) = name
198                .strip_prefix(prefix)
199                .and_then(|rest| rest.strip_suffix(suffix))
200            else {
201                continue;
202            };
203            let Ok(version) = Version::parse(version_text) else {
204                continue;
205            };
206            let Ok(target) = reference.peel_to_id() else {
207                continue;
208            };
209            matches.entry(target.detach()).or_default().push(version);
210        }
211
212        for versions in matches.values_mut() {
213            versions.sort_by(|left, right| right.cmp(left));
214        }
215        Ok(matches)
216    }
217
218    fn walk_to_tag(
219        &self,
220        matches: &HashMap<gix::ObjectId, Vec<Version>>,
221        include_prerelease: bool,
222    ) -> Result<(Option<Version>, u64)> {
223        let repository = self.repository.inner();
224        let mut head = repository
225            .head()
226            .map_err(|error| Error::Git(format!("failed to read HEAD: {error}")))?;
227        let Some(mut current) = head
228            .try_peel_to_id()
229            .map_err(|error| Error::Git(format!("failed to resolve HEAD: {error}")))?
230            .map(|id| id.detach())
231        else {
232            return Ok((None, 0));
233        };
234        let mut height = 0;
235
236        loop {
237            if let Some(versions) = matches.get(&current) {
238                if let Some(version) = versions
239                    .iter()
240                    .find(|version| include_prerelease || version.pre.is_empty())
241                {
242                    return Ok((Some(version.clone()), height));
243                }
244            }
245
246            let commit = repository
247                .find_object(current)
248                .map_err(|error| Error::Git(format!("failed to find commit: {error}")))?
249                .try_into_commit()
250                .map_err(|error| Error::Git(format!("tag target is not a commit: {error}")))?;
251            let Some(parent) = commit.parent_ids().next() else {
252                return Ok((None, height));
253            };
254            current = parent.detach();
255            height += 1;
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn maximum_bump_wins() {
266        let first = BTreeMap::from([
267            ("one".to_owned(), Bump::Patch),
268            ("two".to_owned(), Bump::Minor),
269        ]);
270        let second = BTreeMap::from([
271            ("one".to_owned(), Bump::Major),
272            ("two".to_owned(), Bump::Patch),
273        ]);
274        let aggregate = aggregate_bumps([&first, &second]);
275        assert_eq!(aggregate["one"], Bump::Major);
276        assert_eq!(aggregate["two"], Bump::Minor);
277    }
278
279    #[test]
280    fn compatibility_significance_pre_1_0_mapping() {
281        let current = Version::parse("0.4.1").expect("valid version");
282        assert_eq!(bump_version(&current, Bump::Major), Version::new(0, 5, 0));
283        assert_eq!(bump_version(&current, Bump::Minor), Version::new(0, 4, 2));
284        assert_eq!(bump_version(&current, Bump::Patch), Version::new(0, 4, 2));
285    }
286
287    #[test]
288    fn strict_semver_mapping_at_1_0_and_later() {
289        let current = Version::parse("1.4.1").expect("valid version");
290        assert_eq!(bump_version(&current, Bump::Major), Version::new(2, 0, 0));
291        assert_eq!(bump_version(&current, Bump::Minor), Version::new(1, 5, 0));
292        assert_eq!(bump_version(&current, Bump::Patch), Version::new(1, 4, 2));
293    }
294
295    #[test]
296    fn bump_removes_prerelease_and_build_metadata() {
297        let current = Version::parse("1.2.3-beta.2+build.1").expect("valid version");
298        assert_eq!(bump_version(&current, Bump::Patch), Version::new(1, 2, 4));
299    }
300
301    #[test]
302    fn dependency_bumps_propagate_only_in_shared_ecosystems() {
303        let config = Config::from_yaml(
304            r#"
305packages:
306  library:
307    path: library
308    projections:
309      - { adapter: npm, file: package.json, mode: committed }
310  application:
311    path: application
312    depends-on: [library]
313    projections:
314      - { adapter: npm, file: package.json, mode: committed }
315  unrelated:
316    path: unrelated
317    depends-on: [library]
318    projections:
319      - { adapter: cargo, file: Cargo.toml, mode: committed }
320"#,
321        )
322        .expect("valid config");
323        let declared = BTreeMap::from([("library".to_owned(), Bump::Minor)]);
324        let effective = effective_bumps(&config, &declared);
325        assert_eq!(effective["application"], Bump::Patch);
326        assert_eq!(effective["unrelated"], Bump::None);
327    }
328}