1mod 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#[derive(Debug, thiserror::Error)]
35pub(crate) enum CargoError {
36 #[error(transparent)]
38 Workspace(#[from] WorkspaceError),
39 #[error(transparent)]
41 Semver(#[from] SemverError),
42 #[error(transparent)]
44 Package(#[from] PackageError),
45 #[error(transparent)]
47 Changelog(#[from] ReadError),
48 #[error(transparent)]
50 MissingUnreleased(#[from] MissingUnreleased),
51 #[error(transparent)]
53 Path(#[from] PathError),
54 #[error(transparent)]
56 Edit(#[from] EditError),
57 #[error("configured release unit {0:?} has no Cargo manifest")]
59 MissingManifest(String),
60 #[error("unit {unit} publishes but package {package} sets publish = false in its manifest")]
62 PrivatePackage {
63 unit: String,
65 package: String,
67 },
68 #[error("Cargo release unit {0:?} has no package mapping")]
70 MissingPackage(String),
71 #[error(transparent)]
73 Lockfile(#[from] FilesError),
74}
75
76#[derive(Debug)]
78pub(crate) struct CargoAdapter<R> {
79 publisher: CargoPublisher<R>,
80 workspace: RefCell<Option<Rc<Workspace>>>,
81}
82
83impl<R> CargoAdapter<R> {
84 #[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 #[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 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 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 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 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
304fn 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}