1use std::{
2 collections::{BTreeMap, BTreeSet},
3 path::Path,
4};
5
6use semifold_core::{
7 DependencyKind, EcosystemId, EditSource, FileEdit, FileEditExpectation, FileHash, PackageId,
8 PackageSnapshot, SharedVersionEdit, VersionMap, VersionSource, VersionSourceId,
9};
10use serde::Deserialize;
11
12use crate::{
13 adapter::{
14 AdapterError, EcosystemAdapter, EcosystemPlanInput, ManifestDependency, PackageInspection,
15 PackageLocation, ParsedPackage,
16 },
17 config::{PackageConfig, ReleaseChannel},
18 error::ResolveError,
19};
20
21#[derive(Deserialize)]
22struct CargoPackage {
23 pub name: String,
24 pub version: CargoVersion,
25 pub publish: Option<bool>,
26}
27
28#[derive(Deserialize)]
29#[serde(untagged)]
30enum CargoVersion {
31 Literal(String),
32 Workspace { workspace: bool },
33}
34
35#[derive(Deserialize)]
36struct CargoWorkspacePackage {
37 pub version: Option<String>,
38}
39
40#[derive(Deserialize)]
41struct CargoWorkspace {
42 #[serde(default)]
43 pub members: Vec<String>,
44 pub dependencies: Option<BTreeMap<String, serde_json::Value>>,
45 pub package: Option<CargoWorkspacePackage>,
46}
47
48#[derive(Deserialize)]
49struct CargoToml {
50 pub package: Option<CargoPackage>,
51 pub workspace: Option<CargoWorkspace>,
52 pub dependencies: Option<BTreeMap<String, serde_json::Value>>,
53 #[serde(rename = "dev-dependencies")]
54 pub dev_dependencies: Option<BTreeMap<String, serde_json::Value>>,
55 #[serde(rename = "build-dependencies")]
56 pub build_dependencies: Option<BTreeMap<String, serde_json::Value>>,
57}
58
59pub struct RustResolver;
60
61struct PlannedManifest {
62 original: String,
63 document: toml_edit::DocumentMut,
64 package: Option<PackageId>,
65 dependencies: BTreeSet<PackageId>,
66 shared_versions: BTreeMap<VersionSourceId, BTreeSet<PackageId>>,
67}
68
69impl RustResolver {
70 fn package_config(path: impl Into<std::path::PathBuf>) -> PackageConfig {
71 PackageConfig {
72 path: path.into(),
73 resolver: EcosystemId::RUST,
74 publish: None,
75 channel: ReleaseChannel::Stable,
76 channel_bump: None,
77 assets: Vec::new(),
78 github_release: None,
79 depends_on: vec![],
80 }
81 }
82
83 fn package_inspection(
84 id: PackageId,
85 package: ParsedPackage,
86 dependencies: Vec<ManifestDependency>,
87 ) -> Result<PackageInspection, AdapterError> {
88 let path = camino::Utf8PathBuf::from_path_buf(package.path).map_err(|path| {
89 AdapterError::InvalidInput {
90 reason: format!("Rust package path is not valid UTF-8: {}", path.display()),
91 }
92 })?;
93 Ok(PackageInspection {
94 id,
95 manifest_name: package.name,
96 version: package.version,
97 version_source: package.version_source,
98 ecosystem: EcosystemId::RUST,
99 path,
100 publishable: !package.private,
101 dependencies,
102 })
103 }
104
105 pub fn plan_file_edits(
107 root: &Path,
108 released_packages: &[&PackageSnapshot],
109 workspace_packages: &[&PackageSnapshot],
110 versions: &VersionMap,
111 ) -> Result<Vec<FileEdit>, ResolveError> {
112 let changed_versions = workspace_packages
113 .iter()
114 .filter_map(|package| {
115 versions
116 .get(&package.id)
117 .filter(|version| *version != &package.version)
118 .map(|version| (package, version))
119 })
120 .map(|(package, version)| {
121 RustResolver
122 .encode_version(version)
123 .map(|version| (package.manifest_name.clone(), (package.id.clone(), version)))
124 .map_err(|error| ResolveError::InvalidVersion {
125 version: version.to_string(),
126 reason: error.to_string(),
127 })
128 })
129 .collect::<Result<BTreeMap<_, _>, _>>()?;
130 let mut manifests = BTreeMap::<String, PlannedManifest>::new();
131
132 for package in released_packages {
133 let relative_path = Self::manifest_path(package);
134 let next_version =
135 versions
136 .get(&package.id)
137 .ok_or_else(|| ResolveError::InvalidConfig {
138 path: root.join(&relative_path),
139 reason: format!("missing planned version for {}", package.id),
140 })?;
141 let encoded_version = RustResolver.encode_version(next_version).map_err(|error| {
142 ResolveError::InvalidVersion {
143 version: next_version.to_string(),
144 reason: error.to_string(),
145 }
146 })?;
147 {
148 let manifest = Self::load_manifest(root, &relative_path, &mut manifests)?;
149 manifest.package = Some(package.id.clone());
150 if matches!(package.version_source, VersionSource::PackageManifest) {
151 let package_table = manifest
152 .document
153 .get_mut("package")
154 .and_then(toml_edit::Item::as_table_mut)
155 .ok_or(ResolveError::ParseError {
156 path: root.join(&relative_path),
157 reason: "package table not found".to_string(),
158 })?;
159 package_table.insert("version", toml_edit::value(&encoded_version));
160 }
161
162 for dependency_table in ["dependencies", "dev-dependencies", "build-dependencies"] {
163 if let Some(dependencies) = manifest
164 .document
165 .get_mut(dependency_table)
166 .and_then(toml_edit::Item::as_table_mut)
167 {
168 manifest
169 .dependencies
170 .extend(Self::update_dependency_versions(
171 dependencies,
172 &changed_versions,
173 ));
174 }
175 }
176 }
177 if let VersionSource::Shared { source } = &package.version_source {
178 if source.field != "workspace.package.version" {
179 return Err(ResolveError::InvalidConfig {
180 path: root.join(&source.manifest),
181 reason: format!("unsupported Rust shared version field: {}", source.field),
182 });
183 }
184 let owner = Self::load_manifest(root, source.manifest.as_str(), &mut manifests)?;
185 let workspace = owner
186 .document
187 .get_mut("workspace")
188 .and_then(toml_edit::Item::as_table_mut)
189 .ok_or(ResolveError::ParseError {
190 path: root.join(&source.manifest),
191 reason: "workspace table not found".to_string(),
192 })?;
193 let workspace_package = workspace
194 .get_mut("package")
195 .and_then(toml_edit::Item::as_table_mut)
196 .ok_or(ResolveError::ParseError {
197 path: root.join(&source.manifest),
198 reason: "workspace.package table not found".to_string(),
199 })?;
200 workspace_package.insert("version", toml_edit::value(encoded_version));
201 owner
202 .shared_versions
203 .entry(source.clone())
204 .or_default()
205 .insert(package.id.clone());
206 }
207 }
208
209 let workspace_path = "Cargo.toml";
210 let workspace_absolute = root.join(workspace_path);
211 if workspace_absolute.exists() {
212 let manifest = Self::load_manifest(root, workspace_path, &mut manifests)?;
213 if let Some(dependencies) = manifest
214 .document
215 .get_mut("workspace")
216 .and_then(toml_edit::Item::as_table_mut)
217 .and_then(|workspace| workspace.get_mut("dependencies"))
218 .and_then(toml_edit::Item::as_table_mut)
219 {
220 manifest
221 .dependencies
222 .extend(Self::update_dependency_versions(
223 dependencies,
224 &changed_versions,
225 ));
226 }
227 }
228
229 manifests
230 .into_iter()
231 .filter_map(|(path, manifest)| {
232 let new_content = manifest.document.to_string();
233 (new_content != manifest.original).then_some((path, manifest, new_content))
234 })
235 .map(|(path, manifest, new_content)| {
236 let dependencies = manifest.dependencies.into_iter().collect::<Vec<_>>();
237 let shared_versions = manifest
238 .shared_versions
239 .into_iter()
240 .map(|(source, packages)| SharedVersionEdit {
241 source,
242 packages: packages.into_iter().collect(),
243 })
244 .collect::<Vec<_>>();
245 let source = if shared_versions.is_empty() {
246 manifest.package.map_or_else(
247 || EditSource::WorkspaceDependencies { dependencies },
248 |package| EditSource::PackageVersion { package },
249 )
250 } else {
251 EditSource::WorkspaceManifest {
252 shared_versions,
253 dependencies,
254 }
255 };
256 Ok(FileEdit {
257 path: path.into(),
258 expected: FileEditExpectation::Existing {
259 hash: FileHash::from_bytes(manifest.original.as_bytes()),
260 },
261 new_content,
262 source,
263 })
264 })
265 .collect()
266 }
267
268 fn manifest_path(package: &PackageSnapshot) -> String {
269 if package.path.as_str().is_empty() || package.path == "." {
270 "Cargo.toml".to_string()
271 } else {
272 format!("{}/Cargo.toml", package.path.as_str().trim_end_matches('/'))
273 }
274 }
275
276 fn load_manifest<'manifests>(
277 root: &Path,
278 relative_path: &str,
279 manifests: &'manifests mut BTreeMap<String, PlannedManifest>,
280 ) -> Result<&'manifests mut PlannedManifest, ResolveError> {
281 use std::collections::btree_map::Entry;
282
283 match manifests.entry(relative_path.to_string()) {
284 Entry::Occupied(entry) => Ok(entry.into_mut()),
285 Entry::Vacant(entry) => {
286 let absolute_path = root.join(relative_path);
287 let original = std::fs::read_to_string(&absolute_path)?;
288 let document = original
289 .parse::<toml_edit::DocumentMut>()
290 .map_err(|error| ResolveError::ParseError {
291 path: absolute_path,
292 reason: error.to_string(),
293 })?;
294 Ok(entry.insert(PlannedManifest {
295 original,
296 document,
297 package: None,
298 dependencies: BTreeSet::new(),
299 shared_versions: BTreeMap::new(),
300 }))
301 }
302 }
303 }
304
305 fn update_dependency_versions(
306 dependencies: &mut toml_edit::Table,
307 changed_versions: &BTreeMap<String, (PackageId, String)>,
308 ) -> BTreeSet<PackageId> {
309 let mut updated = BTreeSet::new();
310 for (name, dependency) in dependencies.iter_mut() {
311 let manifest_name = dependency
312 .get("package")
313 .and_then(toml_edit::Item::as_str)
314 .unwrap_or(name.get());
315 let Some((package, version)) = changed_versions.get(manifest_name) else {
316 continue;
317 };
318 if dependency.is_str() {
319 if dependency.as_str() != Some(version.as_str()) {
320 *dependency = toml_edit::value(version);
321 updated.insert(package.clone());
322 }
323 } else if dependency.get("version").is_some()
324 && dependency.get("version").and_then(toml_edit::Item::as_str)
325 != Some(version.as_str())
326 {
327 dependency["version"] = toml_edit::value(version);
328 updated.insert(package.clone());
329 }
330 }
331 updated
332 }
333
334 fn dependency_requirement(dependency: &serde_json::Value) -> Option<String> {
335 dependency
336 .as_str()
337 .or_else(|| {
338 dependency
339 .get("version")
340 .and_then(serde_json::Value::as_str)
341 })
342 .map(str::to_string)
343 }
344
345 fn collect_dependencies(
346 dependencies: Option<BTreeMap<String, serde_json::Value>>,
347 workspace_dependencies: Option<&BTreeMap<String, serde_json::Value>>,
348 kind: DependencyKind,
349 ) -> Vec<ManifestDependency> {
350 dependencies
351 .unwrap_or_default()
352 .into_iter()
353 .map(|(name, dependency)| {
354 let dependency = dependency
355 .get("workspace")
356 .and_then(serde_json::Value::as_bool)
357 .filter(|workspace| *workspace)
358 .and_then(|_| {
359 workspace_dependencies.and_then(|dependencies| dependencies.get(&name))
360 })
361 .unwrap_or(&dependency);
362 ManifestDependency {
363 manifest_name: dependency
364 .get("package")
365 .and_then(serde_json::Value::as_str)
366 .unwrap_or(&name)
367 .to_string(),
368 kind,
369 requirement: Self::dependency_requirement(dependency),
370 }
371 })
372 .collect()
373 }
374
375 pub fn manifest_dependencies(
376 root: &Path,
377 pkg_config: &PackageConfig,
378 ) -> Result<Vec<ManifestDependency>, ResolveError> {
379 let cargo_toml_path = root.join(&pkg_config.path).join("Cargo.toml");
380 let cargo_toml: CargoToml =
381 toml_edit::de::from_str(&std::fs::read_to_string(&cargo_toml_path)?).map_err(|e| {
382 ResolveError::ParseError {
383 path: cargo_toml_path,
384 reason: e.to_string(),
385 }
386 })?;
387 let workspace_manifest_path = root.join("Cargo.toml");
388 let workspace_dependencies = if workspace_manifest_path.exists() {
389 let workspace_manifest: CargoToml =
390 toml_edit::de::from_str(&std::fs::read_to_string(&workspace_manifest_path)?)
391 .map_err(|e| ResolveError::ParseError {
392 path: workspace_manifest_path,
393 reason: e.to_string(),
394 })?;
395 workspace_manifest
396 .workspace
397 .and_then(|workspace| workspace.dependencies)
398 } else {
399 None
400 };
401
402 Ok([
403 Self::collect_dependencies(
404 cargo_toml.dependencies,
405 workspace_dependencies.as_ref(),
406 DependencyKind::Runtime,
407 ),
408 Self::collect_dependencies(
409 cargo_toml.dev_dependencies,
410 workspace_dependencies.as_ref(),
411 DependencyKind::Development,
412 ),
413 Self::collect_dependencies(
414 cargo_toml.build_dependencies,
415 workspace_dependencies.as_ref(),
416 DependencyKind::Build,
417 ),
418 ]
419 .concat())
420 }
421}
422
423impl EcosystemAdapter for RustResolver {
424 fn ecosystem(&self) -> EcosystemId {
425 EcosystemId::RUST
426 }
427
428 fn encode_version(&self, version: &semver::Version) -> Result<String, AdapterError> {
429 Ok(version.to_string())
430 }
431
432 fn discover(&self, root: &camino::Utf8Path) -> Result<Vec<PackageInspection>, AdapterError> {
433 let packages = self.discover_packages(root.as_std_path())?;
434 let mut inspections = packages
435 .into_iter()
436 .map(|package| {
437 let dependencies = Self::manifest_dependencies(
438 root.as_std_path(),
439 &Self::package_config(package.path.clone()),
440 )?;
441 Self::package_inspection(
442 PackageId::new(package.name.clone()),
443 package,
444 dependencies,
445 )
446 })
447 .collect::<Result<Vec<_>, AdapterError>>()?;
448 inspections.sort_by(|left, right| {
449 left.id
450 .cmp(&right.id)
451 .then_with(|| left.path.cmp(&right.path))
452 });
453 Ok(inspections)
454 }
455
456 fn inspect(&self, location: &PackageLocation) -> Result<PackageInspection, AdapterError> {
457 if location.path.is_absolute()
458 || location
459 .path
460 .components()
461 .any(|component| component == camino::Utf8Component::ParentDir)
462 {
463 return Err(AdapterError::InvalidInput {
464 reason: format!(
465 "Rust package path must be relative to the project root: {}",
466 location.path
467 ),
468 });
469 }
470 let config = Self::package_config(location.path.as_std_path());
471 let package = self.parse_package(location.project_root.as_std_path(), &config)?;
472 let dependencies =
473 Self::manifest_dependencies(location.project_root.as_std_path(), &config)?;
474 Self::package_inspection(location.id.clone(), package, dependencies)
475 }
476
477 fn plan_edits(&self, input: EcosystemPlanInput<'_>) -> Result<Vec<FileEdit>, AdapterError> {
478 if input
479 .workspace_packages
480 .iter()
481 .any(|package| package.ecosystem != EcosystemId::RUST)
482 {
483 return Err(AdapterError::InvalidInput {
484 reason: "Rust edit planning received a non-Rust workspace package".to_string(),
485 });
486 }
487 let workspace_packages = input
488 .workspace_packages
489 .iter()
490 .map(|package| (package.id.clone(), package))
491 .collect::<BTreeMap<_, _>>();
492 let released_packages = input
493 .released_packages
494 .iter()
495 .map(|id| {
496 workspace_packages
497 .get(id)
498 .copied()
499 .ok_or_else(|| AdapterError::InvalidInput {
500 reason: format!("released Rust package {id} is not in the workspace"),
501 })
502 })
503 .collect::<Result<Vec<_>, _>>()?;
504 let workspace_package_refs = input.workspace_packages.iter().collect::<Vec<_>>();
505 Ok(Self::plan_file_edits(
506 input.project_root.as_std_path(),
507 &released_packages,
508 &workspace_package_refs,
509 input.versions,
510 )?)
511 }
512}
513
514impl RustResolver {
515 fn parse_package(
516 &self,
517 root: &Path,
518 pkg_config: &PackageConfig,
519 ) -> Result<ParsedPackage, ResolveError> {
520 let toml_path = root.join(&pkg_config.path).join("Cargo.toml");
521 if !toml_path.exists() {
522 return Err(ResolveError::FileOrDirNotFound {
523 path: toml_path.clone(),
524 });
525 }
526 let toml_str = std::fs::read_to_string(&toml_path)?;
527 let cargo_toml: CargoToml =
528 toml_edit::de::from_str(&toml_str).map_err(|e| ResolveError::ParseError {
529 path: toml_path.clone(),
530 reason: e.to_string(),
531 })?;
532 let cargo_pkg_config = cargo_toml.package.ok_or(ResolveError::InvalidConfig {
533 path: toml_path.clone(),
534 reason: "Not found package in Cargo.toml".into(),
535 })?;
536 let publish = cargo_pkg_config.publish.unwrap_or(true);
537 let (version, version_source) = match cargo_pkg_config.version {
538 CargoVersion::Literal(version) => (version, VersionSource::PackageManifest),
539 CargoVersion::Workspace { workspace: true } => {
540 let workspace_path = root.join("Cargo.toml");
541 let workspace_manifest: CargoToml = toml_edit::de::from_str(
542 &std::fs::read_to_string(&workspace_path)?,
543 )
544 .map_err(|error| ResolveError::ParseError {
545 path: workspace_path.clone(),
546 reason: error.to_string(),
547 })?;
548 let version = workspace_manifest
549 .workspace
550 .and_then(|workspace| workspace.package)
551 .and_then(|package| package.version)
552 .ok_or_else(|| ResolveError::InvalidConfig {
553 path: workspace_path,
554 reason: "workspace package version is required by an inherited package"
555 .to_string(),
556 })?;
557 (
558 version,
559 VersionSource::Shared {
560 source: VersionSourceId {
561 manifest: "Cargo.toml".into(),
562 field: "workspace.package.version".to_string(),
563 },
564 },
565 )
566 }
567 CargoVersion::Workspace { workspace: false } => {
568 return Err(ResolveError::InvalidConfig {
569 path: toml_path,
570 reason: "package.version.workspace must be true".to_string(),
571 });
572 }
573 };
574 let package = ParsedPackage {
575 name: cargo_pkg_config.name,
576 version: semver::Version::parse(&version)?,
577 version_source,
578 path: pkg_config.path.clone(),
579 private: !publish,
580 };
581 Ok(package)
582 }
583
584 fn discover_packages(&self, root: &Path) -> Result<Vec<ParsedPackage>, ResolveError> {
585 let cargo_toml_path = root.join("Cargo.toml");
586 if !cargo_toml_path.exists() {
587 log::warn!(
588 "Cannot resolve package in {}, Cargo.toml not found.",
589 root.display()
590 );
591 return Ok(vec![]);
592 }
593
594 let toml_str = std::fs::read_to_string(&cargo_toml_path)?;
595 let cargo_toml: CargoToml =
596 toml_edit::de::from_str(&toml_str).map_err(|e| ResolveError::ParseError {
597 path: cargo_toml_path.clone(),
598 reason: e.to_string(),
599 })?;
600
601 if cargo_toml.workspace.is_none() {
602 if cargo_toml.package.is_none() {
603 log::warn!("Failed to resolve package in {}", root.display());
604 return Ok(vec![]);
605 }
606 let package = self.parse_package(
607 root,
608 &PackageConfig {
609 path: ".".into(),
610 resolver: EcosystemId::RUST,
611 publish: None,
612 channel: ReleaseChannel::Stable,
613 channel_bump: None,
614 assets: vec![],
615 github_release: None,
616 depends_on: vec![],
617 },
618 )?;
619 return Ok(vec![package]);
620 }
621
622 let Some(workspace) = cargo_toml.workspace else {
623 return Err(ResolveError::InvalidConfig {
624 path: cargo_toml_path,
625 reason: "workspace disappeared after manifest parsing".to_string(),
626 });
627 };
628 let members = workspace
629 .members
630 .iter()
631 .try_fold(Vec::new(), |mut members, member| {
632 let pattern = root.join(member).display().to_string();
633 let paths = glob::glob(&pattern)
634 .map_err(|e| ResolveError::ParseError {
635 path: cargo_toml_path.clone(),
636 reason: e.to_string(),
637 })?
638 .map(|path| {
639 path.map_err(|error| ResolveError::ParseError {
640 path: error.path().to_path_buf(),
641 reason: error.to_string(),
642 })
643 })
644 .collect::<Result<Vec<_>, _>>()?;
645 members.extend(paths);
646 Ok::<_, ResolveError>(members)
647 })?;
648
649 log::debug!("members: {members:?}");
650
651 let packages = members
652 .into_iter()
653 .map(|path| {
654 let rel_path = pathdiff::diff_paths(&path, root).unwrap_or(path);
655 self.parse_package(
656 root,
657 &PackageConfig {
658 path: rel_path.to_path_buf(),
659 resolver: EcosystemId::RUST,
660 publish: None,
661 channel: ReleaseChannel::Stable,
662 channel_bump: None,
663 assets: vec![],
664 github_release: None,
665 depends_on: vec![],
666 },
667 )
668 })
669 .collect::<Result<Vec<_>, _>>()?;
670
671 Ok(packages)
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use std::{
678 fs,
679 path::{Path, PathBuf},
680 time::{SystemTime, UNIX_EPOCH},
681 };
682
683 use crate::{
684 adapter::{AdapterError, EcosystemAdapter, EcosystemPlanInput, PackageLocation},
685 config::{PackageConfig, ReleaseChannel},
686 error::ResolveError,
687 resolver::ResolverType,
688 };
689 use semifold_core::{
690 EcosystemId, EditSource, PackageId, PackageSnapshot, VersionMap, VersionSource,
691 VersionSourceId,
692 };
693
694 use super::RustResolver;
695
696 fn temp_dir(test_name: &str) -> PathBuf {
697 let nonce = SystemTime::now()
698 .duration_since(UNIX_EPOCH)
699 .unwrap()
700 .as_nanos();
701 let path = std::env::temp_dir().join(format!(
702 "semifold-rust-resolver-{test_name}-{}-{nonce}",
703 std::process::id()
704 ));
705 fs::create_dir_all(&path).unwrap();
706 path
707 }
708
709 fn package_config(path: impl Into<PathBuf>) -> PackageConfig {
710 PackageConfig {
711 path: path.into(),
712 resolver: ResolverType::Rust.into(),
713 publish: None,
714 channel: ReleaseChannel::Stable,
715 channel_bump: None,
716 assets: vec![],
717 github_release: None,
718 depends_on: vec![],
719 }
720 }
721
722 fn write_package(
723 root: &Path,
724 path: &str,
725 name: &str,
726 version: &str,
727 publish: Option<bool>,
728 dependencies: Option<&str>,
729 ) {
730 let package_root = root.join(path);
731 fs::create_dir_all(&package_root).unwrap();
732 let publish = publish
733 .map(|value| format!("publish = {value}\n"))
734 .unwrap_or_default();
735 let dependencies = dependencies
736 .map(|value| format!("\n[dependencies]\n{value}\n"))
737 .unwrap_or_default();
738 fs::write(
739 package_root.join("Cargo.toml"),
740 format!(
741 "[package]\nname = \"{name}\"\nversion = \"{version}\"\n{publish}{dependencies}"
742 ),
743 )
744 .unwrap();
745 }
746
747 fn write_inherited_package(root: &Path, path: &str, name: &str, publish: Option<bool>) {
748 let package_root = root.join(path);
749 fs::create_dir_all(&package_root).unwrap();
750 let publish = publish
751 .map(|value| format!("publish = {value}\n"))
752 .unwrap_or_default();
753 fs::write(
754 package_root.join("Cargo.toml"),
755 format!("[package]\nname = \"{name}\"\nversion.workspace = true\n{publish}"),
756 )
757 .unwrap();
758 }
759
760 #[test]
761 fn resolves_a_single_package() {
762 let root = temp_dir("single-package");
763 write_package(&root, ".", "single", "1.2.3", None, None);
764
765 let package = RustResolver
766 .parse_package(&root, &package_config("."))
767 .unwrap();
768
769 assert_eq!(package.name, "single");
770 assert_eq!(package.version, semver::Version::parse("1.2.3").unwrap());
771 assert!(!package.private);
772 fs::remove_dir_all(root).unwrap();
773 }
774
775 #[test]
776 fn discovers_workspace_members_and_private_packages() {
777 let root = temp_dir("workspace");
778 fs::write(
779 root.join("Cargo.toml"),
780 "[workspace]\nmembers = [\"crates/*\"]\n",
781 )
782 .unwrap();
783 write_package(&root, "crates/core", "core", "1.0.0", None, None);
784 write_package(
785 &root,
786 "crates/internal",
787 "internal",
788 "1.0.0",
789 Some(false),
790 None,
791 );
792
793 let mut packages = RustResolver.discover_packages(&root).unwrap();
794 packages.sort_by(|left, right| left.name.cmp(&right.name));
795
796 assert_eq!(packages.len(), 2);
797 assert_eq!(packages[0].name, "core");
798 assert_eq!(packages[0].path, PathBuf::from("crates/core"));
799 assert!(!packages[0].private);
800 assert_eq!(packages[1].name, "internal");
801 assert_eq!(packages[1].path, PathBuf::from("crates/internal"));
802 assert!(packages[1].private);
803 fs::remove_dir_all(root).unwrap();
804 }
805
806 #[test]
807 fn workspace_package_table_without_version_allows_literal_member_versions() {
808 let root = temp_dir("workspace-package-without-version");
809 fs::write(
810 root.join("Cargo.toml"),
811 "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nedition = \"2024\"\n",
812 )
813 .unwrap();
814 write_package(&root, "crates/core", "core", "1.0.0", None, None);
815 let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
816
817 let packages = RustResolver.discover(&project_root).unwrap();
818
819 assert_eq!(packages.len(), 1);
820 assert_eq!(packages[0].version, semver::Version::new(1, 0, 0));
821 assert_eq!(packages[0].version_source, VersionSource::PackageManifest);
822 fs::remove_dir_all(root).unwrap();
823 }
824
825 #[test]
826 fn discovers_workspace_inherited_versions_without_panicking() {
827 let root = temp_dir("workspace-inherited-version");
828 fs::write(
829 root.join("Cargo.toml"),
830 "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"1.2.3\"\n",
831 )
832 .unwrap();
833 write_inherited_package(&root, "crates/core", "core", None);
834 write_inherited_package(&root, "crates/internal", "internal", Some(false));
835 let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
836
837 let packages = RustResolver.discover(&project_root).unwrap();
838
839 assert_eq!(packages.len(), 2);
840 assert!(packages.iter().all(|package| {
841 package.version == semver::Version::new(1, 2, 3)
842 && matches!(
843 &package.version_source,
844 VersionSource::Shared { source }
845 if source.manifest == "Cargo.toml"
846 && source.field == "workspace.package.version"
847 )
848 }));
849 assert!(
850 packages
851 .iter()
852 .find(|package| package.manifest_name == "internal")
853 .is_some_and(|package| !package.publishable)
854 );
855 fs::remove_dir_all(root).unwrap();
856 }
857
858 #[test]
859 fn inherited_version_requires_a_workspace_package_version() {
860 let root = temp_dir("workspace-inherited-version-missing-source");
861 fs::write(
862 root.join("Cargo.toml"),
863 "[workspace]\nmembers = [\"crates/*\"]\n",
864 )
865 .unwrap();
866 write_inherited_package(&root, "crates/core", "core", None);
867 let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
868
869 assert!(matches!(
870 RustResolver.discover(&project_root),
871 Err(AdapterError::Manifest(ResolveError::InvalidConfig { .. }))
872 ));
873 fs::remove_dir_all(root).unwrap();
874 }
875
876 #[test]
877 fn adapter_discovers_and_inspects_manifest_dependencies_before_id_binding() {
878 let root = temp_dir("adapter-inspection");
879 fs::write(
880 root.join("Cargo.toml"),
881 "[workspace]\nmembers = [\"crates/*\"]\n",
882 )
883 .unwrap();
884 write_package(&root, "crates/core", "core", "1.0.0", None, None);
885 write_package(
886 &root,
887 "crates/app",
888 "app",
889 "1.0.0",
890 None,
891 Some("core = { version = \"1\", path = \"../core\" }\nserde = \"1\""),
892 );
893 let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
894
895 let discovered = RustResolver.discover(&project_root).unwrap();
896 assert_eq!(
897 discovered
898 .iter()
899 .map(|package| package.id.as_str())
900 .collect::<Vec<_>>(),
901 ["app", "core"]
902 );
903 let app = RustResolver
904 .inspect(&PackageLocation {
905 id: PackageId::new("configured-app"),
906 project_root,
907 path: "crates/app".into(),
908 })
909 .unwrap();
910
911 assert_eq!(app.id, PackageId::new("configured-app"));
912 assert_eq!(app.manifest_name, "app");
913 assert_eq!(
914 app.dependencies
915 .iter()
916 .map(|dependency| dependency.manifest_name.as_str())
917 .collect::<Vec<_>>(),
918 ["core", "serde"]
919 );
920 fs::remove_dir_all(root).unwrap();
921 }
922
923 #[test]
924 fn plans_a_manifest_edit_from_the_complete_version_map() {
925 let root = temp_dir("plan-file-edit");
926 let manifest_path = root.join("crates/app/Cargo.toml");
927 fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
928 let original = "# keep this comment\n[package]\nname = \"app\"\nversion = \"1.0.0\"\n\n[dependencies]\ncore = { version = \"1.0.0\", path = \"../core\", features = [\"serde\"] }\nalias = { package = \"renamed\", version = \"2.0.0\" }\n\n[dev-dependencies]\ndev = \"3.0.0\"\n\n[build-dependencies]\nbuild = { version = \"4.0.0\" }\n";
929 fs::write(&manifest_path, original).unwrap();
930 let package = PackageSnapshot {
931 id: PackageId::new("app"),
932 manifest_name: "app".to_string(),
933 version: semver::Version::new(1, 0, 0),
934 version_source: VersionSource::PackageManifest,
935 ecosystem: EcosystemId::RUST,
936 path: "crates/app".into(),
937 publishable: true,
938 dependencies: vec![],
939 };
940 let internal = [
941 package.clone(),
942 PackageSnapshot {
943 id: PackageId::new("core-id"),
944 manifest_name: "core".to_string(),
945 version: semver::Version::new(1, 0, 0),
946 version_source: VersionSource::PackageManifest,
947 ecosystem: EcosystemId::RUST,
948 path: "crates/core".into(),
949 publishable: true,
950 dependencies: vec![],
951 },
952 PackageSnapshot {
953 id: PackageId::new("renamed-id"),
954 manifest_name: "renamed".to_string(),
955 version: semver::Version::new(2, 0, 0),
956 version_source: VersionSource::PackageManifest,
957 ecosystem: EcosystemId::RUST,
958 path: "crates/renamed".into(),
959 publishable: true,
960 dependencies: vec![],
961 },
962 PackageSnapshot {
963 id: PackageId::new("dev-id"),
964 manifest_name: "dev".to_string(),
965 version: semver::Version::new(3, 0, 0),
966 version_source: VersionSource::PackageManifest,
967 ecosystem: EcosystemId::RUST,
968 path: "crates/dev".into(),
969 publishable: true,
970 dependencies: vec![],
971 },
972 PackageSnapshot {
973 id: PackageId::new("build-id"),
974 manifest_name: "build".to_string(),
975 version: semver::Version::new(4, 0, 0),
976 version_source: VersionSource::PackageManifest,
977 ecosystem: EcosystemId::RUST,
978 path: "crates/build".into(),
979 publishable: true,
980 dependencies: vec![],
981 },
982 ];
983 let versions = VersionMap::from([
984 (PackageId::new("app"), semver::Version::new(1, 0, 1)),
985 (PackageId::new("core-id"), semver::Version::new(1, 1, 0)),
986 (PackageId::new("renamed-id"), semver::Version::new(2, 1, 0)),
987 (PackageId::new("dev-id"), semver::Version::new(3, 1, 0)),
988 (PackageId::new("build-id"), semver::Version::new(4, 1, 0)),
989 ]);
990
991 let project_root = camino::Utf8Path::from_path(&root).unwrap();
992 let edits = RustResolver
993 .plan_edits(EcosystemPlanInput {
994 project_root,
995 workspace_packages: &internal,
996 released_packages: std::slice::from_ref(&package.id),
997 versions: &versions,
998 })
999 .unwrap();
1000 let edit = &edits[0];
1001
1002 assert_eq!(edit.path.as_str(), "crates/app/Cargo.toml");
1003 assert_eq!(
1004 edit.expected,
1005 semifold_core::FileEditExpectation::Existing {
1006 hash: semifold_core::FileHash::from_bytes(original.as_bytes()),
1007 }
1008 );
1009 assert!(edit.new_content.contains("# keep this comment"));
1010 assert!(edit.new_content.contains("version = \"1.0.1\""));
1011 assert!(edit.new_content.contains(
1012 "core = { version = \"1.1.0\", path = \"../core\", features = [\"serde\"] }"
1013 ));
1014 assert!(
1015 edit.new_content
1016 .contains("alias = { package = \"renamed\", version = \"2.1.0\" }")
1017 );
1018 assert!(edit.new_content.contains("dev = \"3.1.0\""));
1019 assert!(edit.new_content.contains("build = { version = \"4.1.0\" }"));
1020 assert_eq!(fs::read_to_string(manifest_path).unwrap(), original);
1021 fs::remove_dir_all(root).unwrap();
1022 }
1023
1024 #[test]
1025 fn merges_workspace_dependency_updates_independently_of_release_order() {
1026 let root = temp_dir("workspace-edit-plan");
1027 fs::write(
1028 root.join("Cargo.toml"),
1029 "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.dependencies]\ncore-alias = { package = \"core\", version = \"1.0.0\", path = \"crates/core\" }\nhelper = \"2.0.0\"\nexternal = \"^9\"\n",
1030 )
1031 .unwrap();
1032 write_package(&root, "crates/core", "core", "1.0.0", None, None);
1033 write_package(&root, "crates/helper", "helper", "2.0.0", None, None);
1034 let app_manifest = root.join("crates/app/Cargo.toml");
1035 fs::create_dir_all(app_manifest.parent().unwrap()).unwrap();
1036 fs::write(
1037 &app_manifest,
1038 "[package]\nname = \"app\"\nversion = \"1.0.0\"\n\n[dependencies]\ncore-alias = { workspace = true }\n\n[dev-dependencies]\nhelper = { workspace = true }\n",
1039 )
1040 .unwrap();
1041
1042 let app = PackageSnapshot {
1043 id: PackageId::new("app-id"),
1044 manifest_name: "app".to_string(),
1045 version: semver::Version::new(1, 0, 0),
1046 version_source: VersionSource::PackageManifest,
1047 ecosystem: EcosystemId::RUST,
1048 path: "crates/app".into(),
1049 publishable: true,
1050 dependencies: vec![],
1051 };
1052 let core = PackageSnapshot {
1053 id: PackageId::new("core-id"),
1054 manifest_name: "core".to_string(),
1055 version: semver::Version::new(1, 0, 0),
1056 version_source: VersionSource::PackageManifest,
1057 ecosystem: EcosystemId::RUST,
1058 path: "crates/core".into(),
1059 publishable: true,
1060 dependencies: vec![],
1061 };
1062 let helper = PackageSnapshot {
1063 id: PackageId::new("helper-id"),
1064 manifest_name: "helper".to_string(),
1065 version: semver::Version::new(2, 0, 0),
1066 version_source: VersionSource::PackageManifest,
1067 ecosystem: EcosystemId::RUST,
1068 path: "crates/helper".into(),
1069 publishable: true,
1070 dependencies: vec![],
1071 };
1072 let versions = VersionMap::from([
1073 (PackageId::new("app-id"), semver::Version::new(1, 0, 1)),
1074 (PackageId::new("core-id"), semver::Version::new(1, 1, 0)),
1075 (PackageId::new("helper-id"), semver::Version::new(2, 1, 0)),
1076 ]);
1077 let workspace = [&app, &core, &helper];
1078
1079 let first =
1080 RustResolver::plan_file_edits(&root, &[&app, &core, &helper], &workspace, &versions)
1081 .unwrap();
1082 let second =
1083 RustResolver::plan_file_edits(&root, &[&helper, &core, &app], &workspace, &versions)
1084 .unwrap();
1085
1086 assert_eq!(first, second);
1087 assert_eq!(
1088 first
1089 .iter()
1090 .filter(|edit| edit.path == "Cargo.toml")
1091 .count(),
1092 1
1093 );
1094 let workspace_edit = first.iter().find(|edit| edit.path == "Cargo.toml").unwrap();
1095 assert!(workspace_edit.new_content.contains(
1096 "core-alias = { package = \"core\", version = \"1.1.0\", path = \"crates/core\" }"
1097 ));
1098 assert!(workspace_edit.new_content.contains("helper = \"2.1.0\""));
1099 assert!(workspace_edit.new_content.contains("external = \"^9\""));
1100 assert!(matches!(
1101 &workspace_edit.source,
1102 EditSource::WorkspaceDependencies { dependencies }
1103 if dependencies
1104 == &[PackageId::new("core-id"), PackageId::new("helper-id")]
1105 ));
1106 let app_edit = first
1107 .iter()
1108 .find(|edit| edit.path == "crates/app/Cargo.toml")
1109 .unwrap();
1110 assert!(
1111 app_edit
1112 .new_content
1113 .contains("core-alias = { workspace = true }")
1114 );
1115 assert!(
1116 app_edit
1117 .new_content
1118 .contains("helper = { workspace = true }")
1119 );
1120 assert!(
1121 fs::read_to_string(root.join("Cargo.toml"))
1122 .unwrap()
1123 .contains("version = \"1.0.0\"")
1124 );
1125 fs::remove_dir_all(root).unwrap();
1126 }
1127
1128 #[test]
1129 fn edits_an_inherited_workspace_version_once_without_rewriting_members() {
1130 let root = temp_dir("inherited-workspace-version-edit");
1131 let workspace_manifest =
1132 "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"1.2.3\"\n";
1133 fs::write(root.join("Cargo.toml"), workspace_manifest).unwrap();
1134 write_inherited_package(&root, "crates/a", "a", None);
1135 write_inherited_package(&root, "crates/b", "b", Some(false));
1136 let source = VersionSource::Shared {
1137 source: VersionSourceId {
1138 manifest: "Cargo.toml".into(),
1139 field: "workspace.package.version".to_string(),
1140 },
1141 };
1142 let package = |id: &str, publishable: bool| PackageSnapshot {
1143 id: PackageId::new(id),
1144 manifest_name: id.to_string(),
1145 version: semver::Version::new(1, 2, 3),
1146 version_source: source.clone(),
1147 ecosystem: EcosystemId::RUST,
1148 path: format!("crates/{id}").into(),
1149 publishable,
1150 dependencies: Vec::new(),
1151 };
1152 let a = package("a", true);
1153 let b = package("b", false);
1154 let versions = VersionMap::from([
1155 (a.id.clone(), semver::Version::new(1, 3, 0)),
1156 (b.id.clone(), semver::Version::new(1, 3, 0)),
1157 ]);
1158
1159 let edits = RustResolver::plan_file_edits(&root, &[&b, &a], &[&a, &b], &versions).unwrap();
1160
1161 assert_eq!(edits.len(), 1);
1162 assert_eq!(edits[0].path, "Cargo.toml");
1163 assert!(edits[0].new_content.contains("version = \"1.3.0\""));
1164 assert!(matches!(
1165 &edits[0].source,
1166 EditSource::WorkspaceManifest {
1167 shared_versions,
1168 dependencies,
1169 } if dependencies.is_empty()
1170 && shared_versions.len() == 1
1171 && shared_versions[0].packages == [PackageId::new("a"), PackageId::new("b")]
1172 ));
1173 assert!(
1174 fs::read_to_string(root.join("crates/a/Cargo.toml"))
1175 .unwrap()
1176 .contains("version.workspace = true")
1177 );
1178 assert_eq!(
1179 fs::read_to_string(root.join("Cargo.toml")).unwrap(),
1180 workspace_manifest
1181 );
1182 fs::remove_dir_all(root).unwrap();
1183 }
1184}