1use std::collections::{BTreeMap, BTreeSet};
2
3use semver::Version;
4use serde::Serialize;
5
6use crate::{BumpLevel, ChangesetId, EcosystemId, FileEdit, PackageId, PlanWarning, ReleaseReason};
7
8pub type VersionMap = BTreeMap<PackageId, Version>;
9
10#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
12pub struct PackageRelease {
13 pub id: PackageId,
14 pub ecosystem: EcosystemId,
15 pub current_version: Version,
16 pub next_version: Version,
17 pub bump: BumpLevel,
18 pub reasons: Vec<ReleaseReason>,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
23pub struct ReleasePlan {
24 packages: Vec<PackageRelease>,
25 versions: VersionMap,
26 order: Vec<PackageId>,
27 consumed_changesets: Vec<ChangesetId>,
28 warnings: Vec<PlanWarning>,
29 file_edits: Vec<FileEdit>,
30}
31
32impl ReleasePlan {
33 pub fn new(
34 mut packages: Vec<PackageRelease>,
35 versions: VersionMap,
36 order: Vec<PackageId>,
37 mut consumed_changesets: Vec<ChangesetId>,
38 mut warnings: Vec<PlanWarning>,
39 mut file_edits: Vec<FileEdit>,
40 ) -> Result<Self, ReleasePlanError> {
41 let mut package_ids = BTreeSet::new();
42 for package in &mut packages {
43 if !package_ids.insert(package.id.clone()) {
44 return Err(ReleasePlanError::DuplicatePackage {
45 package: package.id.clone(),
46 });
47 }
48 let planned =
49 versions
50 .get(&package.id)
51 .ok_or_else(|| ReleasePlanError::MissingVersion {
52 package: package.id.clone(),
53 })?;
54 if planned != &package.next_version {
55 return Err(ReleasePlanError::VersionMismatch {
56 package: package.id.clone(),
57 release: package.next_version.clone(),
58 planned: planned.clone(),
59 });
60 }
61 package.reasons.sort();
62 if package
63 .reasons
64 .windows(2)
65 .any(|reasons| matches!(reasons, [left, right] if left == right))
66 {
67 return Err(ReleasePlanError::DuplicateReason {
68 package: package.id.clone(),
69 });
70 }
71 }
72 packages.sort_by(|left, right| left.id.cmp(&right.id));
73
74 let mut ordered_ids = BTreeSet::new();
75 for package in &order {
76 if !ordered_ids.insert(package.clone()) {
77 return Err(ReleasePlanError::DuplicateOrderPackage {
78 package: package.clone(),
79 });
80 }
81 }
82 if ordered_ids != package_ids {
83 return Err(ReleasePlanError::OrderDoesNotMatchPackages);
84 }
85
86 consumed_changesets.sort();
87 if consumed_changesets
88 .windows(2)
89 .any(|changesets| matches!(changesets, [left, right] if left == right))
90 {
91 return Err(ReleasePlanError::DuplicateChangeset);
92 }
93 warnings.sort();
94 file_edits.sort_by(|left, right| left.path.cmp(&right.path));
95 if let Some(path) = file_edits.windows(2).find_map(|edits| match edits {
96 [left, right] if left.path == right.path => Some(left.path.clone()),
97 _ => None,
98 }) {
99 return Err(ReleasePlanError::DuplicateFileEdit { path });
100 }
101
102 Ok(Self {
103 packages,
104 versions,
105 order,
106 consumed_changesets,
107 warnings,
108 file_edits,
109 })
110 }
111
112 #[must_use]
113 pub fn packages(&self) -> &[PackageRelease] {
114 &self.packages
115 }
116
117 #[must_use]
118 pub fn package(&self, id: &PackageId) -> Option<&PackageRelease> {
119 self.packages.iter().find(|package| &package.id == id)
120 }
121
122 #[must_use]
123 pub fn versions(&self) -> &VersionMap {
124 &self.versions
125 }
126
127 #[must_use]
128 pub fn order(&self) -> &[PackageId] {
129 &self.order
130 }
131
132 #[must_use]
133 pub fn consumed_changesets(&self) -> &[ChangesetId] {
134 &self.consumed_changesets
135 }
136
137 #[must_use]
138 pub fn warnings(&self) -> &[PlanWarning] {
139 &self.warnings
140 }
141
142 #[must_use]
143 pub fn file_edits(&self) -> &[FileEdit] {
144 &self.file_edits
145 }
146
147 pub fn with_file_edits(self, file_edits: Vec<FileEdit>) -> Result<Self, ReleasePlanError> {
149 Self::new(
150 self.packages,
151 self.versions,
152 self.order,
153 self.consumed_changesets,
154 self.warnings,
155 file_edits,
156 )
157 }
158}
159
160#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
161pub enum ReleasePlanError {
162 #[error("duplicate package release: {package}")]
163 DuplicatePackage { package: PackageId },
164 #[error("release package is missing from the complete version map: {package}")]
165 MissingVersion { package: PackageId },
166 #[error(
167 "release version for {package} is {release}, but the complete version map contains {planned}"
168 )]
169 VersionMismatch {
170 package: PackageId,
171 release: Version,
172 planned: Version,
173 },
174 #[error("duplicate release reason for package: {package}")]
175 DuplicateReason { package: PackageId },
176 #[error("duplicate package in release order: {package}")]
177 DuplicateOrderPackage { package: PackageId },
178 #[error("release order does not contain exactly the released packages")]
179 OrderDoesNotMatchPackages,
180 #[error("duplicate consumed changeset")]
181 DuplicateChangeset,
182 #[error("multiple planned edits target the same file: {path}")]
183 DuplicateFileEdit { path: camino::Utf8PathBuf },
184}
185
186#[cfg(test)]
187mod tests {
188 use camino::Utf8PathBuf;
189
190 use super::*;
191 use crate::{EditSource, FileEditExpectation, FileHash};
192
193 fn package_release(
194 id: &str,
195 current: (u64, u64, u64),
196 next: (u64, u64, u64),
197 ) -> PackageRelease {
198 PackageRelease {
199 id: PackageId::new(id),
200 ecosystem: EcosystemId::RUST,
201 current_version: Version::new(current.0, current.1, current.2),
202 next_version: Version::new(next.0, next.1, next.2),
203 bump: BumpLevel::Patch,
204 reasons: vec![ReleaseReason::Changeset {
205 changeset: ChangesetId::new(format!("{id}-change")),
206 }],
207 }
208 }
209
210 fn file_edit(package: &str, path: &str) -> FileEdit {
211 FileEdit {
212 path: Utf8PathBuf::from(path),
213 expected: FileEditExpectation::Existing {
214 hash: FileHash::from_bytes(format!("hash-{package}").as_bytes()),
215 },
216 new_content: format!("updated {package}"),
217 source: EditSource::PackageVersion {
218 package: PackageId::new(package),
219 },
220 }
221 }
222
223 #[test]
224 fn canonicalizes_unordered_collections_for_stable_serialization() {
225 let alpha = package_release("alpha", (1, 0, 0), (1, 0, 1));
226 let beta = package_release("beta", (2, 0, 0), (2, 0, 1));
227 let versions = BTreeMap::from([
228 (PackageId::new("alpha"), Version::new(1, 0, 1)),
229 (PackageId::new("beta"), Version::new(2, 0, 1)),
230 (PackageId::new("unchanged"), Version::new(3, 0, 0)),
231 ]);
232 let first = ReleasePlan::new(
233 vec![beta.clone(), alpha.clone()],
234 versions.clone(),
235 vec![PackageId::new("alpha"), PackageId::new("beta")],
236 vec![
237 ChangesetId::new("beta-change"),
238 ChangesetId::new("alpha-change"),
239 ],
240 vec![
241 PlanWarning::NonPatchBumpOnPrerelease {
242 package: PackageId::new("beta"),
243 requested: BumpLevel::Major,
244 },
245 PlanWarning::NonPatchBumpOnPrerelease {
246 package: PackageId::new("alpha"),
247 requested: BumpLevel::Minor,
248 },
249 ],
250 vec![file_edit("beta", "b.toml"), file_edit("alpha", "a.toml")],
251 )
252 .unwrap();
253 let second = ReleasePlan::new(
254 vec![alpha, beta],
255 versions,
256 vec![PackageId::new("alpha"), PackageId::new("beta")],
257 vec![
258 ChangesetId::new("alpha-change"),
259 ChangesetId::new("beta-change"),
260 ],
261 vec![
262 PlanWarning::NonPatchBumpOnPrerelease {
263 package: PackageId::new("alpha"),
264 requested: BumpLevel::Minor,
265 },
266 PlanWarning::NonPatchBumpOnPrerelease {
267 package: PackageId::new("beta"),
268 requested: BumpLevel::Major,
269 },
270 ],
271 vec![file_edit("alpha", "a.toml"), file_edit("beta", "b.toml")],
272 )
273 .unwrap();
274
275 assert_eq!(first, second);
276 assert_eq!(
277 serde_json::to_string(&first).unwrap(),
278 serde_json::to_string(&second).unwrap()
279 );
280 assert_eq!(
281 first.versions()[&PackageId::new("unchanged")],
282 Version::new(3, 0, 0)
283 );
284 }
285
286 #[test]
287 fn rejects_release_versions_that_disagree_with_the_complete_map() {
288 let package = package_release("core", (1, 0, 0), (1, 1, 0));
289 let error = ReleasePlan::new(
290 vec![package],
291 BTreeMap::from([(PackageId::new("core"), Version::new(1, 0, 1))]),
292 vec![PackageId::new("core")],
293 vec![],
294 vec![],
295 vec![],
296 )
297 .unwrap_err();
298
299 assert_eq!(
300 error,
301 ReleasePlanError::VersionMismatch {
302 package: PackageId::new("core"),
303 release: Version::new(1, 1, 0),
304 planned: Version::new(1, 0, 1),
305 }
306 );
307 }
308
309 #[test]
310 fn rejects_release_order_that_does_not_match_release_packages() {
311 let package = package_release("core", (1, 0, 0), (1, 0, 1));
312 let error = ReleasePlan::new(
313 vec![package],
314 BTreeMap::from([(PackageId::new("core"), Version::new(1, 0, 1))]),
315 vec![PackageId::new("other")],
316 vec![],
317 vec![],
318 vec![],
319 )
320 .unwrap_err();
321
322 assert_eq!(error, ReleasePlanError::OrderDoesNotMatchPackages);
323 }
324}