1mod edit;
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::ffi::OsStr;
7use std::path::{Path, PathBuf};
8use std::process::{Command, ExitStatus};
9use std::{fmt, fs, io};
10
11use cargo_platform::Platform;
12use ephemeris_files::RepositoryRoot;
13use facet::Facet;
14use toml_edit::{DocumentMut, Item, Value};
15
16use super::version_groups::VersionGroups;
17
18#[derive(Debug, thiserror::Error)]
20pub(crate) enum WorkspaceError {
21 #[error("cannot read {path}")]
23 Read {
24 path: PathBuf,
26 source: io::Error,
28 },
29 #[error("invalid Cargo manifest {path}")]
31 Manifest {
32 path: PathBuf,
34 source: toml_edit::TomlError,
36 },
37 #[error("workspace has no members")]
39 NoMembers,
40 #[error("cannot run cargo metadata")]
42 MetadataSpawn(#[source] io::Error),
43 #[error("cargo metadata exited with {status}: {stderr}")]
45 MetadataFailed {
46 status: ExitStatus,
48 stderr: String,
50 },
51 #[error("cannot decode cargo metadata")]
53 MetadataDecode(#[source] Box<facet_json::DeserializeError>),
54 #[error("duplicate release unit `{0}`")]
56 Duplicate(String),
57 #[error("unknown release unit `{0}`")]
59 UnknownUnit(String),
60 #[error("manifest {} has no parent directory", .0.display())]
62 NoManifestDirectory(PathBuf),
63 #[error("manifest {} was not loaded", .0.display())]
65 ManifestNotLoaded(PathBuf),
66 #[error("manifest of release unit `{0}` has no package table")]
68 NoPackageTable(String),
69 #[error("release unit `{0}` has no supported package version source")]
71 MissingVersionSource(String),
72 #[error("invalid version `{version}`")]
74 InvalidVersion {
75 version: String,
77 #[source]
79 source: semver::Error,
80 },
81 #[error("shared version source {version_source} has unconfigured members: {missing:?}")]
83 IncompleteVersionGroup {
84 version_source: VersionSource,
86 missing: Vec<String>,
88 },
89 #[error("shared version source {version_source} has conflicting targets for {members:?}")]
91 ConflictingVersionGroup {
92 version_source: VersionSource,
94 members: Vec<String>,
96 },
97 #[error("cannot update {dependency}: proposed release omits dependent packages {missing:?}")]
100 UnselectedDependencyConsumers {
101 dependency: String,
103 missing: Vec<String>,
105 },
106 #[error("invalid requirement {requirement:?} on {dependency} in {consumer}")]
108 InvalidRequirement {
109 consumer: String,
111 dependency: String,
113 requirement: String,
115 #[source]
117 source: semver::Error,
118 },
119 #[error(
121 "cannot safely update {dependency} for {consumer}: requirement {requirement:?} does not \
122 accept {version}"
123 )]
124 UnsupportedDependencyRequirement {
125 consumer: String,
127 dependency: String,
129 requirement: String,
131 version: String,
133 },
134 #[error("cannot locate local dependency source for {dependency} used by {consumer}")]
136 MissingDependencySource {
137 consumer: String,
139 dependency: String,
141 },
142 #[error("release plan is stale for {path}; refresh before applying")]
144 Stale {
145 path: PathBuf,
147 },
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub(crate) struct Member {
152 pub(crate) name: String,
153 pub(crate) path: PathBuf,
154 pub(crate) version: String,
155 pub(crate) publishable: bool,
156 pub(crate) dependencies: Vec<String>,
157}
158
159#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
161pub(crate) enum VersionSource {
162 Member(PathBuf),
164 Workspace(PathBuf),
166}
167
168#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
169struct DependencySlot {
170 manifest: PathBuf,
171 table_path: Vec<String>,
172 alias: String,
173}
174
175#[derive(Clone, Debug)]
176struct DependencyUse {
177 consumer: String,
178 dependency: String,
179 slot: DependencySlot,
180 requirement: Option<String>,
181 kind: Option<String>,
182}
183
184struct Declaration<'a> {
187 root_manifest: &'a Path,
188 root: &'a DocumentMut,
189 manifest: &'a Path,
190 consumer: &'a str,
191 dependency: &'a str,
192 alias: &'a str,
193 path: &'a Path,
194 kind: Option<&'a str>,
195}
196
197#[derive(Clone, Debug)]
198struct Package {
199 unit: Member,
200 source: VersionSource,
201 dependencies: Vec<DependencyUse>,
202}
203
204struct WorkspaceSources {
205 root_source: String,
206 members: Vec<MetadataPackage>,
207 documents: BTreeMap<PathBuf, DocumentMut>,
208 snapshots: BTreeMap<PathBuf, String>,
209}
210
211#[derive(Debug)]
212pub(crate) struct Workspace {
213 root: RepositoryRoot,
214 packages: Vec<Package>,
215 version_groups: VersionGroups,
216 snapshots: BTreeMap<PathBuf, String>,
217}
218
219#[derive(Clone, Debug, Eq, Facet, PartialEq)]
220struct Metadata {
221 packages: Vec<MetadataPackage>,
222 workspace_members: Vec<String>,
223}
224
225#[derive(Clone, Debug, Eq, Facet, PartialEq)]
226struct MetadataPackage {
227 id: String,
228 name: String,
229 version: String,
230 manifest_path: String,
231 publish: Option<Vec<String>>,
232 dependencies: Vec<MetadataDependency>,
233}
234
235#[derive(Clone, Debug, Eq, Facet, PartialEq)]
236struct MetadataDependency {
237 name: String,
238 req: String,
239 path: Option<String>,
240 rename: Option<String>,
241 kind: Option<String>,
242 target: Option<String>,
243}
244
245impl DependencyUse {
246 fn kind_for_ordering(&self) -> bool {
247 self.kind.as_deref() != Some("dev")
248 }
249}
250
251impl Declaration<'_> {
252 fn missing_source(&self) -> WorkspaceError {
253 WorkspaceError::MissingDependencySource {
254 consumer: self.consumer.to_owned(),
255 dependency: self.dependency.to_owned(),
256 }
257 }
258
259 fn dependency_use(
262 &self,
263 table_path: Vec<String>,
264 item: &Item,
265 ) -> Result<DependencyUse, WorkspaceError> {
266 let inherited = item
267 .as_table_like()
268 .and_then(|table| table.get("workspace"))
269 .and_then(Item::as_bool)
270 == Some(true);
271 let (source_manifest, source_table_path, effective) = if inherited {
272 (
273 self.root_manifest,
274 vec!["workspace".to_owned(), "dependencies".to_owned()],
275 inherited_dependency(self.root, self.alias).ok_or_else(|| self.missing_source())?,
276 )
277 } else {
278 (self.manifest, table_path, item)
279 };
280 let effective_table = effective.as_table_like();
281 let effective_name = effective_table
282 .and_then(|table| table.get("package"))
283 .and_then(Item::as_str)
284 .unwrap_or(self.alias);
285 let effective_path = effective_table
286 .and_then(|table| table.get("path"))
287 .and_then(Item::as_str)
288 .map(|relative| {
289 let path = source_manifest
290 .parent()
291 .unwrap_or_else(|| Path::new("."))
292 .join(relative);
293 fs::canonicalize(&path).map_err(|source| WorkspaceError::Read { path, source })
294 })
295 .transpose()?;
296 if effective_name != self.dependency || effective_path.as_deref() != Some(self.path) {
297 return Err(WorkspaceError::Stale {
298 path: self.manifest.to_path_buf(),
299 });
300 }
301 Ok(DependencyUse {
302 consumer: self.consumer.to_owned(),
303 dependency: self.dependency.to_owned(),
304 slot: DependencySlot {
305 manifest: source_manifest.to_path_buf(),
306 table_path: source_table_path,
307 alias: self.alias.to_owned(),
308 },
309 requirement: effective_table
310 .and_then(|table| table.get("version"))
311 .and_then(Item::as_str)
312 .map(str::to_owned),
313 kind: self.kind.map(str::to_owned),
314 })
315 }
316}
317
318impl Workspace {
319 pub(crate) fn load(root: &RepositoryRoot) -> Result<Self, WorkspaceError> {
320 let root_manifest = root.as_path().join("Cargo.toml");
321 let sources = snapshot_workspace(&root_manifest, root.as_path())?;
322 let root_document = parse_manifest(&root_manifest, &sources.root_source)?;
323 let mut packages = assemble_packages(&root_manifest, &root_document, &sources)?;
324 packages.sort_by(|left, right| left.unit.name.cmp(&right.unit.name));
325 let version_groups = VersionGroups::new(
326 packages
327 .iter()
328 .map(|package| (package.source.clone(), package.unit.name.clone())),
329 );
330 Ok(Self {
331 root: root.clone(),
332 packages,
333 version_groups,
334 snapshots: sources.snapshots,
335 })
336 }
337
338 pub(crate) fn units(&self) -> Vec<Member> {
339 self.packages
340 .iter()
341 .map(|package| package.unit.clone())
342 .collect()
343 }
344
345 pub(crate) fn renamed(&self, names: &BTreeMap<String, String>) -> Self {
348 let rename = |name: &String| names.get(name).unwrap_or(name).clone();
349 let packages: Vec<_> = self
350 .packages
351 .iter()
352 .map(|package| Package {
353 unit: Member {
354 name: rename(&package.unit.name),
355 dependencies: package.unit.dependencies.iter().map(rename).collect(),
356 ..package.unit.clone()
357 },
358 source: package.source.clone(),
359 dependencies: package
360 .dependencies
361 .iter()
362 .map(|usage| DependencyUse {
363 consumer: rename(&usage.consumer),
364 dependency: rename(&usage.dependency),
365 ..usage.clone()
366 })
367 .collect(),
368 })
369 .collect();
370 let version_groups = VersionGroups::new(
371 packages
372 .iter()
373 .map(|package| (package.source.clone(), package.unit.name.clone())),
374 );
375 Self {
376 root: self.root.clone(),
377 packages,
378 version_groups,
379 snapshots: self.snapshots.clone(),
380 }
381 }
382
383 pub(crate) const fn version_groups(&self) -> &VersionGroups {
384 &self.version_groups
385 }
386
387 pub(crate) const fn snapshots(&self) -> &BTreeMap<PathBuf, String> {
388 &self.snapshots
389 }
390
391 pub(crate) const fn root(&self) -> &RepositoryRoot {
392 &self.root
393 }
394}
395
396impl fmt::Display for VersionSource {
397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398 match self {
399 Self::Member(path) => write!(f, "{} [package].version", path.display()),
400 Self::Workspace(path) => write!(f, "{} [workspace.package].version", path.display()),
401 }
402 }
403}
404
405fn snapshot_workspace(
406 root_manifest: &Path,
407 root: &Path,
408) -> Result<WorkspaceSources, WorkspaceError> {
409 let root_source = read(root_manifest)?;
410 let metadata = read_metadata(root_manifest, root)?;
411 let members = metadata_members(&metadata);
412 if members.is_empty() {
413 return Err(WorkspaceError::NoMembers);
414 }
415 let mut snapshots = BTreeMap::from([(root_manifest.to_path_buf(), root_source.clone())]);
416 let mut documents = BTreeMap::new();
417 for package in &members {
418 let manifest = PathBuf::from(&package.manifest_path);
419 let contents = if let Some(contents) = snapshots.get(&manifest) {
420 contents.clone()
421 } else {
422 let contents = read(&manifest)?;
423 snapshots.insert(manifest.clone(), contents.clone());
424 contents
425 };
426 documents.insert(manifest.clone(), parse_manifest(&manifest, &contents)?);
427 }
428 let stable_metadata = read_metadata(root_manifest, root)?;
429 for (path, snapshot) in &snapshots {
430 if read(path)? != *snapshot {
431 return Err(WorkspaceError::Stale { path: path.clone() });
432 }
433 }
434 if let Some(path) = metadata_difference(&metadata, &stable_metadata, root_manifest) {
435 return Err(WorkspaceError::Stale { path });
436 }
437 Ok(WorkspaceSources {
438 root_source,
439 members,
440 documents,
441 snapshots,
442 })
443}
444
445fn assemble_packages(
446 root_manifest: &Path,
447 root_document: &DocumentMut,
448 sources: &WorkspaceSources,
449) -> Result<Vec<Package>, WorkspaceError> {
450 let member_paths: BTreeMap<_, _> = sources
451 .members
452 .iter()
453 .map(|package| {
454 let manifest = PathBuf::from(&package.manifest_path);
455 let directory = manifest
456 .parent()
457 .ok_or_else(|| WorkspaceError::NoManifestDirectory(manifest.clone()))?;
458 let directory = fs::canonicalize(directory).map_err(|source| WorkspaceError::Read {
459 path: directory.to_path_buf(),
460 source,
461 })?;
462 Ok((directory, (package.id.clone(), package.name.clone())))
463 })
464 .collect::<Result<_, WorkspaceError>>()?;
465 let mut packages = Vec::with_capacity(sources.members.len());
466 let mut names = BTreeSet::new();
467 for metadata_package in &sources.members {
468 if !names.insert(metadata_package.name.clone()) {
469 return Err(WorkspaceError::Duplicate(metadata_package.name.clone()));
470 }
471 let manifest = PathBuf::from(&metadata_package.manifest_path);
472 let document = sources
473 .documents
474 .get(&manifest)
475 .ok_or_else(|| WorkspaceError::ManifestNotLoaded(manifest.clone()))?;
476 let source = version_source(
477 root_manifest,
478 root_document,
479 &manifest,
480 document,
481 &metadata_package.name,
482 &metadata_package.version,
483 )?;
484 let dependencies = dependency_uses(
485 root_manifest,
486 root_document,
487 &manifest,
488 document,
489 metadata_package,
490 &member_paths,
491 )?;
492 let mut ordering_dependencies: Vec<_> = dependencies
493 .iter()
494 .filter(|dependency| dependency.kind_for_ordering())
495 .map(|dependency| dependency.dependency.clone())
496 .collect();
497 ordering_dependencies.sort();
498 ordering_dependencies.dedup();
499 packages.push(Package {
500 unit: Member {
501 name: metadata_package.name.clone(),
502 path: manifest
503 .parent()
504 .ok_or_else(|| WorkspaceError::NoManifestDirectory(manifest.clone()))?
505 .to_path_buf(),
506 version: metadata_package.version.clone(),
507 publishable: metadata_package
508 .publish
509 .as_ref()
510 .is_none_or(|registries| !registries.is_empty()),
511 dependencies: ordering_dependencies,
512 },
513 source,
514 dependencies,
515 });
516 }
517 Ok(packages)
518}
519
520fn version_source(
521 root_manifest: &Path,
522 root: &DocumentMut,
523 manifest: &Path,
524 package_document: &DocumentMut,
525 name: &str,
526 metadata_version: &str,
527) -> Result<VersionSource, WorkspaceError> {
528 let package = package_document
529 .get("package")
530 .and_then(Item::as_table_like)
531 .ok_or_else(|| WorkspaceError::NoPackageTable(name.to_owned()))?;
532 match package.get("version") {
533 Some(Item::Value(Value::String(version))) => {
534 if version.value() != metadata_version {
535 return Err(WorkspaceError::Stale {
536 path: manifest.to_path_buf(),
537 });
538 }
539 Ok(VersionSource::Member(manifest.to_path_buf()))
540 }
541 Some(item)
542 if item
543 .as_table_like()
544 .and_then(|table| table.get("workspace"))
545 .and_then(Item::as_bool)
546 == Some(true) =>
547 {
548 let version = root
549 .get("workspace")
550 .and_then(Item::as_table_like)
551 .and_then(|table| table.get("package"))
552 .and_then(Item::as_table_like)
553 .and_then(|table| table.get("version"))
554 .and_then(Item::as_str)
555 .ok_or_else(|| WorkspaceError::MissingVersionSource(name.to_owned()))?;
556 if version != metadata_version {
557 return Err(WorkspaceError::Stale {
558 path: root_manifest.to_path_buf(),
559 });
560 }
561 Ok(VersionSource::Workspace(root_manifest.to_path_buf()))
562 }
563 _ => Err(WorkspaceError::MissingVersionSource(name.to_owned())),
564 }
565}
566
567fn dependency_uses(
568 root_manifest: &Path,
569 root: &DocumentMut,
570 manifest: &Path,
571 package_document: &DocumentMut,
572 package: &MetadataPackage,
573 members: &BTreeMap<PathBuf, (String, String)>,
574) -> Result<Vec<DependencyUse>, WorkspaceError> {
575 let mut result = Vec::new();
576 let mut seen = BTreeSet::new();
577 for dependency in &package.dependencies {
578 for usage in dependency_uses_for_dependency(
579 root_manifest,
580 root,
581 manifest,
582 package_document,
583 package,
584 members,
585 dependency,
586 )? {
587 if seen.insert((
588 usage.slot.clone(),
589 usage.kind.clone(),
590 dependency.target.clone(),
591 )) {
592 result.push(usage);
593 }
594 }
595 }
596 Ok(result)
597}
598
599fn dependency_uses_for_dependency(
600 root_manifest: &Path,
601 root: &DocumentMut,
602 manifest: &Path,
603 package_document: &DocumentMut,
604 package: &MetadataPackage,
605 members: &BTreeMap<PathBuf, (String, String)>,
606 dependency: &MetadataDependency,
607) -> Result<Vec<DependencyUse>, WorkspaceError> {
608 let Some(path) = &dependency.path else {
609 return Ok(Vec::new());
610 };
611 let path = fs::canonicalize(path).map_err(|source| WorkspaceError::Read {
612 path: PathBuf::from(path),
613 source,
614 })?;
615 let Some((_, target_name)) = members.get(&path) else {
616 return Ok(Vec::new());
617 };
618 let Some(section) = dependency_section(dependency.kind.as_deref()) else {
619 return Ok(Vec::new());
620 };
621 let declaration = Declaration {
622 root_manifest,
623 root,
624 manifest,
625 consumer: &package.name,
626 dependency: target_name,
627 alias: dependency.rename.as_deref().unwrap_or(&dependency.name),
628 path: &path,
629 kind: dependency.kind.as_deref(),
630 };
631 let matching: Vec<_> = dependency_items(package_document, declaration.alias)
632 .into_iter()
633 .filter(|(table_path, _)| {
634 declares_section(table_path, section, dependency.target.as_deref())
635 })
636 .collect();
637 if matching.is_empty() {
638 return Err(declaration.missing_source());
639 }
640 matching
641 .into_iter()
642 .map(|(table_path, item)| declaration.dependency_use(table_path, item))
643 .collect()
644}
645
646fn dependency_section(kind: Option<&str>) -> Option<&'static str> {
649 match kind {
650 None | Some("normal") => Some("dependencies"),
651 Some("dev") => Some("dev-dependencies"),
652 Some("build") => Some("build-dependencies"),
653 Some(_) => None,
654 }
655}
656
657fn declares_section(table_path: &[String], section: &str, platform: Option<&str>) -> bool {
660 if table_path.last().is_none_or(|value| value != section) {
661 return false;
662 }
663 platform.map_or(table_path.len() == 1, |target| {
664 table_path.len() == 3
665 && table_path
666 .get(1)
667 .is_some_and(|candidate| same_platform(candidate, target))
668 })
669}
670
671fn inherited_dependency<'a>(root: &'a DocumentMut, alias: &str) -> Option<&'a Item> {
672 root.get("workspace")
673 .and_then(Item::as_table_like)
674 .and_then(|table| table.get("dependencies"))
675 .and_then(Item::as_table_like)
676 .and_then(|table| table.get(alias))
677}
678
679fn same_platform(source: &str, metadata: &str) -> bool {
680 match (source.parse::<Platform>(), metadata.parse::<Platform>()) {
681 (Ok(source), Ok(metadata)) => source == metadata,
682 _ => false,
683 }
684}
685
686fn dependency_items<'a>(document: &'a DocumentMut, alias: &str) -> Vec<(Vec<String>, &'a Item)> {
687 let sections = ["dependencies", "dev-dependencies", "build-dependencies"];
688 let mut result = Vec::new();
689 for section in sections {
690 if let Some(item) = document
691 .get(section)
692 .and_then(Item::as_table_like)
693 .and_then(|table| table.get(alias))
694 {
695 result.push((vec![section.to_owned()], item));
696 }
697 }
698 if let Some(targets) = document.get("target").and_then(Item::as_table_like) {
699 for (target, target_item) in targets.iter() {
700 let Some(target_table) = target_item.as_table_like() else {
701 continue;
702 };
703 for section in sections {
704 if let Some(item) = target_table
705 .get(section)
706 .and_then(Item::as_table_like)
707 .and_then(|table| table.get(alias))
708 {
709 result.push((
710 vec!["target".to_owned(), target.to_owned(), section.to_owned()],
711 item,
712 ));
713 }
714 }
715 }
716 }
717 result
718}
719
720fn parse_manifest(path: &Path, contents: &str) -> Result<DocumentMut, WorkspaceError> {
721 contents
722 .parse::<DocumentMut>()
723 .map_err(|source| WorkspaceError::Manifest {
724 path: path.to_path_buf(),
725 source,
726 })
727}
728
729fn read(path: &Path) -> Result<String, WorkspaceError> {
730 fs::read_to_string(path).map_err(|source| WorkspaceError::Read {
731 path: path.to_path_buf(),
732 source,
733 })
734}
735
736pub(super) fn run_metadata(
738 directory: &Path,
739 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
740) -> Result<Vec<u8>, WorkspaceError> {
741 let output = Command::new("cargo")
742 .arg("metadata")
743 .args(args)
744 .current_dir(directory)
745 .output()
746 .map_err(WorkspaceError::MetadataSpawn)?;
747 if output.status.success() {
748 Ok(output.stdout)
749 } else {
750 Err(WorkspaceError::MetadataFailed {
751 status: output.status,
752 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
753 })
754 }
755}
756
757fn read_metadata(root_manifest: &Path, root: &Path) -> Result<Metadata, WorkspaceError> {
758 let stdout = run_metadata(
759 root,
760 [
761 OsStr::new("--no-deps"),
762 OsStr::new("--locked"),
763 OsStr::new("--offline"),
764 OsStr::new("--format-version=1"),
765 OsStr::new("--manifest-path"),
766 root_manifest.as_os_str(),
767 ],
768 )?;
769 facet_json::from_str(&String::from_utf8_lossy(&stdout))
770 .map_err(|error| WorkspaceError::MetadataDecode(Box::new(error)))
771}
772
773fn metadata_members(metadata: &Metadata) -> Vec<MetadataPackage> {
774 let member_ids: BTreeSet<_> = metadata.workspace_members.iter().collect();
775 let mut packages: Vec<_> = metadata
776 .packages
777 .iter()
778 .filter(|package| member_ids.contains(&package.id))
779 .cloned()
780 .collect();
781 packages.sort_by(|left, right| left.manifest_path.cmp(&right.manifest_path));
782 for package in &mut packages {
783 package.dependencies.sort_by(|left, right| {
784 (
785 &left.name,
786 &left.req,
787 &left.path,
788 &left.rename,
789 &left.kind,
790 &left.target,
791 )
792 .cmp(&(
793 &right.name,
794 &right.req,
795 &right.path,
796 &right.rename,
797 &right.kind,
798 &right.target,
799 ))
800 });
801 }
802 packages
803}
804
805fn metadata_difference(
806 before: &Metadata,
807 after: &Metadata,
808 root_manifest: &Path,
809) -> Option<PathBuf> {
810 let left_members: BTreeSet<_> = before.workspace_members.iter().collect();
811 let right_members: BTreeSet<_> = after.workspace_members.iter().collect();
812 if left_members != right_members {
813 return Some(root_manifest.to_path_buf());
814 }
815 let left = metadata_members(before);
816 let right = metadata_members(after);
817 if left == right {
818 return None;
819 }
820 let right_by_manifest: BTreeMap<_, _> = right
821 .iter()
822 .map(|package| (&package.manifest_path, package))
823 .collect();
824 left.iter()
825 .find(|package| right_by_manifest.get(&package.manifest_path) != Some(package))
826 .map(|package| PathBuf::from(&package.manifest_path))
827 .or_else(|| Some(root_manifest.to_path_buf()))
828}