Skip to main content

ephemeris_release/proposal/
analysis.rs

1//! Version bump analysis. Callers supply what Git observed about each unit
2//! and how to find the required bump, so the policy here stays pure.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use super::ProposalError;
7use crate::conventional::Commit;
8use crate::unit::Unit;
9use crate::version::{Bump, Proposed, Version};
10
11/// One proposed unit version.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub(crate) struct Change {
14    pub(crate) unit: String,
15    pub(crate) version: Proposed,
16    /// The direct dependencies updated in the same proposal, with their
17    /// proposed versions. A first release is not an update.
18    pub(crate) dependencies: BTreeMap<String, Version>,
19}
20
21impl Change {
22    fn new(unit: &Unit, version: Proposed) -> Self {
23        Self {
24            unit: unit.name.clone(),
25            version,
26            dependencies: BTreeMap::new(),
27        }
28    }
29}
30
31/// Every change a proposal makes.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub(crate) struct Proposal {
34    pub(crate) changes: Vec<Change>,
35}
36
37/// What Git reports about one unit since its baseline tag, or since the root
38/// commit for a unit that has no tag and is still at 0.0.0.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub(crate) struct History {
41    pub(crate) baseline_tag: Option<String>,
42    pub(crate) changed: bool,
43    /// Whether a commit format is set, so `commits` is meaningful.
44    pub(crate) conventional: bool,
45    /// The commits that change the unit, when a commit format is set.
46    pub(crate) commits: Vec<Commit>,
47}
48
49/// The configured bump policy.
50#[derive(Clone, Copy, Debug)]
51pub(crate) struct Policy<'a> {
52    /// Applications, which are bumped without API analysis and take a patch
53    /// bump when only a dependency changed.
54    pub(crate) applications: &'a BTreeSet<String>,
55    /// Applications that are only bumped by an explicit override.
56    pub(crate) non_bumping: &'a BTreeSet<String>,
57    pub(crate) overrides: &'a BTreeMap<String, Version>,
58}
59
60impl Proposal {
61    /// Proposed versions keyed by unit.
62    pub(crate) fn versions(&self) -> BTreeMap<String, Proposed> {
63        self.changes
64            .iter()
65            .map(|change| (change.unit.clone(), change.version.clone()))
66            .collect()
67    }
68}
69
70impl Unit {
71    pub(crate) fn bumped(&self, bump: Bump) -> Result<Version, ProposalError> {
72        bump.apply(&self.version)
73            .ok_or_else(|| ProposalError::BumpOverflow {
74                unit: self.name.clone(),
75                version: self.version.to_string(),
76                bump,
77            })
78    }
79
80    /// Apply a bump, turning a major bump below 1.0.0 into a minor one.
81    fn bumped_in_development(&self, bump: Bump) -> Result<Version, ProposalError> {
82        match bump {
83            Bump::Major if self.version.is_initial_development() => self.bumped(Bump::Minor),
84            bump @ (Bump::Patch | Bump::Minor | Bump::Major) => self.bumped(bump),
85        }
86    }
87
88    pub(crate) fn transitive_dependents(
89        units: &[Self],
90        mut affected: BTreeSet<String>,
91    ) -> BTreeSet<String> {
92        loop {
93            let previous = affected.len();
94            for unit in units {
95                if unit
96                    .dependencies
97                    .iter()
98                    .any(|dependency| affected.contains(dependency))
99                {
100                    affected.insert(unit.name.clone());
101                }
102            }
103            if affected.len() == previous {
104                return affected;
105            }
106        }
107    }
108}
109
110/// The first version of a unit at 0.0.0: its override, or the largest bump its
111/// commits ask for. Applications count every commit and libraries only
112/// features, fixes and breaking changes.
113fn first_version(
114    unit: &Unit,
115    history: &History,
116    policy: Policy<'_>,
117) -> Result<Version, ProposalError> {
118    if let Some(version) = policy.overrides.get(&unit.name) {
119        return Ok(version.clone());
120    }
121    let application = policy.applications.contains(&unit.name);
122    let bump = history
123        .commits
124        .iter()
125        .filter(|commit| application || commit.is_release_worthy())
126        .map(Commit::bump)
127        .max()
128        .filter(|_| history.conventional)
129        .ok_or_else(|| ProposalError::FirstVersionUnknown {
130            unit: unit.name.clone(),
131            path: unit.version_path().clone(),
132        })?;
133    unit.bumped_in_development(bump)
134}
135
136/// Changed units receive their override or the larger of the bumps their
137/// commits and API analysis require. Applications take a patch bump when no
138/// commit asks for more. A breaking change is a minor bump below 1.0.0 and
139/// otherwise a major bump. Applications depending on a changed unit are
140/// bumped too. A unit at 0.0.0 without a tag takes the same bump rules from its
141/// whole history, ignoring API analysis, as a first release. A unit without
142/// history is a first release at its override or its current version.
143pub(crate) fn analyze(
144    units: &[Unit],
145    history: &BTreeMap<String, History>,
146    policy: Policy<'_>,
147    mut required_bump: impl FnMut(&Unit, &str) -> Result<Bump, ProposalError>,
148) -> Result<Proposal, ProposalError> {
149    let mut changes = Vec::new();
150    for unit in units {
151        let Some(unit_history) = history.get(&unit.name) else {
152            changes.push(Change::new(
153                unit,
154                Proposed::First(
155                    policy
156                        .overrides
157                        .get(&unit.name)
158                        .unwrap_or(&unit.version)
159                        .clone(),
160                ),
161            ));
162            continue;
163        };
164        let Some(baseline_tag) = &unit_history.baseline_tag else {
165            if policy.non_bumping.contains(&unit.name) && !policy.overrides.contains_key(&unit.name)
166            {
167                continue;
168            }
169            let version = first_version(unit, unit_history, policy)?;
170            changes.push(Change::new(unit, Proposed::First(version)));
171            continue;
172        };
173        if !unit_history.changed
174            || (policy.non_bumping.contains(&unit.name)
175                && !policy.overrides.contains_key(&unit.name))
176        {
177            continue;
178        }
179        let version = if let Some(version) = policy.overrides.get(&unit.name) {
180            version.clone()
181        } else {
182            let commits = unit_history.commits.iter().map(Commit::bump).max();
183            let bump = if policy.applications.contains(&unit.name) {
184                commits.unwrap_or(Bump::Patch)
185            } else {
186                let required = required_bump(unit, baseline_tag)?;
187                commits.map_or(required, |bump| bump.max(required))
188            };
189            unit.bumped_in_development(bump)?
190        };
191        changes.push(Change::new(unit, Proposed::Bump(version)));
192    }
193    let affected = Unit::transitive_dependents(
194        units,
195        changes.iter().map(|change| change.unit.clone()).collect(),
196    );
197    for unit in units {
198        if !policy.applications.contains(&unit.name)
199            || !affected.contains(&unit.name)
200            || changes.iter().any(|change| change.unit == unit.name)
201        {
202            continue;
203        }
204        let version = match policy.overrides.get(&unit.name) {
205            Some(version) => version.clone(),
206            None => unit.bumped(Bump::Patch)?,
207        };
208        changes.push(Change::new(unit, Proposed::Bump(version)));
209    }
210    let mut proposal = Proposal { changes };
211    let versions = proposal.versions();
212    for change in &mut proposal.changes {
213        let dependencies = units
214            .iter()
215            .find(|unit| unit.name == change.unit)
216            .map_or(&[][..], |unit| unit.dependencies.as_slice());
217        change.dependencies = dependencies
218            .iter()
219            .filter(|name| history.contains_key(*name))
220            .filter_map(|name| match versions.get(name)? {
221                Proposed::Bump(version) => Some((name.clone(), version.clone())),
222                Proposed::First(_) => None,
223            })
224            .collect();
225    }
226    Ok(proposal)
227}
228
229#[cfg(test)]
230mod tests {
231    use std::collections::{BTreeMap, BTreeSet};
232    use std::path::Path;
233
234    use super::{Change, History, Policy, analyze};
235    use crate::commit_subject::CommitSubject;
236    use crate::conventional::Commit;
237    use crate::state::ReleaseTarget;
238    use crate::unit::Unit;
239    use crate::version::{Bump, Proposed, Version};
240
241    fn unit(name: &str, version: &str, dependencies: &[&str]) -> Unit {
242        Unit {
243            name: name.into(),
244            package_name: Some(name.into()),
245            version: Version::parse(version).unwrap(),
246            target: ReleaseTarget::Cargo {
247                manifest: format!("{name}/Cargo.toml").parse().unwrap(),
248                publish: true,
249            },
250            source: vec![name.parse().unwrap()].try_into().unwrap(),
251            changelog: Some(format!("{name}/CHANGELOG.md").parse().unwrap()),
252            tag_prefix: format!("{name}-v"),
253            dependencies: dependencies.iter().map(|value| (*value).into()).collect(),
254            pins: BTreeMap::new(),
255            revision: None,
256        }
257    }
258
259    fn history(entries: &[(&str, &str, bool)]) -> BTreeMap<String, History> {
260        entries
261            .iter()
262            .map(|(name, tag, changed)| {
263                (
264                    (*name).to_owned(),
265                    History {
266                        baseline_tag: Some((*tag).to_owned()),
267                        changed: *changed,
268                        conventional: true,
269                        commits: Vec::new(),
270                    },
271                )
272            })
273            .collect()
274    }
275
276    fn version(value: &str) -> Version {
277        Version::parse(value).unwrap()
278    }
279
280    #[test]
281    fn dependent_closure_reaches_non_publishing_application_through_a_diamond() {
282        let units = [
283            unit("core", "1.0.0", &[]),
284            unit("left", "1.0.0", &["core"]),
285            unit("right", "1.0.0", &["core"]),
286            unit("app", "1.0.0", &["left", "right"]),
287            unit("unrelated", "1.0.0", &[]),
288        ];
289        assert_eq!(
290            Unit::transitive_dependents(&units, BTreeSet::from(["core".into()])),
291            BTreeSet::from(["app".into(), "core".into(), "left".into(), "right".into()])
292        );
293    }
294
295    #[test]
296    fn application_patch_skips_the_analysis() {
297        let units = [unit("app", "3.1.0", &[])];
298        let proposal = analyze(
299            &units,
300            &history(&[("app", "dpdhl-llm-3.1.0-2", true)]),
301            Policy {
302                applications: &BTreeSet::from(["app".into()]),
303                non_bumping: &BTreeSet::new(),
304                overrides: &BTreeMap::new(),
305            },
306            |_, _| panic!("applications are not analyzed"),
307        )
308        .unwrap();
309        assert_eq!(
310            proposal.changes,
311            [Change {
312                unit: "app".into(),
313                version: Proposed::Bump(version("3.1.1")),
314                dependencies: BTreeMap::new(),
315            }]
316        );
317    }
318
319    #[test]
320    fn bumped_units_record_the_updated_versions_of_their_direct_dependencies() {
321        let units = [
322            unit("core", "1.0.0", &[]),
323            unit("lib", "1.0.0", &["core"]),
324            unit("fresh", "0.1.0", &[]),
325            unit("app", "2.0.0", &["lib", "core", "fresh"]),
326        ];
327        let history = history(&[
328            ("core", "core-v1.0.0", true),
329            ("lib", "lib-v1.0.0", false),
330            ("app", "app-v2.0.0", true),
331        ]);
332        let proposal = analyze(
333            &units,
334            &history,
335            Policy {
336                applications: &BTreeSet::from(["app".into()]),
337                non_bumping: &BTreeSet::new(),
338                overrides: &BTreeMap::new(),
339            },
340            |_, _| Ok(Bump::Minor),
341        )
342        .unwrap();
343        let dependencies = |name: &str| {
344            proposal
345                .changes
346                .iter()
347                .find(|change| change.unit == name)
348                .map(|change| change.dependencies.clone())
349        };
350        assert_eq!(dependencies("core"), Some(BTreeMap::new()));
351        assert_eq!(dependencies("lib"), None);
352        assert_eq!(
353            dependencies("app"),
354            Some(BTreeMap::from([("core".into(), version("1.1.0"))]))
355        );
356    }
357
358    #[test]
359    fn application_bump_none_skips_automatic_version_but_accepts_override() {
360        let units = [unit("app", "1.0.0", &[])];
361        let history = history(&[("app", "app-v1.0.0", true)]);
362        let non_bumping = BTreeSet::from(["app".into()]);
363        let mut policy = Policy {
364            applications: &BTreeSet::new(),
365            non_bumping: &non_bumping,
366            overrides: &BTreeMap::new(),
367        };
368        let proposal = analyze(&units, &history, policy, |_, _| Ok(Bump::Patch)).unwrap();
369        assert_eq!(proposal.changes, Vec::<Change>::new());
370
371        let overrides = BTreeMap::from([("app".into(), version("1.1.0"))]);
372        policy.overrides = &overrides;
373        let overridden = analyze(&units, &history, policy, |_, _| Ok(Bump::Patch)).unwrap();
374        assert_eq!(
375            overridden.changes[0].version,
376            Proposed::Bump(version("1.1.0"))
377        );
378    }
379
380    #[test]
381    fn breaking_library_change_bumps_major_and_dependent_applications() {
382        let units = [unit("core", "1.0.0", &[]), unit("app", "2.0.0", &["core"])];
383        let history = history(&[("core", "core-v1.0.0", true), ("app", "app-v2.0.0", false)]);
384        let applications = BTreeSet::from(["app".into()]);
385        let mut policy = Policy {
386            applications: &applications,
387            non_bumping: &BTreeSet::new(),
388            overrides: &BTreeMap::new(),
389        };
390        let expected = BTreeMap::from([
391            ("app".into(), Proposed::Bump(version("2.0.1"))),
392            ("core".into(), Proposed::Bump(version("2.0.0"))),
393        ]);
394        assert_eq!(
395            analyze(&units, &history, policy, |_, _| Ok(Bump::Major))
396                .unwrap()
397                .versions(),
398            expected
399        );
400
401        let overrides = BTreeMap::from([("core".into(), version("3.0.0"))]);
402        policy.overrides = &overrides;
403        let proposal = analyze(&units, &history, policy, |_, _| Ok(Bump::Major)).unwrap();
404        assert_eq!(
405            proposal.versions()["core"],
406            Proposed::Bump(version("3.0.0"))
407        );
408    }
409
410    #[test]
411    fn unchanged_units_are_not_proposed() {
412        let units = [unit("core", "1.0.0", &[])];
413        let policy = Policy {
414            applications: &BTreeSet::new(),
415            non_bumping: &BTreeSet::new(),
416            overrides: &BTreeMap::new(),
417        };
418        let unchanged = history(&[("core", "core-v1.0.0", false)]);
419        assert_eq!(
420            analyze(&units, &unchanged, policy, |_, _| Ok(Bump::Patch))
421                .unwrap()
422                .changes,
423            Vec::<Change>::new()
424        );
425    }
426
427    #[test]
428    fn an_untagged_unit_is_a_first_release_beside_tagged_units() {
429        let units = [unit("core", "1.0.0", &[]), unit("new", "0.3.0", &[])];
430        let history = history(&[("core", "core-v1.0.0", true)]);
431        let mut policy = Policy {
432            applications: &BTreeSet::new(),
433            non_bumping: &BTreeSet::from(["new".into()]),
434            overrides: &BTreeMap::new(),
435        };
436        assert_eq!(
437            analyze(&units, &history, policy, |unit, _| {
438                assert_eq!(unit.name, "core", "a first release has nothing to compare");
439                Ok(Bump::Patch)
440            })
441            .unwrap()
442            .versions(),
443            BTreeMap::from([
444                ("core".into(), Proposed::Bump(version("1.0.1"))),
445                ("new".into(), Proposed::First(version("0.3.0"))),
446            ])
447        );
448
449        let overrides = BTreeMap::from([("new".into(), version("1.0.0"))]);
450        policy.overrides = &overrides;
451        assert_eq!(
452            analyze(&units, &history, policy, |_, _| Ok(Bump::Patch))
453                .unwrap()
454                .versions()["new"],
455            Proposed::First(version("1.0.0"))
456        );
457    }
458
459    #[test]
460    fn bump_overflow_names_the_bump() {
461        assert_eq!(
462            unit("a", "1.2.3", &[]).bumped(Bump::Patch).unwrap(),
463            version("1.2.4")
464        );
465        assert!(matches!(
466            unit("a", "1.2.18446744073709551615", &[]).bumped(Bump::Patch),
467            Err(super::ProposalError::BumpOverflow { unit, version: _, bump: Bump::Patch })
468                if unit == "a"
469        ));
470        assert!(matches!(
471            unit("a", "1.18446744073709551615.0", &[]).bumped(Bump::Minor),
472            Err(super::ProposalError::BumpOverflow {
473                unit: _,
474                version: _,
475                bump: Bump::Minor
476            })
477        ));
478    }
479
480    fn library_bump(version: &str, bump: Bump) -> Result<Version, super::ProposalError> {
481        let units = [unit("core", version, &[])];
482        analyze(
483            &units,
484            &history(&[("core", "core-v1", true)]),
485            Policy {
486                applications: &BTreeSet::new(),
487                non_bumping: &BTreeSet::new(),
488                overrides: &BTreeMap::new(),
489            },
490            |_, _| Ok(bump),
491        )
492        .map(|proposal| proposal.versions()["core"].version().clone())
493    }
494
495    #[test]
496    fn library_takes_the_bump_analysis_requires() {
497        assert_eq!(
498            library_bump("1.2.3", Bump::Patch).unwrap(),
499            version("1.2.4")
500        );
501        assert_eq!(
502            library_bump("1.2.3", Bump::Minor).unwrap(),
503            version("1.3.0")
504        );
505    }
506
507    #[test]
508    fn major_below_one_is_a_minor_bump() {
509        assert_eq!(
510            library_bump("0.4.7", Bump::Major).unwrap(),
511            version("0.5.0")
512        );
513    }
514
515    fn commits(messages: &[&str]) -> Vec<Commit> {
516        Commit::from_messages(
517            messages.iter().copied(),
518            &CommitSubject::try_from("release: {unit} {version}").unwrap(),
519        )
520    }
521
522    fn commit_bump(version: &str, application: bool, required: Bump, messages: &[&str]) -> Version {
523        let units = [unit("core", version, &[])];
524        let mut history = history(&[("core", "core-v1", true)]);
525        history.get_mut("core").unwrap().commits = commits(messages);
526        let applications = if application {
527            BTreeSet::from(["core".into()])
528        } else {
529            BTreeSet::new()
530        };
531        let policy = Policy {
532            applications: &applications,
533            non_bumping: &BTreeSet::new(),
534            overrides: &BTreeMap::new(),
535        };
536        analyze(&units, &history, policy, |_, _| Ok(required))
537            .unwrap()
538            .versions()["core"]
539            .version()
540            .clone()
541    }
542
543    #[test]
544    fn commits_replace_the_application_patch() {
545        let bump = |version, messages| commit_bump(version, true, Bump::Major, messages);
546        assert_eq!(bump("1.2.3", &["fix: a", "feat: b"]), version("1.3.0"));
547        assert_eq!(bump("1.2.3", &["fix: a", "docs: b"]), version("1.2.4"));
548        assert_eq!(bump("1.2.3", &["fix!: a"]), version("2.0.0"));
549        assert_eq!(
550            bump("0.2.3", &["fix: a\n\nBREAKING CHANGE: b"]),
551            version("0.3.0")
552        );
553        assert_eq!(bump("1.2.3", &[]), version("1.2.4"));
554    }
555
556    #[test]
557    fn a_library_takes_the_larger_of_the_commit_and_api_bumps() {
558        let bump = |required, messages| commit_bump("1.2.3", false, required, messages);
559        assert_eq!(bump(Bump::Patch, &["feat: a"]), version("1.3.0"));
560        assert_eq!(bump(Bump::Minor, &["fix: a"]), version("1.3.0"));
561        assert_eq!(bump(Bump::Patch, &["fix: a", "Change b"]), version("1.2.4"));
562        assert_eq!(bump(Bump::Minor, &["refactor!: a"]), version("2.0.0"));
563        assert_eq!(
564            commit_bump("0.4.0", false, Bump::Patch, &["feat!: a"]),
565            version("0.5.0")
566        );
567    }
568
569    fn first_version(
570        application: bool,
571        conventional: bool,
572        overrides: &BTreeMap<String, Version>,
573        messages: &[&str],
574    ) -> Result<Proposed, super::ProposalError> {
575        let units = [unit("core", "0.0.0", &[])];
576        let history = BTreeMap::from([(
577            "core".to_owned(),
578            History {
579                baseline_tag: None,
580                changed: true,
581                conventional,
582                commits: commits(messages),
583            },
584        )]);
585        let applications = if application {
586            BTreeSet::from(["core".into()])
587        } else {
588            BTreeSet::new()
589        };
590        analyze(
591            &units,
592            &history,
593            Policy {
594                applications: &applications,
595                non_bumping: &BTreeSet::new(),
596                overrides,
597            },
598            |_, _| Ok(Bump::Major),
599        )
600        .map(|proposal| proposal.versions()["core"].clone())
601    }
602
603    #[test]
604    fn an_unversioned_unit_takes_its_first_version_from_its_commits() {
605        let first = |application, messages: &[&str]| {
606            first_version(application, true, &BTreeMap::new(), messages).unwrap()
607        };
608        assert_eq!(
609            first(false, &["feat: a"]),
610            Proposed::First(version("0.1.0"))
611        );
612        assert_eq!(
613            first(false, &["fix: a", "feat: b"]),
614            Proposed::First(version("0.1.0"))
615        );
616        assert_eq!(first(false, &["fix: a"]), Proposed::First(version("0.0.1")));
617        assert_eq!(
618            first(false, &["fix!: a"]),
619            Proposed::First(version("0.1.0"))
620        );
621        assert_eq!(
622            first(true, &["chore: a"]),
623            Proposed::First(version("0.0.1"))
624        );
625    }
626
627    #[test]
628    fn an_unversioned_override_wins() {
629        let overrides = BTreeMap::from([("core".into(), version("1.0.0"))]);
630        assert_eq!(
631            first_version(false, false, &overrides, &[]).unwrap(),
632            Proposed::First(version("1.0.0"))
633        );
634        assert_eq!(
635            first_version(false, true, &overrides, &["feat: a"]).unwrap(),
636            Proposed::First(version("1.0.0"))
637        );
638    }
639
640    #[test]
641    fn an_unversioned_unit_without_a_bumping_commit_is_an_error() {
642        for (application, conventional, messages) in [
643            (false, false, &["feat: a"][..]),
644            (true, false, &["feat: a"][..]),
645            (false, true, &["chore: a"][..]),
646            (false, true, &[][..]),
647            (true, true, &[][..]),
648        ] {
649            assert!(
650                matches!(
651                    first_version(application, conventional, &BTreeMap::new(), messages),
652                    Err(super::ProposalError::FirstVersionUnknown { unit, path })
653                        if unit == "core" && path.as_path() == Path::new("core/Cargo.toml")
654                ),
655                "{application} {conventional} {messages:?}"
656            );
657        }
658    }
659
660    #[test]
661    fn major_from_one_is_a_major_bump() {
662        assert_eq!(
663            library_bump("1.4.2", Bump::Major).unwrap(),
664            version("2.0.0")
665        );
666    }
667}