1use std::collections::{BTreeMap, BTreeSet};
5
6use super::ProposalError;
7use crate::conventional::Commit;
8use crate::unit::Unit;
9use crate::version::{Bump, Proposed, Version};
10
11#[derive(Clone, Debug, Eq, PartialEq)]
13pub(crate) struct Change {
14 pub(crate) unit: String,
15 pub(crate) version: Proposed,
16 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#[derive(Clone, Debug, Eq, PartialEq)]
33pub(crate) struct Proposal {
34 pub(crate) changes: Vec<Change>,
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
40pub(crate) struct History {
41 pub(crate) baseline_tag: Option<String>,
42 pub(crate) changed: bool,
43 pub(crate) conventional: bool,
45 pub(crate) commits: Vec<Commit>,
47}
48
49#[derive(Clone, Copy, Debug)]
51pub(crate) struct Policy<'a> {
52 pub(crate) applications: &'a BTreeSet<String>,
55 pub(crate) non_bumping: &'a BTreeSet<String>,
57 pub(crate) overrides: &'a BTreeMap<String, Version>,
58}
59
60impl Proposal {
61 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 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
110fn 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
136pub(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}