Skip to main content

semifold_resolver/resolver/
rust.rs

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    /// Plans package and shared workspace manifest replacements as one deterministic batch.
106    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}