Skip to main content

semifold_core/
release_plan.rs

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/// Planned version transition for one package that will be released.
11#[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/// Immutable result of release-domain planning.
22#[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    /// Returns the same validated release plan with its planned file edits attached.
148    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}