use crate::error::{Result, PublishError};
use crate::workspace::PackageInfo;
use semver::Version;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tokio::time::{sleep, timeout};
#[derive(Debug, Clone)]
pub struct CargoPublisher {
max_retries: usize,
base_retry_delay: Duration,
operation_timeout: Duration,
dry_run_validation: bool,
}
impl Default for CargoPublisher {
fn default() -> Self {
Self {
max_retries: 3,
base_retry_delay: Duration::from_secs(5),
operation_timeout: Duration::from_secs(300), dry_run_validation: true,
}
}
}
#[derive(Debug, Clone)]
pub struct PublishResult {
pub package_name: String,
pub version: Version,
pub duration: Duration,
pub retry_attempts: usize,
pub warnings: Vec<String>,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct YankResult {
pub package_name: String,
pub version: Version,
pub duration: Duration,
pub success: bool,
}
#[derive(Debug, Clone)]
pub struct PublishConfig {
pub registry: Option<String>,
pub dry_run_first: bool,
pub allow_dirty: bool,
pub additional_args: Vec<String>,
pub token: Option<String>,
}
impl Default for PublishConfig {
fn default() -> Self {
Self {
registry: None,
dry_run_first: true,
allow_dirty: false,
additional_args: Vec::new(),
token: None,
}
}
}
impl CargoPublisher {
pub fn new() -> Self {
Self::default()
}
pub fn with_retry_config(
max_retries: usize,
base_delay: Duration,
timeout: Duration,
) -> Self {
Self {
max_retries,
base_retry_delay: base_delay,
operation_timeout: timeout,
dry_run_validation: true,
}
}
pub async fn publish_package(
&self,
package_info: &PackageInfo,
config: &PublishConfig,
) -> Result<PublishResult> {
let start_time = std::time::Instant::now();
let package_name = &package_info.name;
let version = Version::parse(&package_info.version)
.map_err(|e| PublishError::PublishFailed {
package: package_name.clone(),
reason: format!("Invalid version '{}': {}", package_info.version, e),
})?;
let mut warnings = Vec::new();
let mut retry_attempts = 0;
if config.dry_run_first {
self.validate_package_dry_run(package_info, config).await?;
}
let _result = self.retry_with_backoff(
|| self.attempt_publish(package_info, config),
&mut retry_attempts,
&mut warnings,
).await?;
let duration = start_time.elapsed();
Ok(PublishResult {
package_name: package_name.clone(),
version,
duration,
retry_attempts,
warnings,
dry_run: false,
})
}
pub async fn validate_package_dry_run(
&self,
package_info: &PackageInfo,
config: &PublishConfig,
) -> Result<PublishResult> {
let start_time = std::time::Instant::now();
let package_name = &package_info.name;
let version = Version::parse(&package_info.version)
.map_err(|e| PublishError::DryRunFailed {
package: package_name.clone(),
reason: format!("Invalid version '{}': {}", package_info.version, e),
})?;
let mut cmd = self.build_publish_command(package_info, config);
cmd.arg("--dry-run");
let output = timeout(self.operation_timeout, cmd.output()).await
.map_err(|_| PublishError::DryRunFailed {
package: package_name.clone(),
reason: "Dry run timed out".to_string(),
})?
.map_err(|e| PublishError::DryRunFailed {
package: package_name.clone(),
reason: format!("Failed to execute cargo publish: {}", e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PublishError::DryRunFailed {
package: package_name.clone(),
reason: stderr.to_string(),
}.into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let warnings = self.extract_warnings(&stdout);
let duration = start_time.elapsed();
Ok(PublishResult {
package_name: package_name.clone(),
version,
duration,
retry_attempts: 0,
warnings,
dry_run: true,
})
}
pub async fn yank_package(
&self,
package_name: &str,
version: &Version,
config: &PublishConfig,
) -> Result<YankResult> {
let start_time = std::time::Instant::now();
let mut cmd = Command::new("cargo");
cmd.arg("yank")
.arg("--vers")
.arg(version.to_string())
.arg(package_name)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref registry) = config.registry {
cmd.arg("--registry").arg(registry);
}
if let Some(ref token) = config.token {
cmd.arg("--token").arg(token);
}
let output = timeout(self.operation_timeout, cmd.output()).await
.map_err(|_| PublishError::YankFailed {
package: package_name.to_string(),
version: version.to_string(),
reason: "Yank operation timed out".to_string(),
})?
.map_err(|e| PublishError::YankFailed {
package: package_name.to_string(),
version: version.to_string(),
reason: format!("Failed to execute cargo yank: {}", e),
})?;
let success = output.status.success();
if !success {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PublishError::YankFailed {
package: package_name.to_string(),
version: version.to_string(),
reason: stderr.to_string(),
}.into());
}
let duration = start_time.elapsed();
Ok(YankResult {
package_name: package_name.to_string(),
version: version.clone(),
duration,
success,
})
}
async fn attempt_publish(
&self,
package_info: &PackageInfo,
config: &PublishConfig,
) -> Result<()> {
let mut cmd = self.build_publish_command(package_info, config);
let output = timeout(self.operation_timeout, cmd.output()).await
.map_err(|_| PublishError::PublishFailed {
package: package_info.name.clone(),
reason: "Publish operation timed out".to_string(),
})?
.map_err(|e| PublishError::PublishFailed {
package: package_info.name.clone(),
reason: format!("Failed to execute cargo publish: {}", e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("rate limit") || stderr.contains("too many requests") {
return Err(PublishError::RateLimitExceeded {
retry_after_seconds: 60, }.into());
}
if stderr.contains("already published") {
return Err(PublishError::AlreadyPublished {
package: package_info.name.clone(),
version: package_info.version.clone(),
}.into());
}
if stderr.contains("authentication") || stderr.contains("unauthorized") {
return Err(PublishError::AuthenticationError.into());
}
return Err(PublishError::PublishFailed {
package: package_info.name.clone(),
reason: stderr.to_string(),
}.into());
}
Ok(())
}
fn build_publish_command(
&self,
package_info: &PackageInfo,
config: &PublishConfig,
) -> Command {
let mut cmd = Command::new("cargo");
cmd.arg("publish")
.arg("--manifest-path")
.arg(&package_info.cargo_toml_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref registry) = config.registry {
cmd.arg("--registry").arg(registry);
}
if let Some(ref token) = config.token {
cmd.arg("--token").arg(token);
}
if config.allow_dirty {
cmd.arg("--allow-dirty");
}
for arg in &config.additional_args {
cmd.arg(arg);
}
cmd
}
async fn retry_with_backoff<F, Fut>(
&self,
mut operation: F,
retry_attempts: &mut usize,
warnings: &mut Vec<String>,
) -> Result<()>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
let mut delay = self.base_retry_delay;
for attempt in 0..=self.max_retries {
*retry_attempts = attempt;
match operation().await {
Ok(()) => return Ok(()),
Err(e) if attempt == self.max_retries => return Err(e),
Err(e) => {
if !self.is_retryable_error(&e) {
return Err(e);
}
warnings.push(format!(
"Attempt {} failed: {}. Retrying in {:.1}s...",
attempt + 1,
e,
delay.as_secs_f64()
));
sleep(delay).await;
delay = std::cmp::min(delay * 2, Duration::from_secs(300)); }
}
}
unreachable!("Loop should have returned or errored")
}
fn is_retryable_error(&self, error: &crate::error::ReleaseError) -> bool {
match error {
crate::error::ReleaseError::Publish(publish_error) => {
matches!(
publish_error,
PublishError::NetworkError { .. } |
PublishError::RateLimitExceeded { .. }
)
}
_ => false,
}
}
fn extract_warnings(&self, output: &str) -> Vec<String> {
output
.lines()
.filter(|line| line.contains("warning:"))
.map(|line| line.trim().to_string())
.collect()
}
pub async fn is_package_published(
&self,
package_name: &str,
version: &Version,
) -> Result<bool> {
let mut cmd = Command::new("cargo");
cmd.arg("search")
.arg(package_name)
.arg("--limit")
.arg("1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = timeout(Duration::from_secs(30), cmd.output()).await
.map_err(|_| PublishError::NetworkError {
reason: "Search operation timed out".to_string(),
})?
.map_err(|e| PublishError::NetworkError {
reason: format!("Failed to execute cargo search: {}", e),
})?;
if !output.status.success() {
return Ok(false); }
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.starts_with(package_name) && line.contains(&format!("= \"{}\"", version)) {
return Ok(true);
}
}
Ok(false)
}
pub async fn get_published_versions(&self, package_name: &str) -> Result<Vec<Version>> {
let mut cmd = Command::new("cargo");
cmd.arg("search")
.arg(package_name)
.arg("--limit")
.arg("1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = timeout(Duration::from_secs(30), cmd.output()).await
.map_err(|_| PublishError::NetworkError {
reason: "Search operation timed out".to_string(),
})?
.map_err(|e| PublishError::NetworkError {
reason: format!("Failed to execute cargo search: {}", e),
})?;
if !output.status.success() {
return Ok(Vec::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut versions = Vec::new();
for line in stdout.lines() {
if line.starts_with(package_name) {
if let Some(version_part) = line.split("= \"").nth(1) {
if let Some(version_str) = version_part.split('"').next() {
if let Ok(version) = Version::parse(version_str) {
versions.push(version);
}
}
}
}
}
Ok(versions)
}
pub fn set_max_retries(&mut self, max_retries: usize) {
self.max_retries = max_retries;
}
pub fn set_retry_delay(&mut self, delay: Duration) {
self.base_retry_delay = delay;
}
pub fn set_timeout(&mut self, timeout: Duration) {
self.operation_timeout = timeout;
}
pub fn set_dry_run_validation(&mut self, enabled: bool) {
self.dry_run_validation = enabled;
}
}
impl PublishResult {
pub fn is_successful(&self) -> bool {
!self.dry_run }
pub fn summary(&self) -> String {
let status = if self.dry_run { "validated" } else { "published" };
let retry_info = if self.retry_attempts > 0 {
format!(" (after {} retries)", self.retry_attempts)
} else {
String::new()
};
format!(
"Successfully {} {}@{} in {:.2}s{}",
status,
self.package_name,
self.version,
self.duration.as_secs_f64(),
retry_info
)
}
pub fn format_report(&self) -> String {
let mut report = self.summary();
if !self.warnings.is_empty() {
report.push_str("\nWarnings:\n");
for warning in &self.warnings {
report.push_str(&format!(" - {}\n", warning));
}
}
report
}
}
impl YankResult {
pub fn format_result(&self) -> String {
if self.success {
format!(
"✅ Successfully yanked {}@{} in {:.2}s",
self.package_name,
self.version,
self.duration.as_secs_f64()
)
} else {
format!(
"❌ Failed to yank {}@{}",
self.package_name,
self.version
)
}
}
}