1use std::collections::BTreeMap;
2
3use semver::Version;
4use serde::Serialize;
5use sha2::{Digest, Sha256};
6
7use crate::{
8 BumpLevel, ChangesetId, EcosystemId, PackageId, PackageSnapshot, ReleasePlan, ReleaseReason,
9};
10
11#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
13pub struct ReleaseContext {
14 pub plan: ReleasePlanContext,
15 pub repository: Option<RepositoryContext>,
16 pub ci: Option<CiContext>,
17}
18
19impl ReleaseContext {
20 #[must_use]
21 pub fn from_plan(plan: &ReleasePlan) -> Self {
22 Self {
23 plan: ReleasePlanContext::from(plan),
24 repository: None,
25 ci: None,
26 }
27 }
28
29 #[must_use]
30 pub fn with_repository(mut self, repository: RepositoryContext) -> Self {
31 self.repository = Some(repository);
32 self
33 }
34
35 #[must_use]
36 pub fn with_ci(mut self, ci: CiContext) -> Self {
37 self.ci = Some(ci);
38 self
39 }
40}
41
42#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
44pub struct ReleasePlanContext {
45 pub packages: BTreeMap<PackageId, PackageReleaseContext>,
46 pub changesets: Vec<ChangesetId>,
47 pub common_version: Option<Version>,
48 pub fingerprint: String,
49}
50
51impl From<&ReleasePlan> for ReleasePlanContext {
52 fn from(plan: &ReleasePlan) -> Self {
53 let packages = plan
54 .packages()
55 .iter()
56 .map(|package| {
57 (
58 package.id.clone(),
59 PackageReleaseContext {
60 id: package.id.clone(),
61 ecosystem: package.ecosystem.clone(),
62 current_version: package.current_version.clone(),
63 next_version: package.next_version.clone(),
64 bump: package.bump,
65 reasons: package
66 .reasons
67 .iter()
68 .map(ReleaseReasonContext::from)
69 .collect(),
70 },
71 )
72 })
73 .collect::<BTreeMap<_, _>>();
74 let changesets = plan.consumed_changesets().to_vec();
75 let common_version = common_version(&packages);
76 let fingerprint = plan_fingerprint(&packages, &changesets);
77
78 Self {
79 packages,
80 changesets,
81 common_version,
82 fingerprint,
83 }
84 }
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
88pub struct PackageReleaseContext {
89 pub id: PackageId,
90 pub ecosystem: EcosystemId,
91 pub current_version: Version,
92 pub next_version: Version,
93 pub bump: BumpLevel,
94 pub reasons: Vec<ReleaseReasonContext>,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
98#[serde(tag = "kind", rename_all = "snake_case")]
99pub enum ReleaseReasonContext {
100 Changeset {
101 changeset: ChangesetId,
102 },
103 DependencyPropagation {
104 dependency: PackageId,
105 next_version: Version,
106 },
107 SharedVersionPropagation {
108 source: crate::VersionSourceId,
109 },
110}
111
112impl From<&ReleaseReason> for ReleaseReasonContext {
113 fn from(reason: &ReleaseReason) -> Self {
114 match reason {
115 ReleaseReason::Changeset { changeset } => Self::Changeset {
116 changeset: changeset.clone(),
117 },
118 ReleaseReason::DependencyPropagation {
119 dependency,
120 next_version,
121 } => Self::DependencyPropagation {
122 dependency: dependency.clone(),
123 next_version: next_version.clone(),
124 },
125 ReleaseReason::SharedVersionPropagation { source } => Self::SharedVersionPropagation {
126 source: source.clone(),
127 },
128 }
129 }
130}
131
132#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
133pub struct RepositoryContext {
134 pub host: String,
135 pub owner: String,
136 pub name: String,
137 pub web_url: String,
138 pub commit: Option<CommitContext>,
139}
140
141#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
142pub struct CommitContext {
143 pub sha: String,
144 pub short_sha: String,
145 pub author: Option<String>,
146 pub web_url: Option<String>,
147}
148
149#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
150pub struct PullRequestContext {
151 pub number: u64,
152 pub author: Option<String>,
153 pub web_url: Option<String>,
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
157pub struct CiContext {
158 pub provider: CiProvider,
159 pub run_id: Option<String>,
160 pub run_url: Option<String>,
161 pub ref_name: Option<String>,
162}
163
164#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
166#[serde(transparent)]
167pub struct CiProvider(String);
168
169impl CiProvider {
170 #[must_use]
171 pub fn new(value: impl Into<String>) -> Self {
172 Self(value.into())
173 }
174
175 #[must_use]
176 pub fn as_str(&self) -> &str {
177 &self.0
178 }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
182pub struct ReleasePackageContext<'release> {
183 pub release: &'release ReleaseContext,
184 pub package: ReleasePackageTemplateContext,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
188pub struct ReleasePackageTemplateContext {
189 pub id: PackageId,
190 pub name: String,
191 pub ecosystem: EcosystemId,
192 pub current_version: Version,
193 pub next_version: Version,
194 pub version: Version,
195 pub tag: String,
196 pub path: camino::Utf8PathBuf,
197 pub private: bool,
198}
199
200impl<'release> ReleasePackageContext<'release> {
201 pub fn from_snapshot(
202 release: &'release ReleaseContext,
203 snapshot: &PackageSnapshot,
204 ) -> Result<Self, ReleasePackageContextError> {
205 let planned = release.plan.packages.get(&snapshot.id).ok_or_else(|| {
206 ReleasePackageContextError::PackageNotReleased {
207 package: snapshot.id.clone(),
208 }
209 })?;
210 if snapshot.version != planned.current_version {
211 return Err(ReleasePackageContextError::CurrentVersionMismatch {
212 package: snapshot.id.clone(),
213 snapshot: snapshot.version.clone(),
214 planned: planned.current_version.clone(),
215 });
216 }
217 if snapshot.ecosystem != planned.ecosystem {
218 return Err(ReleasePackageContextError::EcosystemMismatch {
219 package: snapshot.id.clone(),
220 snapshot: snapshot.ecosystem.clone(),
221 planned: planned.ecosystem.clone(),
222 });
223 }
224 let next_version = planned.next_version.clone();
225 Ok(Self {
226 release,
227 package: ReleasePackageTemplateContext {
228 id: snapshot.id.clone(),
229 name: snapshot.manifest_name.clone(),
230 ecosystem: snapshot.ecosystem.clone(),
231 current_version: snapshot.version.clone(),
232 version: next_version.clone(),
233 tag: format!("{}-v{next_version}", snapshot.manifest_name),
234 next_version,
235 path: snapshot.path.clone(),
236 private: !snapshot.publishable,
237 },
238 })
239 }
240}
241
242#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
243pub enum ReleasePackageContextError {
244 #[error("package is not part of the release plan: {package}")]
245 PackageNotReleased { package: PackageId },
246 #[error(
247 "package {package} has current version {snapshot}, but the release plan expects {planned}"
248 )]
249 CurrentVersionMismatch {
250 package: PackageId,
251 snapshot: Version,
252 planned: Version,
253 },
254 #[error(
255 "package {package} has ecosystem {snapshot_name}, but the release plan expects {planned_name}",
256 snapshot_name = .snapshot.display_name(),
257 planned_name = .planned.display_name()
258 )]
259 EcosystemMismatch {
260 package: PackageId,
261 snapshot: EcosystemId,
262 planned: EcosystemId,
263 },
264}
265
266#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
267pub struct ChangesetContext {
268 pub id: ChangesetId,
269 pub summary: String,
270 pub summary_paragraphs: Vec<Vec<String>>,
271 pub commit: Option<CommitContext>,
272 pub pull_request: Option<PullRequestContext>,
273}
274
275#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
276pub struct PackageChangesetContext {
277 pub changeset: ChangesetContext,
278 pub section: String,
279}
280
281#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
282pub struct DependencyUpdateContext {
283 pub package: PackageId,
284 pub next_version: Version,
285}
286
287#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
288pub struct ChangelogContext<'release> {
289 pub package: ReleasePackageContext<'release>,
290 pub changesets: Vec<PackageChangesetContext>,
291 pub dependency_updates: Vec<DependencyUpdateContext>,
292}
293
294fn common_version(packages: &BTreeMap<PackageId, PackageReleaseContext>) -> Option<Version> {
295 let mut versions = packages.values().map(|package| &package.next_version);
296 let first = versions.next()?;
297 versions
298 .all(|version| version == first)
299 .then(|| first.clone())
300}
301
302fn plan_fingerprint(
303 packages: &BTreeMap<PackageId, PackageReleaseContext>,
304 changesets: &[ChangesetId],
305) -> String {
306 let mut hasher = Sha256::new();
307 for (id, package) in packages {
308 hash_field(&mut hasher, b"package", id.as_str());
309 hash_field(&mut hasher, b"version", &package.next_version.to_string());
310 }
311 for changeset in changesets {
312 hash_field(&mut hasher, b"changeset", changeset.as_str());
313 }
314 let digest = hasher.finalize();
315 digest
316 .iter()
317 .take(6)
318 .map(|byte| format!("{byte:02x}"))
319 .collect()
320}
321
322fn hash_field(hasher: &mut Sha256, kind: &[u8], value: &str) {
323 hasher.update(kind);
324 hasher.update([0]);
325 hasher.update(value.len().to_string().as_bytes());
326 hasher.update(b":");
327 hasher.update(value.as_bytes());
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::{PackageRelease, VersionMap, VersionSource};
334
335 fn package(id: &str, next: Version) -> PackageRelease {
336 PackageRelease {
337 id: PackageId::new(id),
338 ecosystem: EcosystemId::RUST,
339 current_version: Version::new(1, 0, 0),
340 next_version: next,
341 bump: BumpLevel::Minor,
342 reasons: vec![ReleaseReason::Changeset {
343 changeset: ChangesetId::new(format!("{id}-change")),
344 }],
345 }
346 }
347
348 fn plan(packages: Vec<PackageRelease>, changesets: Vec<&str>) -> ReleasePlan {
349 let versions = packages
350 .iter()
351 .map(|package| (package.id.clone(), package.next_version.clone()))
352 .collect::<VersionMap>();
353 let order = packages.iter().map(|package| package.id.clone()).collect();
354 ReleasePlan::new(
355 packages,
356 versions,
357 order,
358 changesets.into_iter().map(ChangesetId::new).collect(),
359 Vec::new(),
360 Vec::new(),
361 )
362 .unwrap()
363 }
364
365 #[test]
366 fn single_package_has_common_version_and_stable_projection() {
367 let plan = plan(
368 vec![package("core", Version::new(1, 1, 0))],
369 vec!["core-change"],
370 );
371 let context = ReleasePlanContext::from(&plan);
372
373 assert_eq!(context.common_version, Some(Version::new(1, 1, 0)));
374 assert_eq!(context.changesets, vec![ChangesetId::new("core-change")]);
375 assert_eq!(
376 context.packages.keys().next(),
377 Some(&PackageId::new("core"))
378 );
379 assert_eq!(context.fingerprint.len(), 12);
380 assert!(
381 context
382 .fingerprint
383 .bytes()
384 .all(|byte| byte.is_ascii_hexdigit())
385 );
386 }
387
388 #[test]
389 fn multiple_packages_only_have_common_version_when_versions_match() {
390 let matching = plan(
391 vec![
392 package("b", Version::new(2, 0, 0)),
393 package("a", Version::new(2, 0, 0)),
394 ],
395 vec!["b-change", "a-change"],
396 );
397 let different = plan(
398 vec![
399 package("a", Version::new(2, 0, 0)),
400 package("b", Version::new(1, 1, 0)),
401 ],
402 vec!["a-change", "b-change"],
403 );
404
405 assert_eq!(
406 ReleasePlanContext::from(&matching).common_version,
407 Some(Version::new(2, 0, 0))
408 );
409 assert_eq!(ReleasePlanContext::from(&different).common_version, None);
410 }
411
412 #[test]
413 fn empty_plan_has_no_common_version() {
414 let context = ReleasePlanContext::from(&plan(Vec::new(), Vec::new()));
415
416 assert_eq!(context.common_version, None);
417 assert!(context.packages.is_empty());
418 }
419
420 #[test]
421 fn fingerprint_is_independent_of_input_order() {
422 let first = plan(
423 vec![
424 package("b", Version::new(1, 2, 0)),
425 package("a", Version::new(2, 0, 0)),
426 ],
427 vec!["z-change", "a-change"],
428 );
429 let second = plan(
430 vec![
431 package("a", Version::new(2, 0, 0)),
432 package("b", Version::new(1, 2, 0)),
433 ],
434 vec!["a-change", "z-change"],
435 );
436
437 assert_eq!(
438 ReleasePlanContext::from(&first).fingerprint,
439 ReleasePlanContext::from(&second).fingerprint
440 );
441 }
442
443 #[test]
444 fn fingerprint_changes_with_version_or_changeset_identity() {
445 let baseline = ReleasePlanContext::from(&plan(
446 vec![package("core", Version::new(1, 1, 0))],
447 vec!["change-a"],
448 ));
449 let version_changed = ReleasePlanContext::from(&plan(
450 vec![package("core", Version::new(1, 2, 0))],
451 vec!["change-a"],
452 ));
453 let changeset_changed = ReleasePlanContext::from(&plan(
454 vec![package("core", Version::new(1, 1, 0))],
455 vec!["change-b"],
456 ));
457
458 assert_ne!(baseline.fingerprint, version_changed.fingerprint);
459 assert_ne!(baseline.fingerprint, changeset_changed.fingerprint);
460 }
461
462 #[test]
463 fn release_package_context_projects_manifest_and_planned_facts() {
464 let plan = plan(
465 vec![package("configured-id", Version::new(1, 1, 0))],
466 vec!["release-change"],
467 );
468 let release = ReleaseContext::from_plan(&plan);
469 let snapshot = PackageSnapshot {
470 id: PackageId::new("configured-id"),
471 manifest_name: "manifest-name".to_string(),
472 version: Version::new(1, 0, 0),
473 version_source: VersionSource::PackageManifest,
474 ecosystem: EcosystemId::RUST,
475 path: "crates/package".into(),
476 publishable: false,
477 dependencies: Vec::new(),
478 };
479
480 let context = ReleasePackageContext::from_snapshot(&release, &snapshot).unwrap();
481
482 assert_eq!(context.package.id, PackageId::new("configured-id"));
483 assert_eq!(context.package.name, "manifest-name");
484 assert_eq!(context.package.current_version, Version::new(1, 0, 0));
485 assert_eq!(context.package.next_version, Version::new(1, 1, 0));
486 assert_eq!(context.package.version, Version::new(1, 1, 0));
487 assert_eq!(context.package.tag, "manifest-name-v1.1.0");
488 assert!(context.package.private);
489 }
490
491 #[test]
492 fn release_package_context_rejects_non_released_or_changed_snapshots() {
493 let plan = plan(
494 vec![package("configured-id", Version::new(1, 1, 0))],
495 vec!["release-change"],
496 );
497 let release = ReleaseContext::from_plan(&plan);
498 let mut snapshot = PackageSnapshot {
499 id: PackageId::new("missing"),
500 manifest_name: "missing".to_string(),
501 version: Version::new(1, 0, 0),
502 version_source: VersionSource::PackageManifest,
503 ecosystem: EcosystemId::RUST,
504 path: "crates/missing".into(),
505 publishable: true,
506 dependencies: Vec::new(),
507 };
508
509 assert!(matches!(
510 ReleasePackageContext::from_snapshot(&release, &snapshot),
511 Err(ReleasePackageContextError::PackageNotReleased { .. })
512 ));
513
514 snapshot.id = PackageId::new("configured-id");
515 snapshot.version = Version::new(1, 0, 1);
516 assert!(matches!(
517 ReleasePackageContext::from_snapshot(&release, &snapshot),
518 Err(ReleasePackageContextError::CurrentVersionMismatch { .. })
519 ));
520 }
521}