cargo-governor 2.0.0

Machine-First, LLM-Ready, CI/CD-Native release automation tool for Rust crates
Documentation
//! Registry operations

use crate::error::Result;
use command_group::CommandGroup;
use governor_core::traits::registry::CratePackage;
use serde_json::json;
use std::time::Duration;
use tokio::time::sleep;

/// Published crate info
#[derive(Debug, Clone, serde::Serialize)]
#[allow(dead_code)] // Used in mod.rs
pub struct PublishedCrate {
    pub name: String,
    pub version: String,
    pub publish_time_ms: u64,
    pub crates_io_url: String,
}

/// Skipped crate info
#[derive(Debug, Clone, serde::Serialize)]
#[allow(dead_code)] // Used in mod.rs
pub struct PublishSkipped {
    pub name: String,
    pub version: String,
    pub reason: String,
}

/// Check if a crate version is already published on crates.io
///
/// Uses crates.io API to check if a specific version exists.
/// This is more reliable than `cargo search` which only shows the latest version.
///
/// # Errors
///
/// Returns an error if curl command fails or network issue occurs.
pub async fn check_if_published(name: &str, version: &str) -> Result<bool> {
    use std::process::Command;

    // Use crates.io API for specific version
    // Format: https://crates.io/api/v1/crates/{crate_name}/{version}
    // This returns 404 if version doesn't exist, or JSON with version info if it does
    let url = format!("https://crates.io/api/v1/crates/{name}/{version}");

    let output = Command::new("curl")
        .args(["-s", "-f", &url])
        .output()
        .map_err(|e| crate::error::Error::Io(format!("Failed to run curl: {e}")))?;

    // If curl returns success (200), the version exists
    // If curl fails (404), the version doesn't exist
    Ok(output.status.success())
}

/// Publish a single crate using cargo publish
///
/// This function uses `cargo publish` which automatically uses the token
/// stored in ~/.cargo/credentials.toml after running `cargo login`.
/// No manual token management is required.
#[allow(dead_code)] // Used in mod.rs
pub async fn publish_crate_with_retries(
    package: &CratePackage,
    max_retries: usize,
) -> Result<Option<String>> {
    let mut last_error = None;

    for attempt in 0..max_retries {
        if attempt > 0 {
            sleep(Duration::from_secs(5)).await;
        }

        match publish_crate(package) {
            Ok(url) => return Ok(Some(url)),
            Err(e) => last_error = Some(e.to_string()),
        }

        if last_error.is_some() && attempt == max_retries - 1 {
            return Err(crate::error::Error::Registry(
                last_error.unwrap_or_default(),
            ));
        }
    }

    Err(crate::error::Error::Registry(
        last_error.unwrap_or_default(),
    ))
}

/// Publish a single crate using cargo publish
#[allow(dead_code)] // Used in mod.rs
fn publish_crate(package: &CratePackage) -> Result<String> {
    let start = std::time::Instant::now();

    if package.dry_run {
        return Ok(format!(
            "https://crates.io/crates/{}/{}",
            package.name, package.version
        ));
    }

    let args = vec!["publish", "--allow-dirty", "-p", &package.name];

    // Note: cargo package already created the .crate file, so we just need to publish
    // cargo publish will automatically find it in target/package/

    let output = std::process::Command::new("cargo")
        .args(&args)
        .current_dir(
            package
                .manifest_path
                .parent()
                .unwrap_or_else(|| std::path::Path::new(".")),
        )
        .group_spawn()
        .map_err(|e| crate::error::Error::Io(format!("Failed to spawn cargo publish: {e}")))?
        .wait_with_output()
        .map_err(|e| crate::error::Error::Io(format!("Failed to wait for cargo publish: {e}")))?;

    let _duration = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);

    if output.status.success() {
        Ok(format!(
            "https://crates.io/crates/{}/{}",
            package.name, package.version
        ))
    } else {
        let stderr = String::from_utf8_lossy(&output.stderr);
        Err(crate::error::Error::Registry(format!(
            "Failed to publish {}: {}",
            package.name, stderr
        )))
    }
}

/// Get registry configuration
#[allow(dead_code)] // Used in mod.rs
pub struct PublishRegistryConfig {
    /// Delay between publishes in seconds
    #[allow(dead_code)]
    pub delay: u64,
    /// Max retries per crate
    #[allow(dead_code)]
    pub max_retries: usize,
}

impl PublishRegistryConfig {
    #[allow(dead_code)]
    pub fn new(delay: u64, max_retries: usize, _token: Option<String>) -> Self {
        // Token parameter is kept for API compatibility but is no longer used.
        // cargo publish automatically uses the token from ~/.cargo/credentials.toml
        // after running `cargo login`.
        Self { delay, max_retries }
    }
}

/// Print publish error response
#[allow(dead_code)]
pub fn print_publish_error(check: &str, message: &str) {
    let response = json!({
        "success": false,
        "command": "publish",
        "error": {
            "code": "PRE_PUBLISH_CHECK_FAILED",
            "category": "test_failed",
            "message": format!("Pre-publish {check} check failed: {}", message),
        }
    });
    println!("{}", serde_json::to_string_pretty(&response).unwrap());
}