1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PackageVersion {
18 pub current: Version,
20 pub next: Version,
22 pub bump: Bump,
24}
25
26impl PackageVersion {
27 pub fn new(current: Version, bump: Bump) -> Self {
29 let next = bump_version(¤t, bump);
30 Self {
31 current,
32 next,
33 bump,
34 }
35 }
36}
37
38pub 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
54pub 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
100pub 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
139pub struct VersionRepository {
141 repository: tagver::Repository,
142}
143
144impl VersionRepository {
145 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 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 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 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(¤t) {
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(¤t, Bump::Major), Version::new(0, 5, 0));
283 assert_eq!(bump_version(¤t, Bump::Minor), Version::new(0, 4, 2));
284 assert_eq!(bump_version(¤t, 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(¤t, Bump::Major), Version::new(2, 0, 0));
291 assert_eq!(bump_version(¤t, Bump::Minor), Version::new(1, 5, 0));
292 assert_eq!(bump_version(¤t, 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(¤t, 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}