Skip to main content

ephemeris_release/
adapters.rs

1//! Ecosystem adapters behind one batch-oriented interface.
2//!
3//! Each adapter owns discovery, version grouping, edit planning,
4//! compatibility checks, and publication for its release targets.
5//! [`Adapters`] partitions units by target kind and is the only place that
6//! branches on it.
7
8mod 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/// One unit changed by a local release plan.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct LocalChange {
45    /// Configured release unit ID.
46    pub unit: String,
47    /// Current semantic version.
48    pub from: String,
49    /// Proposed semantic version.
50    pub to: String,
51}
52
53/// A validated local plan for any configured release target.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct LocalPlan {
56    changes: Vec<LocalChange>,
57    files: Vec<PathBuf>,
58    edits: PlannedEdits,
59}
60
61/// What edit planning needs beyond the units and their versions.
62#[derive(Clone, Copy, Debug)]
63pub(crate) struct EditContext<'a> {
64    /// The release date written to changelogs.
65    pub date: &'a str,
66    /// The changelog entries by unit.
67    pub entries: &'a BTreeMap<String, ChangelogEntry>,
68    /// The units that get a version anywhere in this proposal.
69    pub released: &'a BTreeSet<String>,
70}
71
72/// A unit cannot be resolved, versioned, or planned.
73#[derive(Debug, thiserror::Error)]
74pub(crate) enum ResolveError {
75    /// The Cargo adapter failed.
76    #[error(transparent)]
77    Cargo(#[from] CargoError),
78    /// The npm adapter failed.
79    #[error(transparent)]
80    Npm(#[from] NpmError),
81    /// A unit's version holder failed.
82    #[error(transparent)]
83    Holder(#[from] HolderError),
84    /// A dependency pin failed.
85    #[error(transparent)]
86    Pin(#[from] PinError),
87    /// A request named a unit that is not configured.
88    #[error("unknown release unit {0:?}")]
89    UnknownUnit(String),
90    /// A requested version is not strict semver.
91    #[error("invalid version {version:?} for {unit:?}")]
92    InvalidVersion {
93        /// The unit.
94        unit: String,
95        /// The rejected version.
96        version: String,
97        /// The parse failure.
98        #[source]
99        source: semver::Error,
100    },
101    /// A target version does not move its unit forward.
102    #[error("version for {unit} is not greater than {current}")]
103    NotIncreasing {
104        /// The unit.
105        unit: String,
106        /// Its version today.
107        current: Version,
108    },
109    /// A first release is below the version its unit has today.
110    #[error("first release of {unit} is below {current}")]
111    FirstBelowCurrent {
112        /// The unit.
113        unit: String,
114        /// Its version today.
115        current: Version,
116    },
117    /// A unit depends on a unit that is not configured.
118    #[error("configured release dependency {dependency:?} of {unit:?} is absent")]
119    UnknownDependency {
120        /// The depending unit.
121        unit: String,
122        /// The missing dependency.
123        dependency: String,
124    },
125    /// A unit pins a unit that is not among its dependencies.
126    #[error("release unit {unit:?} pins {dependency:?}, which is not among its dependencies")]
127    PinWithoutDependency {
128        /// The pinning unit.
129        unit: String,
130        /// The pinned unit.
131        dependency: String,
132    },
133    /// A configured revision file cannot be read.
134    #[error("cannot read the revision file")]
135    RevisionRead(#[source] RequiredFileError),
136    /// A unit changelog cannot be read.
137    #[error("cannot read the changelog")]
138    ChangelogRead(#[source] FilesError),
139    /// A file is not UTF-8 text.
140    #[error(transparent)]
141    NotUtf8(#[from] NotUtf8),
142    /// A configured revision file does not hold a decimal integer.
143    #[error("revision file {} must contain a decimal integer", path.display())]
144    RevisionParse {
145        /// The revision file.
146        path: PathBuf,
147        /// Why parsing failed.
148        #[source]
149        source: ParseIntError,
150    },
151    /// A unit's tag would not read back as its version and revision, as with
152    /// a prerelease followed by a revision suffix.
153    #[error("tag {tag:?} of release unit {unit:?} does not name its version unambiguously")]
154    AmbiguousTag {
155        /// The unit.
156        unit: String,
157        /// The tag the unit would be published under.
158        tag: String,
159    },
160    /// A configured path is unsafe or outside the repository.
161    #[error(transparent)]
162    Path(#[from] PathError),
163}
164
165/// Registry stand-in that is no [`PackageRegistry`], so adapters built with
166/// it cannot publish.
167#[derive(Clone, Copy, Debug, Default)]
168pub(crate) struct NoRegistry;
169
170/// Every supported ecosystem adapter, dispatching units by target kind.
171#[derive(Debug)]
172pub(crate) struct Adapters<R, N> {
173    cargo: CargoAdapter<R>,
174    npm: NpmPublisher<N>,
175}
176
177/// A unit with the place its ecosystem reads the version from: a Cargo
178/// manifest path or a version holder.
179#[derive(Debug)]
180pub(crate) struct Placed<'a, U, P> {
181    name: &'a str,
182    place: P,
183    unit: &'a U,
184}
185
186/// A configured unit that is not resolved yet.
187pub(crate) type Configured<'a, P> = Placed<'a, ReleaseUnitState, P>;
188
189/// A resolved unit that keeps its version in a file or document.
190pub(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    /// Units whose versions this plan changes.
202    #[must_use]
203    pub fn changes(&self) -> &[LocalChange] {
204        &self.changes
205    }
206
207    /// Repository-relative files this plan edits.
208    #[must_use]
209    pub fn files(&self) -> &[PathBuf] {
210        &self.files
211    }
212
213    /// Apply the validated plan.
214    ///
215    /// # Errors
216    /// Returns an error when files are stale or cannot be safely written.
217    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    /// Reject a `target` that does not move this unit forward. A first
242    /// release may stay at the current version.
243    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    /// A resolved unit for the configured `name`, at the version its target
259    /// holds today.
260    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    /// Bind the Cargo registry and the npm registry, when one is configured.
283    #[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    /// Publish Cargo crates with `cargo_authorization` as the whole
294    /// `Authorization` value and npm packages with `npm_token`.
295    #[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    /// Adapters for discovery and planning, which never publish.
305    #[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    /// Units of every ecosystem from the configured release units.
313    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    /// Fan explicit and automatic version proposals out to whatever grouping
369    /// the ecosystem has. Explicit targets win over automatic ones.
370    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    /// Plan the edits for the proposed units, contributing to the shared
394    /// transaction. The requirement changes need no consumer check here, see
395    /// [`Adapters::check_consumers`]. `entries` holds the changelog entries by
396    /// unit.
397    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    /// Write the new version and the asset digests of the released
439    /// dependencies into the units that pin them. The assets are built from
440    /// `worktree`, so the edits of the dependencies must be applied first.
441    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    /// Reject proposals that change a dependency entry for consumers without
454    /// a proposal.
455    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    /// Groups of units that cannot be released apart.
469    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    /// Read workspace files again after they changed on disk.
479    pub(crate) fn reload(&self) {
480        self.cargo.reload();
481    }
482
483    /// Plan a local release of `unit`; `units` are all resolved units.
484    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    /// The smallest bump the unit's public API changes since `baseline_tag`
549    /// require.
550    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    /// The changelog section of the unit's version, empty when the unit has no
570    /// changelog or the file or section does not exist.
571    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    /// Refresh generated files in a proposal worktree after its edits are
601    /// applied, returning repository paths that need staging. Callers must
602    /// run this after writing proposed manifest changes, so a generated
603    /// file such as a lockfile reflects the bumped versions.
604    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}