use std::path::{Path, PathBuf};
use anyhow::Context;
use async_trait::async_trait;
use semver::Version;
use serde::Deserialize;
use log::warn;
use super::{PackageManagerAdapter, ProjectInfo, PublishOutcome};
use crate::model::config::CargoConfig;
use crate::path::AbsolutePath;
#[derive(Debug)]
pub struct CargoAdapter {
config: CargoConfig,
adapter_root: AbsolutePath,
env: crate::Env,
}
impl CargoAdapter {
pub fn new(config: CargoConfig, adapter_root: AbsolutePath, env: crate::Env) -> Self {
Self {
config,
adapter_root,
env,
}
}
async fn resolve_root(&self) -> anyhow::Result<AbsolutePath> {
self.config
.resolve_root(&self.adapter_root, self.env.fs())
.await
}
async fn write_workspace_package_version(
&self,
version: &Version,
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>> {
let pm_root = self.resolve_root().await?;
let root_path = pm_root.child("Cargo.toml");
log::debug!(
"Updating workspace root version at {} to {version}",
root_path.display()
);
let contents = self
.env
.fs()
.read_to_string(&root_path)
.await
.with_context(|| format!("Failed to read {}", root_path.display()))?;
let mut doc = contents
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", root_path.display()))?;
let ws_package = doc
.get_mut("workspace")
.and_then(|ws| ws.get_mut("package"))
.and_then(|p| p.as_table_like_mut())
.context("No [workspace.package] table in workspace root Cargo.toml")?;
ws_package.insert("version", toml_edit::value(version.to_string()));
if !dry_run {
self.env
.fs()
.write(&root_path, doc.to_string().as_bytes())
.await
.with_context(|| format!("Failed to write {}", root_path.display()))?;
}
Ok(vec![root_path.into_path_buf()])
}
}
#[derive(Debug, Deserialize)]
struct CargoToml {
package: Option<Package>,
workspace: Option<Workspace>,
dependencies: Option<std::collections::HashMap<String, toml::Value>>,
#[serde(rename = "dev-dependencies")]
dev_dependencies: Option<std::collections::HashMap<String, toml::Value>>,
#[serde(rename = "build-dependencies")]
build_dependencies: Option<std::collections::HashMap<String, toml::Value>>,
}
#[derive(Debug, Deserialize)]
struct Package {
name: String,
version: Option<VersionField>,
publish: Option<PublishField>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum VersionField {
Literal(String),
Workspace { workspace: bool },
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PublishField {
Bool(bool),
Registries(Vec<String>),
}
#[derive(Debug, Deserialize)]
struct Workspace {
members: Option<Vec<String>>,
package: Option<WorkspacePackage>,
}
impl CargoToml {
fn workspace_version(&self) -> Option<&str> {
self.workspace
.as_ref()
.and_then(|ws| ws.package.as_ref())
.and_then(|pkg| pkg.version.as_deref())
}
}
#[derive(Debug, Deserialize)]
struct WorkspacePackage {
version: Option<String>,
}
async fn read_cargo_toml(
dir: &AbsolutePath,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<Option<CargoToml>> {
let path = dir.child("Cargo.toml");
if !fs.exists(&path).await? {
return Ok(None);
}
let contents = fs
.read_to_string(&path)
.await
.with_context(|| format!("Failed to read {}", path.display()))?;
let cargo: CargoToml =
toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(Some(cargo))
}
fn extract_project_metadata(
cargo: &CargoToml,
package: &Package,
workspace_version: Option<&str>,
) -> anyhow::Result<(Version, bool, bool, Vec<String>)> {
let inherits_workspace = matches!(
&package.version,
Some(VersionField::Workspace { workspace: true })
);
let version_str: &str = match &package.version {
Some(VersionField::Literal(v)) => v,
Some(VersionField::Workspace { workspace: true }) => workspace_version.context(
"version.workspace = true but no [workspace.package].version found in workspace root",
)?,
Some(VersionField::Workspace { workspace: false }) | None => {
anyhow::bail!("Missing version in package section");
}
};
let version = version_str
.parse::<Version>()
.with_context(|| format!("Invalid semver version: {version_str}"))?;
let publishable = match &package.publish {
Some(PublishField::Bool(false)) => false,
Some(PublishField::Registries(registries)) if registries.is_empty() => false,
_ => true,
};
let mut dependency_names = Vec::new();
for deps_map in [
&cargo.dependencies,
&cargo.dev_dependencies,
&cargo.build_dependencies,
]
.into_iter()
.flatten()
{
dependency_names.extend(deps_map.keys().cloned());
}
Ok((version, inherits_workspace, publishable, dependency_names))
}
async fn read_workspace_member(
member_path: &AbsolutePath,
fs: &dyn crate::filesystem::Filesystem,
workspace_version: Option<&str>,
) -> anyhow::Result<Option<ProjectInfo>> {
if !fs.is_dir(member_path).await? {
return Ok(None);
}
let Some(cargo) = read_cargo_toml(member_path, fs).await? else {
return Ok(None);
};
let Some(ref package) = cargo.package else {
return Ok(None);
};
let path = member_path.clone();
let manifest_path = member_path.child("Cargo.toml");
let (version, inherits_workspace, publishable, dependency_names) =
extract_project_metadata(&cargo, package, workspace_version).with_context(|| {
format!(
"Failed to extract metadata from {}",
manifest_path.display()
)
})?;
Ok(Some(ProjectInfo {
name: package.name.clone(),
path,
version,
publishable,
dependency_names,
publishconfig_provenance: None,
workspace_version: inherits_workspace,
}))
}
async fn expand_member_pattern(
pm_root: &AbsolutePath,
pattern: &str,
fs: &dyn crate::filesystem::Filesystem,
workspace_version: Option<&str>,
) -> anyhow::Result<Vec<ProjectInfo>> {
let paths = pm_root.safe_glob(pattern, fs).await?;
let mut projects = Vec::new();
for member_path in paths {
if let Some(info) = read_workspace_member(&member_path, fs, workspace_version).await? {
projects.push(info);
}
}
Ok(projects)
}
fn update_dep_item_version(item: &mut toml_edit::Item, new_version: &str) -> bool {
if let Some(table) = item.as_table_like_mut() {
let Some(old_version) = table.get("version").and_then(|v| v.as_str()) else {
return false;
};
let prefix = super::semver_range_prefix(old_version).to_string();
table.insert(
"version",
toml_edit::value(format!("{prefix}{new_version}")),
);
true
} else if let Some(old_str) = item.as_str() {
let prefix = super::semver_range_prefix(old_str).to_string();
*item = toml_edit::value(format!("{prefix}{new_version}"));
true
} else {
false
}
}
async fn update_workspace_dep(
workspace_toml_path: &AbsolutePath,
dependency_name: &str,
new_version: &str,
dry_run: bool,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<bool> {
if !fs.exists(workspace_toml_path).await? {
return Ok(false);
}
let contents = fs
.read_to_string(workspace_toml_path)
.await
.with_context(|| format!("Failed to read {}", workspace_toml_path.display()))?;
let mut doc = contents
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", workspace_toml_path.display()))?;
let workspace_dep = doc
.get_mut("workspace")
.and_then(|ws| ws.get_mut("dependencies"))
.and_then(|deps| deps.get_mut(dependency_name));
if let Some(dep_item) = workspace_dep
&& update_dep_item_version(dep_item, new_version)
{
if !dry_run {
fs.write(workspace_toml_path, doc.to_string().as_bytes())
.await
.with_context(|| format!("Failed to write {}", workspace_toml_path.display()))?;
}
return Ok(true);
}
Ok(false)
}
async fn update_member_dep(
member_toml_path: &AbsolutePath,
dependency_name: &str,
new_version: &str,
dry_run: bool,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<bool> {
if !fs.exists(member_toml_path).await? {
return Ok(false);
}
let contents = fs
.read_to_string(member_toml_path)
.await
.with_context(|| format!("Failed to read {}", member_toml_path.display()))?;
let mut doc = contents
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", member_toml_path.display()))?;
let mut changed = false;
for section_name in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(dep_item) = doc
.get_mut(section_name)
.and_then(|s| s.get_mut(dependency_name))
else {
continue;
};
if dep_item.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
continue;
}
if update_dep_item_version(dep_item, new_version) {
changed = true;
}
}
if changed && !dry_run {
fs.write(member_toml_path, doc.to_string().as_bytes())
.await
.with_context(|| format!("Failed to write {}", member_toml_path.display()))?;
}
Ok(changed)
}
fn build_cargo_root_project_info(
root_cargo: &CargoToml,
package: &Package,
pm_root: &AbsolutePath,
root_manifest_path: &Path,
) -> anyhow::Result<ProjectInfo> {
let (version, inherits_workspace, publishable, dependency_names) =
extract_project_metadata(root_cargo, package, root_cargo.workspace_version())
.with_context(|| {
format!(
"Failed to extract metadata from {}",
root_manifest_path.display()
)
})?;
Ok(ProjectInfo {
name: package.name.clone(),
path: pm_root.clone(),
version,
publishable,
dependency_names,
publishconfig_provenance: None,
workspace_version: inherits_workspace,
})
}
#[async_trait]
impl PackageManagerAdapter for CargoAdapter {
async fn write_version(
&self,
project: &ProjectInfo,
version: &Version,
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>> {
let manifest_path = project.path.child("Cargo.toml");
let contents = self
.env
.fs()
.read_to_string(&manifest_path)
.await
.with_context(|| format!("Failed to read {}", manifest_path.display()))?;
let mut doc = contents
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", manifest_path.display()))?;
let package = doc
.get_mut("package")
.and_then(|p| p.as_table_like_mut())
.with_context(|| format!("No [package] table in {}", manifest_path.display()))?;
let inherits_workspace = package
.get("version")
.and_then(|v| v.as_table_like())
.and_then(|t| t.get("workspace"))
.and_then(|v| v.as_bool())
== Some(true);
if inherits_workspace {
return self.write_workspace_package_version(version, dry_run).await;
}
package.insert("version", toml_edit::value(version.to_string()));
if !dry_run {
self.env
.fs()
.write(&manifest_path, doc.to_string().as_bytes())
.await
.with_context(|| format!("Failed to write {}", manifest_path.display()))?;
}
Ok(vec![manifest_path.into_path_buf()])
}
async fn enumerate_projects(&self) -> anyhow::Result<Vec<ProjectInfo>> {
let pm_root = self.resolve_root().await?;
let Some(root_cargo) = read_cargo_toml(&pm_root, self.env.fs()).await? else {
return Ok(Vec::new());
};
let root_manifest_path = pm_root.join("Cargo.toml");
let workspace_members = root_cargo
.workspace
.as_ref()
.and_then(|ws| ws.members.as_ref())
.filter(|members| !members.is_empty());
let Some(members) = workspace_members else {
let Some(ref package) = root_cargo.package else {
return Ok(Vec::new());
};
let info =
build_cargo_root_project_info(&root_cargo, package, &pm_root, &root_manifest_path)?;
return Ok(vec![info]);
};
let ws_version = root_cargo.workspace_version();
let mut projects = Vec::new();
for pattern in members {
let member_projects =
expand_member_pattern(&pm_root, pattern, self.env.fs(), ws_version).await?;
projects.extend(member_projects);
}
if let Some(ref package) = root_cargo.package {
let info =
build_cargo_root_project_info(&root_cargo, package, &pm_root, &root_manifest_path)?;
projects.insert(0, info);
}
projects.sort_by(|a, b| a.path.cmp(&b.path));
Ok(projects)
}
async fn update_lock_file(&self) -> anyhow::Result<Option<std::path::PathBuf>> {
let workspace_root = self.resolve_root().await?;
let lock_path = workspace_root.join("Cargo.lock");
let output = self
.env
.run_mut("cargo", &["update", "--workspace"], &workspace_root)
.await
.with_context(|| {
format!(
"Failed to execute cargo update --workspace in {}",
workspace_root.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"cargo update --workspace failed in {}: {}",
workspace_root.display(),
stderr
);
}
Ok(Some(lock_path))
}
async fn publish(&self, project: &ProjectInfo) -> anyhow::Result<PublishOutcome> {
if !self.env.cargo_registry_token_present() {
if self.env.oidc_environment() {
warn!(
"{}: CARGO_REGISTRY_TOKEN is not set; publish is likely to fail. An \
OIDC-capable CI environment was detected - to use crates.io trusted \
publishing, add a token exchange step (such as \
rust-lang/crates-io-auth-action) before `cursus publish`.",
project.name
);
} else {
warn!(
"{}: CARGO_REGISTRY_TOKEN is not set; publish is likely to fail. Set \
CARGO_REGISTRY_TOKEN or run `cargo login` to configure authentication.",
project.name
);
}
}
let manifest_path = project.path.join("Cargo.toml");
let manifest_str = manifest_path.to_string_lossy();
let output = self
.env
.run_mut(
"cargo",
&["publish", "--manifest-path", &manifest_str],
&self.adapter_root,
)
.await
.with_context(|| {
format!(
"Failed to execute cargo publish for {}",
manifest_path.display()
)
})?;
if output.status.success() {
return Ok(PublishOutcome::Published);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("is already uploaded") || stderr.contains("already exists") {
return Ok(PublishOutcome::AlreadyPublished);
}
anyhow::bail!(
"cargo publish failed for {}: {}",
manifest_path.display(),
stderr
);
}
async fn registry_name(&self) -> &str {
"crates.io"
}
async fn manifest_filename(&self) -> &str {
"Cargo.toml"
}
async fn update_dependency_version(
&self,
project: &ProjectInfo,
dependency_name: &str,
new_version: &Version,
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>> {
let pm_root = self.resolve_root().await?;
let version_str = new_version.to_string();
let mut modified = Vec::new();
let fs = self.env.fs();
let workspace_toml_path = pm_root.child("Cargo.toml");
if update_workspace_dep(
&workspace_toml_path,
dependency_name,
&version_str,
dry_run,
fs,
)
.await?
{
modified.push(workspace_toml_path.clone().into_path_buf());
}
let member_toml_path = project.path.child("Cargo.toml");
if *member_toml_path != *workspace_toml_path
&& update_member_dep(
&member_toml_path,
dependency_name,
&version_str,
dry_run,
fs,
)
.await?
{
modified.push(member_toml_path.into_path_buf());
}
Ok(modified)
}
}
#[cfg(test)]
mod tests;