1mod cargo;
9mod changelog;
10mod document;
11mod holder;
12mod npm;
13mod pins;
14mod registry;
15mod utf8;
16mod version_file;
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::num::ParseIntError;
20use std::path::{Path, PathBuf};
21
22use ephemeris_files::{FilesError, PathError, RepositoryPath, RepositoryRoot};
23use ephemeris_token::Token;
24
25pub use self::cargo::CargoRegistry;
26use self::cargo::{CargoAdapter, CargoError, CargoPublisher};
27pub(crate) use self::changelog::Entry as ChangelogEntry;
28use self::holder::{Holder, HolderError, Home};
29pub use self::npm::NpmRegistry;
30use self::npm::{NpmError, NpmPublisher};
31pub(crate) use self::pins::{PinError, read_digest};
32pub use self::registry::RegistryError;
33use self::utf8::NotUtf8;
34use crate::edits::PlannedEdits;
35use crate::error::RequiredFileError;
36use crate::proposal::assets::AssetBuilder;
37use crate::state::{ReleaseTarget, ReleaseUnitState};
38use crate::traits::{PackagePublisher, PackageRegistry};
39use crate::unit::Unit;
40use crate::version::{Bump, Proposed, TagVersion, Version};
41
42#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct LocalChange {
45 pub unit: String,
47 pub from: String,
49 pub to: String,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct LocalPlan {
56 changes: Vec<LocalChange>,
57 files: Vec<PathBuf>,
58 edits: PlannedEdits,
59}
60
61#[derive(Clone, Copy, Debug)]
63pub(crate) struct EditContext<'a> {
64 pub date: &'a str,
66 pub entries: &'a BTreeMap<String, ChangelogEntry>,
68 pub released: &'a BTreeSet<String>,
70}
71
72#[derive(Debug, thiserror::Error)]
74pub(crate) enum ResolveError {
75 #[error(transparent)]
77 Cargo(#[from] CargoError),
78 #[error(transparent)]
80 Npm(#[from] NpmError),
81 #[error(transparent)]
83 Holder(#[from] HolderError),
84 #[error(transparent)]
86 Pin(#[from] PinError),
87 #[error("unknown release unit {0:?}")]
89 UnknownUnit(String),
90 #[error("invalid version {version:?} for {unit:?}")]
92 InvalidVersion {
93 unit: String,
95 version: String,
97 #[source]
99 source: semver::Error,
100 },
101 #[error("version for {unit} is not greater than {current}")]
103 NotIncreasing {
104 unit: String,
106 current: Version,
108 },
109 #[error("first release of {unit} is below {current}")]
111 FirstBelowCurrent {
112 unit: String,
114 current: Version,
116 },
117 #[error("configured release dependency {dependency:?} of {unit:?} is absent")]
119 UnknownDependency {
120 unit: String,
122 dependency: String,
124 },
125 #[error("release unit {unit:?} pins {dependency:?}, which is not among its dependencies")]
127 PinWithoutDependency {
128 unit: String,
130 dependency: String,
132 },
133 #[error("cannot read the revision file")]
135 RevisionRead(#[source] RequiredFileError),
136 #[error("cannot read the changelog")]
138 ChangelogRead(#[source] FilesError),
139 #[error(transparent)]
141 NotUtf8(#[from] NotUtf8),
142 #[error("revision file {} must contain a decimal integer", path.display())]
144 RevisionParse {
145 path: PathBuf,
147 #[source]
149 source: ParseIntError,
150 },
151 #[error("tag {tag:?} of release unit {unit:?} does not name its version unambiguously")]
154 AmbiguousTag {
155 unit: String,
157 tag: String,
159 },
160 #[error(transparent)]
162 Path(#[from] PathError),
163}
164
165#[derive(Clone, Copy, Debug, Default)]
168pub(crate) struct NoRegistry;
169
170#[derive(Debug)]
172pub(crate) struct Adapters<R, N> {
173 cargo: CargoAdapter<R>,
174 npm: NpmPublisher<N>,
175}
176
177#[derive(Debug)]
180pub(crate) struct Placed<'a, U, P> {
181 name: &'a str,
182 place: P,
183 unit: &'a U,
184}
185
186pub(crate) type Configured<'a, P> = Placed<'a, ReleaseUnitState, P>;
188
189pub(crate) type HolderUnit<'a> = Placed<'a, Unit, Holder<'a>>;
191
192impl LocalPlan {
193 pub(crate) fn new(changes: Vec<LocalChange>, edits: PlannedEdits) -> Self {
194 Self {
195 changes,
196 files: edits.changed_paths(),
197 edits,
198 }
199 }
200
201 #[must_use]
203 pub fn changes(&self) -> &[LocalChange] {
204 &self.changes
205 }
206
207 #[must_use]
209 pub fn files(&self) -> &[PathBuf] {
210 &self.files
211 }
212
213 pub fn apply(&self) -> Result<(), crate::Error> {
218 Ok(self.edits.apply()?)
219 }
220}
221
222impl<U, P: Clone> Clone for Placed<'_, U, P> {
223 fn clone(&self) -> Self {
224 Self {
225 name: self.name,
226 place: self.place.clone(),
227 unit: self.unit,
228 }
229 }
230}
231
232impl<U, P: Copy> Copy for Placed<'_, U, P> {}
233
234impl<'a, U, P> Placed<'a, U, P> {
235 const fn new(name: &'a str, place: P, unit: &'a U) -> Self {
236 Self { name, place, unit }
237 }
238}
239
240impl Unit {
241 fn check_increasing(&self, target: &Proposed) -> Result<(), ResolveError> {
244 let order = target.version().precedence_cmp(&self.version);
245 match target {
246 Proposed::Bump(_) if order.is_le() => Err(ResolveError::NotIncreasing {
247 unit: self.name.clone(),
248 current: self.version.clone(),
249 }),
250 Proposed::First(_) if order.is_lt() => Err(ResolveError::FirstBelowCurrent {
251 unit: self.name.clone(),
252 current: self.version.clone(),
253 }),
254 Proposed::Bump(_) | Proposed::First(_) => Ok(()),
255 }
256 }
257
258 pub(crate) fn configured(
261 name: &str,
262 package_name: Option<String>,
263 version: Version,
264 configured: &ReleaseUnitState,
265 ) -> Self {
266 Self {
267 name: name.to_owned(),
268 package_name,
269 version,
270 target: configured.target.clone(),
271 source: configured.source.clone(),
272 changelog: configured.changelog.clone(),
273 tag_prefix: configured.tag_prefix.clone(),
274 dependencies: configured.dependencies.clone(),
275 pins: configured.pins.clone(),
276 revision: None,
277 }
278 }
279}
280
281impl<R, N> Adapters<R, N> {
282 #[must_use]
284 pub(crate) const fn new(cargo_registry: R, npm_registry: Option<N>) -> Self {
285 Self {
286 cargo: CargoAdapter::new(CargoPublisher::new(cargo_registry)),
287 npm: NpmPublisher::new(npm_registry),
288 }
289 }
290}
291
292impl<R, N> Adapters<R, N> {
293 #[must_use]
296 pub(crate) fn authenticated(mut self, cargo_authorization: Token, npm_token: Token) -> Self {
297 self.cargo = self.cargo.authenticated(cargo_authorization);
298 self.npm = self.npm.authenticated(npm_token);
299 self
300 }
301}
302
303impl Adapters<NoRegistry, NoRegistry> {
304 #[must_use]
306 pub(crate) const fn without_registry() -> Self {
307 Self::new(NoRegistry, None)
308 }
309}
310
311impl<R, N> Adapters<R, N> {
312 pub(crate) fn resolve(
314 &self,
315 root: &RepositoryRoot,
316 configured: &BTreeMap<String, ReleaseUnitState>,
317 ) -> Result<Vec<Unit>, ResolveError> {
318 let mut cargo = Vec::new();
319 let mut holders = Vec::new();
320 for (name, unit) in configured {
321 match Home::of(&unit.target) {
322 Home::Cargo(manifest) => cargo.push(Placed::new(name, manifest, unit)),
323 Home::Holder(holder) => holders.push(Placed::new(name, holder, unit)),
324 }
325 }
326 let mut units = self.cargo.resolve(root, &cargo)?;
327 units.extend(holder::resolve(root, &holders)?);
328 units.sort_by(|left, right| left.name.cmp(&right.name));
329 for unit in &mut units {
330 unit.revision = match configured
331 .get(&unit.name)
332 .and_then(|unit| unit.revision_file.as_ref())
333 {
334 Some(revision_file) => Some(Unit::read_revision(root, revision_file)?),
335 None => None,
336 };
337 let tag = unit.tag();
338 if !TagVersion::parse(&tag, &unit.tag_prefix)
339 .is_some_and(|parsed| parsed.names(&unit.version, unit.revision))
340 {
341 return Err(ResolveError::AmbiguousTag {
342 unit: unit.name.clone(),
343 tag,
344 });
345 }
346 if let Some(dependency) = unit
347 .pins
348 .keys()
349 .find(|pinned| !unit.dependencies.contains(pinned))
350 {
351 return Err(ResolveError::PinWithoutDependency {
352 unit: unit.name.clone(),
353 dependency: dependency.clone(),
354 });
355 }
356 for dependency in &unit.dependencies {
357 if !configured.contains_key(dependency) {
358 return Err(ResolveError::UnknownDependency {
359 unit: unit.name.clone(),
360 dependency: dependency.clone(),
361 });
362 }
363 }
364 }
365 Ok(units)
366 }
367
368 pub(crate) fn complete_versions(
371 &self,
372 root: &RepositoryRoot,
373 units: &[Unit],
374 explicit: &BTreeMap<String, Version>,
375 automatic: &BTreeMap<String, Version>,
376 ) -> Result<BTreeMap<String, Version>, ResolveError> {
377 let (cargo, holders) = Self::partition(units);
378 let cargo_names: BTreeSet<_> = cargo.iter().map(|unit| unit.name.as_str()).collect();
379 let holder_names: BTreeSet<_> = holders.iter().map(|holder| holder.name).collect();
380 let mut versions = self.cargo.complete_versions(
381 root,
382 &cargo,
383 &Self::select(explicit, &cargo_names),
384 &Self::select(automatic, &cargo_names),
385 )?;
386 versions.extend(holder::complete_versions(
387 &Self::select(explicit, &holder_names),
388 &Self::select(automatic, &holder_names),
389 ));
390 Ok(versions)
391 }
392
393 pub(crate) fn plan_edits(
398 &self,
399 root: &RepositoryRoot,
400 units: &[Unit],
401 versions: &BTreeMap<String, Proposed>,
402 context: &EditContext<'_>,
403 edits: &mut PlannedEdits,
404 ) -> Result<(), ResolveError> {
405 let EditContext {
406 date,
407 entries,
408 released,
409 } = *context;
410 for unit in units {
411 if let Some(target) = versions.get(&unit.name) {
412 unit.check_increasing(target)?;
413 }
414 }
415 let (cargo, holders) = Self::partition(units);
416 let cargo_names: BTreeSet<_> = cargo.iter().map(|unit| unit.name.as_str()).collect();
417 let holder_names: BTreeSet<_> = holders.iter().map(|holder| holder.name).collect();
418 self.cargo.plan_edits(
419 root,
420 &cargo,
421 &Self::select(versions, &cargo_names),
422 date,
423 entries,
424 edits,
425 )?;
426 holder::plan_edits(
427 root,
428 &holders,
429 &Self::select(versions, &holder_names),
430 date,
431 entries,
432 edits,
433 )?;
434 pins::plan_edits(root, units, versions, released, edits)?;
435 Ok(())
436 }
437
438 pub(crate) fn plan_pin_edits<B: AssetBuilder>(
442 root: &RepositoryRoot,
443 units: &[Unit],
444 released_now: &BTreeMap<String, Proposed>,
445 builder: &B,
446 worktree: &Path,
447 edits: &mut PlannedEdits,
448 ) -> Result<(), ResolveError> {
449 pins::plan_pin_edits(root, units, released_now, builder, worktree, edits)?;
450 Ok(())
451 }
452
453 pub(crate) fn check_consumers(
456 &self,
457 root: &RepositoryRoot,
458 units: &[Unit],
459 versions: &BTreeMap<String, Proposed>,
460 ) -> Result<(), ResolveError> {
461 let (cargo, _) = Self::partition(units);
462 let names: BTreeSet<_> = cargo.iter().map(|unit| unit.name.as_str()).collect();
463 Ok(self
464 .cargo
465 .check_consumers(root, &cargo, &Self::select(versions, &names))?)
466 }
467
468 pub(crate) fn shared_groups(
470 &self,
471 root: &RepositoryRoot,
472 units: &[Unit],
473 ) -> Result<Vec<BTreeSet<String>>, ResolveError> {
474 let (cargo, _) = Self::partition(units);
475 Ok(self.cargo.shared_groups(root, &cargo)?)
476 }
477
478 pub(crate) fn reload(&self) {
480 self.cargo.reload();
481 }
482
483 pub(crate) fn local_plan(
485 &self,
486 root: &RepositoryRoot,
487 units: &[Unit],
488 unit: &Unit,
489 target_version: &Version,
490 date: &str,
491 ) -> Result<LocalPlan, ResolveError> {
492 unit.check_increasing(&Proposed::Bump(target_version.clone()))?;
493 let mut plan = match Home::of(&unit.target) {
494 Home::Holder(holder) => {
495 let unit = Placed::new(&unit.name, holder, unit);
496 holder::local_plan(root, unit, target_version, date)?
497 }
498 Home::Cargo(_) => {
499 let (cargo, _) = Self::partition(units);
500 self.cargo
501 .local_plan(root, &cargo, unit, target_version, date)?
502 }
503 };
504 let versions =
505 BTreeMap::from([(unit.name.clone(), Proposed::Bump(target_version.clone()))]);
506 let released = BTreeSet::from([unit.name.clone()]);
507 pins::plan_edits(root, units, &versions, &released, &mut plan.edits)?;
508 plan.files = plan.edits.changed_paths();
509 Ok(plan)
510 }
511}
512
513impl<R: PackageRegistry, N: PackageRegistry> PackagePublisher for Adapters<R, N> {
514 type Error = ResolveError;
515
516 async fn is_published(&self, unit: &Unit) -> Result<bool, Self::Error> {
517 match unit.target {
518 ReleaseTarget::Cargo {
519 manifest: _,
520 publish: _,
521 } => Ok(self.cargo.is_published(unit).await?),
522 ReleaseTarget::Npm {
523 manifest: _,
524 publish: _,
525 } => Ok(self.npm.is_published(unit).await?),
526 ReleaseTarget::VersionFile { version_file: _ }
527 | ReleaseTarget::Document { path: _, key: _ } => Ok(true),
528 }
529 }
530
531 async fn publish(&self, unit: &Unit, file: &Path) -> Result<(), Self::Error> {
532 match unit.target {
533 ReleaseTarget::Cargo {
534 manifest: _,
535 publish: _,
536 } => Ok(self.cargo.publish(unit, file).await?),
537 ReleaseTarget::Npm {
538 manifest: _,
539 publish: _,
540 } => Ok(self.npm.publish(unit, file).await?),
541 ReleaseTarget::VersionFile { version_file: _ }
542 | ReleaseTarget::Document { path: _, key: _ } => Ok(()),
543 }
544 }
545}
546
547impl Unit {
548 pub(crate) fn required_bump(
551 &self,
552 root: &RepositoryRoot,
553 baseline_tag: &str,
554 ) -> Result<Bump, ResolveError> {
555 match self.target {
556 ReleaseTarget::Cargo {
557 manifest: _,
558 publish: _,
559 } => Ok(cargo::required_bump(root.as_path(), self, baseline_tag)?),
560 ReleaseTarget::Npm {
561 manifest: _,
562 publish: _,
563 }
564 | ReleaseTarget::VersionFile { version_file: _ }
565 | ReleaseTarget::Document { path: _, key: _ } => Ok(Bump::Patch),
566 }
567 }
568
569 pub(crate) fn release_notes(&self, root: &RepositoryRoot) -> Result<String, ResolveError> {
572 let Some(changelog) = &self.changelog else {
573 return Ok(String::new());
574 };
575 let Some(bytes) = root.read(changelog).map_err(ResolveError::ChangelogRead)? else {
576 return Ok(String::new());
577 };
578 let contents = utf8::decode(changelog.as_path(), &bytes)?;
579 Ok(changelog::section(contents, &self.version.to_string()).unwrap_or_default())
580 }
581
582 pub(crate) fn read_revision(
583 root: &RepositoryRoot,
584 revision_file: &RepositoryPath,
585 ) -> Result<u64, ResolveError> {
586 let bytes =
587 RequiredFileError::read(root, revision_file).map_err(ResolveError::RevisionRead)?;
588 let path = revision_file.as_path();
589 utf8::decode(path, &bytes)?
590 .trim()
591 .parse::<u64>()
592 .map_err(|source| ResolveError::RevisionParse {
593 path: path.to_path_buf(),
594 source,
595 })
596 }
597}
598
599impl<R, N> Adapters<R, N> {
600 pub(crate) fn prepare(
605 worktree: &RepositoryRoot,
606 units: &[Unit],
607 ) -> Result<Vec<PathBuf>, ResolveError> {
608 let (cargo, _) = Self::partition(units);
609 Ok(cargo::prepare(worktree, &cargo)?)
610 }
611
612 fn partition(units: &[Unit]) -> (Vec<&Unit>, Vec<HolderUnit<'_>>) {
613 let mut cargo = Vec::new();
614 let mut holders = Vec::new();
615 for unit in units {
616 match Home::of(&unit.target) {
617 Home::Holder(holder) => holders.push(Placed::new(&unit.name, holder, unit)),
618 Home::Cargo(_) => cargo.push(unit),
619 }
620 }
621 (cargo, holders)
622 }
623
624 fn select<V: Clone>(
625 versions: &BTreeMap<String, V>,
626 names: &BTreeSet<&str>,
627 ) -> BTreeMap<String, V> {
628 versions
629 .iter()
630 .filter(|(name, _)| names.contains(name.as_str()))
631 .map(|(name, version)| (name.clone(), version.clone()))
632 .collect()
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use std::fs;
639
640 use super::{Proposed, ResolveError, Unit};
641 use crate::state::ReleaseUnitState;
642
643 fn unit(version: &str) -> Unit {
644 let state: ReleaseUnitState = facet_json::from_str(
645 r#"{"target":{"ecosystem":"version-file","version_file":"VERSION"},"source":["."],"changelog":"CHANGELOG.md","dependencies":[],"pins":{},"tag_prefix":"v","kind":"library","application_bump":"none","revision_file":null}"#,
646 )
647 .unwrap();
648 Unit::configured("app", None, version.parse().unwrap(), &state)
649 }
650
651 #[test]
652 fn a_unit_without_a_changelog_has_empty_release_notes() {
653 let state: ReleaseUnitState = facet_json::from_str(
654 r#"{"target":{"ecosystem":"version-file","version_file":"VERSION"},"source":["."],"changelog":null,"dependencies":[],"pins":{},"tag_prefix":"v","kind":"library","application_bump":"none","revision_file":null}"#,
655 )
656 .unwrap();
657 let unit = Unit::configured("app", None, "1.0.0".parse().unwrap(), &state);
658 let directory = tempfile::tempdir().unwrap();
659 fs::write(
660 directory.path().join("CHANGELOG.md"),
661 "## [Unreleased]\n\n## [1.0.0] - 2026-10-01\n\nnotes\n",
662 )
663 .unwrap();
664 let root = ephemeris_files::RepositoryRoot::new(directory.path()).unwrap();
665 assert_eq!(unit.release_notes(&root).unwrap(), "");
666 }
667
668 #[test]
669 fn a_target_that_does_not_move_forward_is_rejected() {
670 let unit = unit("1.2.3");
671 for target in ["1.2.3", "1.2.2", "1.2.3+build", "1.2.3-rc.1"] {
672 assert!(matches!(
673 unit.check_increasing(&Proposed::Bump(target.parse().unwrap())),
674 Err(ResolveError::NotIncreasing { unit, current })
675 if unit == "app" && current == "1.2.3".parse().unwrap()
676 ));
677 }
678 assert!(
679 unit.check_increasing(&Proposed::Bump("1.2.4".parse().unwrap()))
680 .is_ok()
681 );
682 }
683
684 #[test]
685 fn a_first_release_may_stay_at_the_current_version() {
686 let unit = unit("1.2.3");
687 for target in ["1.2.3", "1.2.3+build", "1.2.4"] {
688 assert!(
689 unit.check_increasing(&Proposed::First(target.parse().unwrap()))
690 .is_ok()
691 );
692 }
693 assert!(matches!(
694 unit.check_increasing(&Proposed::First("1.2.2".parse().unwrap())),
695 Err(ResolveError::FirstBelowCurrent { unit, current })
696 if unit == "app" && current == "1.2.3".parse().unwrap()
697 ));
698 }
699}