Skip to main content

ephemeris_release/adapters/
cargo.rs

1//! Cargo workspace discovery, version groups, and manifest/dependency
2//! planning.
3
4mod package;
5mod planning;
6mod publisher;
7mod registry;
8mod semver_checks;
9mod version_groups;
10mod workspace;
11
12use std::cell::RefCell;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15use std::rc::Rc;
16
17use ephemeris_files::{FilesError, PathError, RepositoryPath, RepositoryRoot};
18use ephemeris_token::Token;
19
20pub(crate) use self::publisher::{CargoPublisher, PackageError};
21pub use self::registry::CargoRegistry;
22pub(crate) use self::semver_checks::SemverError;
23use self::workspace::Workspace;
24pub(crate) use self::workspace::WorkspaceError;
25use super::changelog::{Entry, MissingUnreleased, ReadError};
26use super::{Configured, LocalChange, LocalPlan};
27use crate::PackageRegistry;
28use crate::edits::{EditError, PlannedEdits};
29use crate::state::ReleaseTarget;
30use crate::unit::Unit;
31use crate::version::{Bump, Proposed, Version};
32
33/// A Cargo adapter failure.
34#[derive(Debug, thiserror::Error)]
35pub(crate) enum CargoError {
36    /// Workspace discovery or planning failed.
37    #[error(transparent)]
38    Workspace(#[from] WorkspaceError),
39    /// The API compatibility check could not run.
40    #[error(transparent)]
41    Semver(#[from] SemverError),
42    /// Checking or publishing a package failed.
43    #[error(transparent)]
44    Package(#[from] PackageError),
45    /// A unit changelog cannot be read.
46    #[error(transparent)]
47    Changelog(#[from] ReadError),
48    /// A changelog lacks the `## [Unreleased]` heading.
49    #[error(transparent)]
50    MissingUnreleased(#[from] MissingUnreleased),
51    /// A planned path is unsafe or lies outside the repository root.
52    #[error(transparent)]
53    Path(#[from] PathError),
54    /// The planned edits conflict or are stale.
55    #[error(transparent)]
56    Edit(#[from] EditError),
57    /// A unit's manifest is not a workspace member.
58    #[error("configured release unit {0:?} has no Cargo manifest")]
59    MissingManifest(String),
60    /// A unit publishes a package whose manifest forbids publishing.
61    #[error("unit {unit} publishes but package {package} sets publish = false in its manifest")]
62    PrivatePackage {
63        /// The release unit.
64        unit: String,
65        /// The Cargo package.
66        package: String,
67    },
68    /// A resolved unit lacks its Cargo package name.
69    #[error("Cargo release unit {0:?} has no package mapping")]
70    MissingPackage(String),
71    /// The proposal worktree lockfile cannot be read.
72    #[error(transparent)]
73    Lockfile(#[from] FilesError),
74}
75
76/// Cargo release behaviour over a lazily loaded workspace.
77#[derive(Debug)]
78pub(crate) struct CargoAdapter<R> {
79    publisher: CargoPublisher<R>,
80    workspace: RefCell<Option<Rc<Workspace>>>,
81}
82
83impl<R> CargoAdapter<R> {
84    /// Wrap a publisher; the workspace is loaded on first use.
85    #[must_use]
86    pub(crate) const fn new(publisher: CargoPublisher<R>) -> Self {
87        Self {
88            publisher,
89            workspace: RefCell::new(None),
90        }
91    }
92
93    /// Authenticate uploads with `authorization`.
94    #[must_use]
95    pub(crate) fn authenticated(mut self, authorization: Token) -> Self {
96        self.publisher = self.publisher.authenticated(authorization);
97        self
98    }
99
100    fn workspace(&self, root: &RepositoryRoot) -> Result<Rc<Workspace>, CargoError> {
101        if let Some(workspace) = self.workspace.borrow().as_ref()
102            && workspace.root() == root
103        {
104            return Ok(Rc::clone(workspace));
105        }
106        let workspace = Rc::new(Workspace::load(root)?);
107        *self.workspace.borrow_mut() = Some(Rc::clone(&workspace));
108        Ok(workspace)
109    }
110
111    /// The workspace with its packages named by the IDs of `units`. A
112    /// selection must cover whole version groups.
113    fn release_workspace(
114        &self,
115        root: &RepositoryRoot,
116        units: &[&Unit],
117    ) -> Result<Workspace, CargoError> {
118        let names = units
119            .iter()
120            .map(|unit| {
121                unit.package_name
122                    .as_ref()
123                    .map(|package| (package.clone(), unit.name.clone()))
124                    .ok_or_else(|| CargoError::MissingPackage(unit.name.clone()))
125            })
126            .collect::<Result<BTreeMap<_, _>, _>>()?;
127        let workspace = self.workspace(root)?.renamed(&names);
128        workspace
129            .version_groups()
130            .validate_selection(&units.iter().map(|unit| unit.name.clone()).collect())?;
131        Ok(workspace)
132    }
133
134    pub(super) fn resolve(
135        &self,
136        root: &RepositoryRoot,
137        configured: &[Configured<'_, &RepositoryPath>],
138    ) -> Result<Vec<Unit>, CargoError> {
139        if configured.is_empty() {
140            return Ok(Vec::new());
141        }
142        let workspace = self.workspace(root)?;
143        let by_manifest: BTreeMap<_, _> = workspace
144            .units()
145            .into_iter()
146            .map(|member| (member.path.join("Cargo.toml"), member))
147            .collect();
148        configured
149            .iter()
150            .map(|configured| {
151                let member = by_manifest
152                    .get(&workspace.root().as_path().join(configured.place.as_path()))
153                    .ok_or_else(|| CargoError::MissingManifest(configured.name.to_owned()))?;
154                if !member.publishable
155                    && matches!(
156                        configured.unit.target,
157                        ReleaseTarget::Cargo {
158                            publish: true,
159                            manifest: _
160                        }
161                    )
162                {
163                    return Err(CargoError::PrivatePackage {
164                        unit: configured.name.to_owned(),
165                        package: member.name.clone(),
166                    });
167                }
168                let version = Version::parse(&member.version).map_err(|parse_error| {
169                    WorkspaceError::InvalidVersion {
170                        version: member.version.clone(),
171                        source: parse_error,
172                    }
173                })?;
174                Ok(Unit::configured(
175                    configured.name,
176                    Some(member.name.clone()),
177                    version,
178                    configured.unit,
179                ))
180            })
181            .collect()
182    }
183
184    pub(super) fn complete_versions(
185        &self,
186        root: &RepositoryRoot,
187        units: &[&Unit],
188        explicit: &BTreeMap<String, Version>,
189        automatic: &BTreeMap<String, Version>,
190    ) -> Result<BTreeMap<String, Version>, CargoError> {
191        if units.is_empty() {
192            return Ok(BTreeMap::new());
193        }
194        let groups = self
195            .release_workspace(root, units)?
196            .version_groups()
197            .clone();
198        let mut versions = groups.complete_automatic(automatic);
199        versions.extend(groups.complete_explicit(explicit)?);
200        Ok(versions)
201    }
202
203    pub(super) fn plan_edits(
204        &self,
205        root: &RepositoryRoot,
206        units: &[&Unit],
207        versions: &BTreeMap<String, Proposed>,
208        date: &str,
209        entries: &BTreeMap<String, Entry>,
210        edits: &mut PlannedEdits,
211    ) -> Result<(), CargoError> {
212        if versions.is_empty() {
213            return Ok(());
214        }
215        let versions = versions
216            .iter()
217            .map(|(name, proposed)| (name.clone(), proposed.version().clone()))
218            .collect();
219        let plan = self
220            .release_workspace(root, units)?
221            .plan_unchecked(&versions, &changelogs(units))?;
222        plan.contribute_edits(edits, date, entries)
223    }
224
225    /// Reject requirement changes that reach consumers without a proposal.
226    pub(super) fn check_consumers(
227        &self,
228        root: &RepositoryRoot,
229        units: &[&Unit],
230        versions: &BTreeMap<String, Proposed>,
231    ) -> Result<(), CargoError> {
232        if versions.is_empty() {
233            return Ok(());
234        }
235        let versions = versions
236            .iter()
237            .map(|(name, proposed)| (name.clone(), proposed.version().clone()))
238            .collect();
239        Ok(self
240            .release_workspace(root, units)?
241            .check_consumers(&versions)?)
242    }
243
244    /// The units that share one version and so release together.
245    pub(super) fn shared_groups(
246        &self,
247        root: &RepositoryRoot,
248        units: &[&Unit],
249    ) -> Result<Vec<BTreeSet<String>>, CargoError> {
250        if units.is_empty() {
251            return Ok(Vec::new());
252        }
253        let workspace = self.release_workspace(root, units)?;
254        Ok(workspace
255            .version_groups()
256            .iter()
257            .filter(|(_, members)| members.len() > 1)
258            .map(|(_, members)| members.iter().cloned().collect())
259            .collect())
260    }
261
262    /// Read the manifests again on the next use.
263    pub(super) fn reload(&self) {
264        *self.workspace.borrow_mut() = None;
265    }
266
267    pub(super) fn local_plan(
268        &self,
269        root: &RepositoryRoot,
270        units: &[&Unit],
271        unit: &Unit,
272        target_version: &Version,
273        date: &str,
274    ) -> Result<LocalPlan, CargoError> {
275        let plan = self.release_workspace(root, units)?.plan(
276            &BTreeMap::from([(unit.name.clone(), target_version.clone())]),
277            &changelogs(units),
278        )?;
279        let changes = plan
280            .changes
281            .iter()
282            .map(|change| LocalChange {
283                unit: change.unit.clone(),
284                from: change.from.clone(),
285                to: change.to.clone(),
286            })
287            .collect();
288        let mut edits = PlannedEdits::new(root);
289        plan.contribute_edits(&mut edits, date, &BTreeMap::new())?;
290        Ok(LocalPlan::new(changes, edits))
291    }
292}
293
294impl<R: PackageRegistry> CargoAdapter<R> {
295    pub(super) async fn is_published(&self, unit: &Unit) -> Result<bool, CargoError> {
296        Ok(self.publisher.is_published(unit).await?)
297    }
298
299    pub(super) async fn publish(&self, unit: &Unit, file: &Path) -> Result<(), CargoError> {
300        Ok(self.publisher.publish(unit, file).await?)
301    }
302}
303
304/// The configured changelog of each unit, by unit ID.
305fn changelogs(units: &[&Unit]) -> BTreeMap<String, Option<RepositoryPath>> {
306    units
307        .iter()
308        .map(|unit| (unit.name.clone(), unit.changelog.clone()))
309        .collect()
310}
311
312pub(super) fn prepare(
313    worktree: &RepositoryRoot,
314    units: &[&Unit],
315) -> Result<Vec<PathBuf>, CargoError> {
316    if units.is_empty() {
317        return Ok(Vec::new());
318    }
319    let lockfile = RepositoryPath::try_from("Cargo.lock").map_err(FilesError::from)?;
320    let before = worktree.read(&lockfile)?;
321    workspace::run_metadata(worktree.as_path(), ["--format-version=1"])?;
322    let after = worktree.read(&lockfile)?;
323    Ok((before != after)
324        .then(|| lockfile.as_path().to_path_buf())
325        .into_iter()
326        .collect())
327}
328
329pub(super) fn required_bump(
330    root: &Path,
331    unit: &Unit,
332    baseline_tag: &str,
333) -> Result<Bump, CargoError> {
334    let package = unit.package_name.as_deref().unwrap_or(&unit.name);
335    Ok(semver_checks::required_bump(root, package, baseline_tag)?)
336}
337
338#[cfg(test)]
339mod tests {
340    use std::fs;
341    use std::path::Path;
342
343    use ephemeris_files::{RepositoryPath, RepositoryRoot};
344
345    use super::{CargoAdapter, CargoError, CargoPublisher};
346    use crate::adapters::{NoRegistry, Placed};
347    use crate::state::ReleaseUnitState;
348
349    fn workspace(manifest_publish: &str) -> tempfile::TempDir {
350        let root = tempfile::tempdir().unwrap();
351        fs::write(
352            root.path().join("Cargo.toml"),
353            "[workspace]\nmembers=[\"a\"]\n",
354        )
355        .unwrap();
356        fs::create_dir_all(root.path().join("a/src")).unwrap();
357        fs::write(root.path().join("a/src/lib.rs"), "").unwrap();
358        fs::write(
359            root.path().join("a/Cargo.toml"),
360            format!("[package]\nname=\"a\"\nversion=\"1.0.0\"\n{manifest_publish}\n"),
361        )
362        .unwrap();
363        root
364    }
365
366    fn unit(publish: bool) -> ReleaseUnitState {
367        facet_json::from_str(&format!(
368            r#"{{"target":{{"ecosystem":"cargo","manifest":"a/Cargo.toml","publish":{publish}}},"source":["a"],"changelog":"a/CHANGELOG.md","dependencies":[],"pins":{{}},"tag_prefix":"a-v","kind":"library","application_bump":"none","revision_file":null}}"#
369        ))
370        .unwrap()
371    }
372
373    fn manifest() -> RepositoryPath {
374        "a/Cargo.toml".parse().unwrap()
375    }
376
377    fn resolve(
378        root: &Path,
379        manifest: &RepositoryPath,
380        state: &ReleaseUnitState,
381    ) -> Result<(), CargoError> {
382        CargoAdapter::new(CargoPublisher::new(NoRegistry))
383            .resolve(
384                &RepositoryRoot::new(root).map_err(CargoError::from)?,
385                &[Placed::new("a", manifest, state)],
386            )
387            .map(|_| ())
388    }
389
390    #[test]
391    fn publishing_unit_over_private_package_is_rejected() {
392        let root = workspace("publish = false");
393        assert!(matches!(
394            resolve(root.path(), &manifest(), &unit(true)),
395            Err(CargoError::PrivatePackage { unit, package }) if unit == "a" && package == "a"
396        ));
397        let empty = workspace("publish = []");
398        assert!(matches!(
399            resolve(empty.path(), &manifest(), &unit(true)),
400            Err(CargoError::PrivatePackage {
401                unit: _,
402                package: _
403            })
404        ));
405    }
406
407    #[test]
408    fn private_unit_over_private_package_resolves() {
409        let root = workspace("publish = false");
410        assert!(resolve(root.path(), &manifest(), &unit(false)).is_ok());
411    }
412
413    #[test]
414    fn publishing_unit_over_restricted_registry_package_resolves() {
415        let root = workspace("publish = [\"internal\"]");
416        assert!(resolve(root.path(), &manifest(), &unit(true)).is_ok());
417    }
418}