cargo-governor 1.2.0

Machine-First, LLM-Ready, CI/CD-Native release automation tool for Rust crates
Documentation
//! Publish service - business logic for crate publishing

pub mod checks;
pub mod registry;

pub use registry::{PublishRegistryConfig, PublishSkipped, PublishedCrate};

use crate::cli::{OutputFormat, PublishOpts};
use crate::error::{CommandExitCode, Result};
use cargo_metadata::MetadataCommand;
use governor_core::domain::version::SemanticVersion;
use governor_core::traits::registry::CratePackage;
use serde_json::json;
use std::path::PathBuf;
use tokio::time::{Duration, sleep};

/// Service for publishing crates
pub struct PublishService {
    workspace_path: String,
    opts: PublishOpts,
}

impl PublishService {
    pub const fn new(workspace_path: String, opts: PublishOpts) -> Self {
        Self {
            workspace_path,
            opts,
        }
    }

    pub async fn execute(&self, _format: OutputFormat) -> Result<CommandExitCode> {
        let start_time = std::time::Instant::now();
        let dry_run = Self::is_dry_run();

        let metadata = self.parse_metadata()?;
        let packages = self.get_packages_to_publish(&metadata);

        if packages.is_empty() {
            self.print_empty_response();
            return Ok(CommandExitCode::Success);
        }

        let (delay, max_retries, _registry_config) = self.get_registry_config();
        let checks_to_skip = self.get_checks_to_skip();

        if !self
            .run_pre_publish_checks(&checks_to_skip, dry_run)
            .await?
        {
            return Ok(CommandExitCode::CheckFailed);
        }

        let (published, skipped, failed) = self
            .publish_packages(&packages, dry_run, max_retries, delay)
            .await?;

        let failed_count = failed.len();
        Self::print_response(&metadata, published, skipped, failed, dry_run, start_time)?;

        if failed_count == 0 {
            Ok(CommandExitCode::Success)
        } else {
            Ok(CommandExitCode::PartialSuccess)
        }
    }

    fn parse_metadata(&self) -> Result<cargo_metadata::Metadata> {
        MetadataCommand::new()
            .current_dir(PathBuf::from(&self.workspace_path))
            .no_deps()
            .exec()
            .map_err(|e| crate::error::Error::Config(format!("Failed to read cargo metadata: {e}")))
    }

    fn get_packages_to_publish(
        &self,
        metadata: &cargo_metadata::Metadata,
    ) -> Vec<(String, String)> {
        use std::collections::{HashMap, HashSet};

        // Filter packages that should be published
        let publishable: HashSet<String> = metadata
            .workspace_packages()
            .iter()
            .filter(|p| self.should_publish_package(p))
            .map(|p| p.name.as_str().to_string())
            .collect();

        if publishable.is_empty() {
            return Vec::new();
        }

        // Build dependency graph for publishable packages only
        let mut dependencies: HashMap<String, Vec<String>> = HashMap::new();

        for pkg in metadata.workspace_packages() {
            let pkg_name = pkg.name.as_str();

            // Only consider packages that are in our publishable set
            if !publishable.contains(pkg_name) {
                continue;
            }

            // Find workspace dependencies
            for dep in &pkg.dependencies {
                let dep_name = dep.name.as_str();
                // Only track internal workspace dependencies that are also publishable
                if dep.path.is_some() && publishable.contains(dep_name) {
                    dependencies
                        .entry(pkg_name.to_string())
                        .or_default()
                        .push(dep_name.to_string());
                }
            }
        }

        // Topological sort (Kahn's algorithm)
        let mut result = Vec::new();
        let mut in_degree: HashMap<String, usize> = HashMap::new();

        // Calculate in-degrees
        for pkg in &publishable {
            in_degree.insert(pkg.clone(), 0);
        }
        for deps in dependencies.values() {
            for dep in deps {
                *in_degree.entry(dep.clone()).or_insert(0) += 1;
            }
        }

        // Start with packages that have no dependencies
        let mut queue: Vec<String> = publishable
            .iter()
            .filter(|p| in_degree.get(*p).copied().unwrap_or(0) == 0)
            .cloned()
            .collect();

        while let Some(pkg) = queue.pop() {
            result.push(pkg.clone());

            // Reduce in-degree for dependent packages
            if let Some(deps) = dependencies.get(&pkg) {
                for dep in deps {
                    if let Some(degree) = in_degree.get_mut(dep) {
                        *degree = degree.saturating_sub(1);
                        if *degree == 0 {
                            queue.push(dep.clone());
                        }
                    }
                }
            }
        }

        // Convert result to (name, version) tuples
        result
            .into_iter()
            .filter_map(|name| {
                metadata
                    .workspace_packages()
                    .iter()
                    .find(|p| p.name.as_str() == name)
                    .map(|p| {
                        let name = p.name.as_str().to_string();
                        let version = p.version.to_string();
                        (name, version)
                    })
            })
            .collect()
    }

    fn should_publish_package(&self, pkg: &cargo_metadata::Package) -> bool {
        // Skip packages that explicitly disable publishing (publish = false in Cargo.toml)
        // When publish = false, pkg.publish is Some(Vec::new()) or None depending on version
        // When publish = ["registry"], pkg.publish is Some(vec!["registry"])
        if pkg.publish.as_ref().is_some_and(std::vec::Vec::is_empty) {
            return false;
        }

        // Skip if explicitly excluded
        if let Some(ref exclude) = self.opts.exclude
            && exclude
                .split(',')
                .any(|name| name.trim() == pkg.name.as_str())
        {
            return false;
        }

        // Only publish if explicitly specified
        if let Some(ref only) = self.opts.only {
            only.split(',').any(|name| name.trim() == pkg.name.as_str())
        } else {
            // By default, publish all packages (libraries and binaries)
            // Topological sort will ensure correct order
            true
        }
    }

    fn get_registry_config(&self) -> (u64, usize, PublishRegistryConfig) {
        use crate::services::release::publish::registry::PublishRegistryConfig;

        let delay = self.opts.delay.unwrap_or(10);
        let max_retries = self.opts.max_retries.unwrap_or(3);
        let config = PublishRegistryConfig::new(delay, max_retries, None);
        (delay, max_retries, config)
    }

    fn get_checks_to_skip(&self) -> Vec<String> {
        self.opts
            .skip_checks
            .as_ref()
            .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
            .unwrap_or_default()
    }

    async fn run_pre_publish_checks(
        &self,
        checks_to_skip: &[String],
        dry_run: bool,
    ) -> Result<bool> {
        use crate::services::release::publish::checks::run_check;
        use crate::services::release::publish::registry::print_publish_error;

        let default_checks = vec!["test".to_string(), "clippy".to_string(), "fmt".to_string()];
        let checks_to_run: Vec<String> = if checks_to_skip.is_empty() {
            default_checks
        } else {
            default_checks
                .into_iter()
                .filter(|c| !checks_to_skip.contains(c))
                .collect()
        };

        for check in &checks_to_run {
            if check == "test" && !dry_run {
                match run_check(&self.workspace_path, check) {
                    Ok(()) => {}
                    Err(e) => {
                        print_publish_error(check, &e.to_string());
                        return Ok(false);
                    }
                }
            }
        }
        Ok(true)
    }

    async fn publish_packages(
        &self,
        packages: &[(String, String)],
        dry_run: bool,
        max_retries: usize,
        delay: u64,
    ) -> Result<(
        Vec<PublishedCrate>,
        Vec<PublishSkipped>,
        Vec<(String, String, String)>,
    )> {
        use crate::services::release::publish::registry::{
            PublishSkipped, PublishedCrate, check_if_published, publish_crate_with_retries,
        };

        let mut published = Vec::new();
        let mut skipped = Vec::new();
        let mut failed = Vec::new();

        for (name, version) in packages {
            // Check if already published using cargo search
            if check_if_published(name, version).await.unwrap_or(false) {
                skipped.push(PublishSkipped {
                    name: name.clone(),
                    version: version.clone(),
                    reason: "already_published".to_string(),
                });
                continue;
            }

            // Find the correct manifest path for this crate
            let metadata = self.parse_metadata()?;
            let manifest_path = metadata
                .workspace_packages()
                .iter()
                .find(|p| p.name.as_str() == name)
                .map_or_else(
                    || std::path::PathBuf::from(&self.workspace_path).join("Cargo.toml"),
                    |p| p.manifest_path.as_std_path().to_path_buf(),
                );

            // Create package without pre-built crate file - cargo publish will handle it
            let package = CratePackage {
                name: name.clone(),
                version: SemanticVersion::parse(version).map_err(|e| {
                    crate::error::Error::Version(format!("Failed to parse version {version}: {e}"))
                })?,
                crate_file: std::path::PathBuf::new(), // Empty - cargo publish will package
                manifest_path,
                token: String::new(), // Not used - cargo uses credentials from ~/.cargo/
                dry_run,
            };

            match publish_crate_with_retries(&package, max_retries).await {
                Ok(Some(url)) => {
                    published.push(PublishedCrate {
                        name: name.clone(),
                        version: version.clone(),
                        publish_time_ms: 0,
                        crates_io_url: url,
                    });
                    if !dry_run {
                        sleep(Duration::from_secs(delay)).await;
                    }
                }
                Ok(None) => {
                    // Dry run or similar
                    published.push(PublishedCrate {
                        name: name.clone(),
                        version: version.clone(),
                        publish_time_ms: 0,
                        crates_io_url: format!("https://crates.io/crates/{name}/{version}"),
                    });
                }
                Err(e) => {
                    failed.push((name.clone(), version.clone(), e.to_string()));
                }
            }
        }

        Ok((published, skipped, failed))
    }

    fn print_empty_response(&self) {
        let response = json!({
            "success": true,
            "command": "publish",
            "result": {
                "message": "No crates to publish",
                "crates_published": [],
                "crates_skipped": ["No packages matched criteria".to_string()],
            }
        });
        println!("{}", serde_json::to_string_pretty(&response).unwrap());
    }

    fn print_response(
        metadata: &cargo_metadata::Metadata,
        published: Vec<PublishedCrate>,
        skipped: Vec<PublishSkipped>,
        failed: Vec<(String, String, String)>,
        dry_run: bool,
        start_time: std::time::Instant,
    ) -> Result<()> {
        let workspace_name = metadata
            .workspace_root
            .file_name()
            .map_or_else(|| "workspace".to_string(), std::string::ToString::to_string);

        let response = json!({
            "success": failed.is_empty(),
            "command": "publish",
            "workspace": workspace_name,
            "result": {
                "crates_published": published,
                "crates_skipped": skipped,
                "crates_failed": failed,
                "total_published": published.len(),
                "dry_run": dry_run,
            },
            "metrics": {
                "execution_time_ms": start_time.elapsed().as_millis(),
                "git_operations": 0,
                "api_calls": published.len() + skipped.len(),
            }
        });

        println!("{}", serde_json::to_string_pretty(&response).unwrap());
        Ok(())
    }

    fn is_dry_run() -> bool {
        std::env::var("CARGO_GOVERNOR_DRY_RUN")
            .or_else(|_| std::env::var("DRY_RUN"))
            .is_ok()
    }
}