1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ReleaseUnitVersion {
18 pub current: Version,
20 pub next: Version,
22 pub bump: Bump,
24}
25
26impl ReleaseUnitVersion {
27 pub fn new(current: Version, bump: Bump) -> Self {
29 Self::new_with_mapping(current, bump, Pre1BumpMapping::Compatibility)
30 }
31
32 pub fn new_with_mapping(current: Version, bump: Bump, mapping: Pre1BumpMapping) -> Self {
34 let next = bump_version_with_mapping(¤t, bump, mapping);
35 Self {
36 current,
37 next,
38 bump,
39 }
40 }
41}
42
43pub 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
59pub 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
124pub 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
240pub fn bump_version(current: &Version, bump: Bump) -> Version {
249 bump_version_with_mapping(current, bump, Pre1BumpMapping::Compatibility)
250}
251
252pub 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
288pub struct VersionRepository {
290 repository: tagver::Repository,
291}
292
293impl VersionRepository {
294 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 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 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 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 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 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 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(¤t) {
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(¤t, Bump::Major), Version::new(0, 5, 0));
467 assert_eq!(bump_version(¤t, Bump::Minor), Version::new(0, 4, 2));
468 assert_eq!(bump_version(¤t, 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(¤t, Bump::Major), Version::new(2, 0, 0));
475 assert_eq!(bump_version(¤t, Bump::Minor), Version::new(1, 5, 0));
476 assert_eq!(bump_version(¤t, 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(¤t, 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}