use super::*;
async fn discover_packages(
workspace_manifest: &Path,
) -> Result<(Vec<Package>, bool), PublishError> {
let content: String = read_to_string(workspace_manifest).await?;
let doc: Value = toml::from_str(&content).map_err(|_| PublishError::ManifestParseError)?;
let workspace_version: Option<String> = doc
.get("workspace")
.and_then(|workspace: &Value| workspace.get("package"))
.and_then(|package: &Value| package.get("version"))
.and_then(|version: &Value| version.as_str())
.map(|version: &str| version.to_string());
let mut packages: Vec<Package> = Vec::new();
if let Some(workspace) = doc.get("workspace")
&& let Some(members) = workspace
.get("members")
.and_then(|members_value: &Value| members_value.as_array())
{
for member in members {
if let Some(pattern) = member.as_str() {
let base_path: &Path = workspace_manifest.parent().unwrap_or(workspace_manifest);
expand_pattern(
base_path,
pattern,
&mut packages,
workspace_version.as_deref(),
)
.await?;
}
}
}
let has_root_package: bool = doc.get("package").is_some();
if has_root_package {
let root_package: Package =
read_package_manifest(workspace_manifest, workspace_version.as_deref()).await?;
packages.push(root_package);
}
Ok((packages, has_root_package))
}
async fn expand_pattern(
base_path: &Path,
pattern: &str,
packages: &mut Vec<Package>,
workspace_version: Option<&str>,
) -> Result<(), PublishError> {
if pattern.contains('*') {
let parent: &Path = Path::new(pattern).parent().unwrap_or(Path::new("."));
let full_parent: PathBuf = base_path.join(parent);
if full_parent.is_dir() {
let mut entries: ReadDir = read_dir(&full_parent).await?;
while let Some(entry) = entries.next_entry().await? {
let path: PathBuf = entry.path();
if path.is_dir() {
let cargo_toml: PathBuf = path.join("Cargo.toml");
if cargo_toml.exists() {
let package: Package =
read_package_manifest(&cargo_toml, workspace_version).await?;
packages.push(package);
}
}
}
}
} else {
let cargo_toml: PathBuf = base_path.join(pattern).join("Cargo.toml");
if cargo_toml.exists() {
let package: Package = read_package_manifest(&cargo_toml, workspace_version).await?;
packages.push(package);
}
}
Ok(())
}
async fn read_package_manifest(
manifest_path: &Path,
workspace_version: Option<&str>,
) -> Result<Package, PublishError> {
let content: String = read_to_string(manifest_path).await?;
let doc: Value = toml::from_str(&content).map_err(|_| PublishError::ManifestParseError)?;
let package_table: &Value = doc.get("package").ok_or(PublishError::ManifestParseError)?;
let name: String = package_table
.get("name")
.and_then(|n: &Value| n.as_str())
.ok_or(PublishError::ManifestParseError)?
.to_string();
let version: String = match package_table.get("version") {
Some(version_value) => {
if let Some(version_str) = version_value.as_str() {
version_str.to_string()
} else if version_value
.get("workspace")
.and_then(|workspace_value: &Value| workspace_value.as_bool())
.unwrap_or(false)
{
workspace_version
.ok_or(PublishError::ManifestParseError)?
.to_string()
} else {
return Err(PublishError::ManifestParseError);
}
}
None => workspace_version
.ok_or(PublishError::ManifestParseError)?
.to_string(),
};
let publish: bool = package_table
.get("publish")
.and_then(|publish_value: &Value| publish_value.as_bool())
.unwrap_or(true);
let path: PathBuf = manifest_path
.parent()
.filter(|p: &&Path| !p.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), |p: &Path| p.to_path_buf());
let local_dependencies: Vec<String> = extract_local_dependencies(&doc, manifest_path)?;
Ok(Package {
name,
version,
path,
local_dependencies,
publish,
})
}
fn extract_local_dependencies(
doc: &Value,
_manifest_path: &Path,
) -> Result<Vec<String>, PublishError> {
let mut deps: Vec<String> = Vec::new();
let dep_sections: [&str; 3] = ["dependencies", "build-dependencies", "dev-dependencies"];
for section in &dep_sections {
if let Some(table) = doc
.get(section)
.and_then(|section_value: &Value| section_value.as_table())
{
for (dep_name, dep_value) in table {
let is_local: bool = match dep_value {
Value::Table(t) => {
let has_path_or_workspace: bool = t.get("path").is_some()
|| t.get("workspace")
.and_then(|workspace_value: &Value| workspace_value.as_bool())
.unwrap_or(false);
let versioned: bool = t.get("version").is_some();
has_path_or_workspace && (*section != "dev-dependencies" || versioned)
}
_ => false,
};
if is_local {
deps.push(dep_name.clone());
}
}
}
}
Ok(deps)
}
fn validate_publish_order(packages: &[Package]) -> Result<(), PublishError> {
let position: HashMap<String, usize> = packages
.iter()
.enumerate()
.map(|(index, package): (usize, &Package)| (package.name.clone(), index))
.collect();
for package in packages {
let Some(package_position) = position.get(&package.name) else {
continue;
};
for dep in &package.local_dependencies {
if let Some(dep_position) = position.get(dep)
&& dep_position > package_position
{
return Err(PublishError::InvalidPublishOrder(format!(
"{} depends on {} but is listed before it in [workspace.members]",
package.name, dep
)));
}
}
}
Ok(())
}
fn position_root_package(packages: &mut Vec<Package>) -> Result<(), PublishError> {
let Some(root) = packages.pop() else {
return Ok(());
};
let earliest_dependent: Option<usize> = packages
.iter()
.enumerate()
.filter(|(_, package)| package.local_dependencies.contains(&root.name))
.map(|(index, _)| index)
.min();
let Some(earliest) = earliest_dependent else {
packages.push(root);
return Ok(());
};
let member_positions: HashMap<&str, usize> = packages
.iter()
.enumerate()
.map(|(index, package)| (package.name.as_str(), index))
.collect();
if let Some(max_dep) = root
.local_dependencies
.iter()
.filter_map(|dep| member_positions.get(dep.as_str()))
.max()
&& max_dep >= &earliest
{
return Err(PublishError::InvalidPublishOrder(format!(
"{} must publish after its dependency at members position {} but before dependent at position {}; reorder [workspace.members]",
root.name, max_dep, earliest
)));
}
packages.insert(earliest, root);
Ok(())
}
pub async fn resolve_publish_order(manifest_path: &str) -> Result<Vec<Package>, PublishError> {
let workspace_manifest: &Path = Path::new(manifest_path);
let (mut packages, has_root_package) = discover_packages(workspace_manifest).await?;
if has_root_package {
position_root_package(&mut packages)?;
}
validate_publish_order(&packages)?;
Ok(packages)
}
pub fn is_already_published(stderr: &str) -> bool {
stderr.contains("already been uploaded")
|| stderr.contains("is already published")
|| stderr.contains("already exists on crates.io index")
}
async fn publish_package_with_retry(package: &Package, max_retries: u32) -> PublishResult {
let mut attempt: u32 = 0;
let mut last_error: Option<String> = None;
while attempt <= max_retries {
match publish_single_package(package).await {
Ok(()) => {
return PublishResult {
package_name: package.name.clone(),
success: true,
error: None,
retries: attempt,
};
}
Err(error) => {
last_error = Some(error.to_string());
attempt += 1;
if attempt <= max_retries {
sleep(Duration::from_secs(2_u64.pow(attempt))).await;
}
}
}
}
PublishResult {
package_name: package.name.clone(),
success: false,
error: last_error,
retries: attempt - 1,
}
}
async fn publish_single_package(package: &Package) -> Result<(), Box<dyn std::error::Error>> {
let output: std::process::Output = Command::new("cargo")
.arg("publish")
.arg("--allow-dirty")
.arg("--no-verify")
.current_dir(&package.path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
if output.status.success() {
return Ok(());
}
let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
if is_already_published(&stderr) {
log::info!("{} is already published, treating as success", package.name);
return Ok(());
}
Err(stderr.into())
}
pub async fn execute_publish(
manifest_path: &str,
max_retries: u32,
) -> Result<Vec<PublishResult>, PublishError> {
let path: &Path = Path::new(manifest_path);
let path: &Path = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
let workspace_manifest: PathBuf = path.join("Cargo.toml");
let sync_report: SyncReport =
match execute_sync(workspace_manifest.to_str().unwrap_or("Cargo.toml")).await {
Ok(report) => report,
Err(error) => return Err(PublishError::SyncFailed(error)),
};
if sync_report.file_changed {
log::info!(
"publish: synced workspace dependencies ({} renamed, {} versioned) to v{}",
sync_report.renamed_entries.len(),
sync_report.versioned_entries.len(),
sync_report.workspace_version,
);
}
let ordered_packages: Vec<Package> =
resolve_publish_order(workspace_manifest.to_str().unwrap_or("Cargo.toml")).await?;
if ordered_packages.is_empty() {
return Ok(Vec::new());
}
let mut results: Vec<PublishResult> = Vec::new();
for package in ordered_packages {
if !package.publish {
log::info!("Skipping {} (publish = false)", package.name);
continue;
}
log::info!("Publishing {} v{}...", package.name, package.version);
let result: PublishResult = publish_package_with_retry(&package, max_retries).await;
if result.success {
if result.retries == 0 {
log::info!("Successfully published {}", result.package_name,);
} else {
log::info!(
"Successfully published {} (retried {} times)",
result.package_name,
result.retries
);
}
} else if let Some(error) = &result.error {
log::error!("Failed to publish {}: {error}", result.package_name);
} else {
log::error!("Failed to publish {}", result.package_name);
}
results.push(result);
}
Ok(results)
}