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};
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 read_publish_order_from_config(&self) -> Option<Vec<String>> {
let cargo_toml_path = PathBuf::from(&self.workspace_path).join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_toml_path).ok()?;
let value: toml::Value = toml::from_str(&content).ok()?;
value
.get("workspace")
.and_then(|w| w.get("metadata"))
.and_then(|m| m.get("governor"))
.and_then(|g| g.get("publish-order"))
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
}
fn get_packages_to_publish(
&self,
metadata: &cargo_metadata::Metadata,
) -> Vec<(String, String)> {
use std::collections::HashSet;
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();
}
let ordered_names = if let Some(explicit_order) = self.read_publish_order_from_config() {
explicit_order
.into_iter()
.filter(|name| publishable.contains(name))
.collect::<Vec<_>>()
} else {
self.topological_sort(&publishable, metadata)
};
ordered_names
.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 topological_sort(
&self,
publishable: &std::collections::HashSet<String>,
metadata: &cargo_metadata::Metadata,
) -> Vec<String> {
use std::collections::HashMap;
let mut dependencies: HashMap<String, Vec<String>> = HashMap::new();
for pkg in metadata.workspace_packages() {
let pkg_name = pkg.name.as_str();
if !publishable.contains(pkg_name) {
continue;
}
for dep in &pkg.dependencies {
let dep_name = dep.name.as_str();
if dep.path.is_some() && publishable.contains(dep_name) {
dependencies
.entry(pkg_name.to_string())
.or_default()
.push(dep_name.to_string());
}
}
}
let mut result = Vec::new();
let mut in_degree: HashMap<String, usize> = HashMap::new();
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;
}
}
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());
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());
}
}
}
}
}
result
}
fn should_publish_package(&self, pkg: &cargo_metadata::Package) -> bool {
if pkg.publish.as_ref().is_some_and(std::vec::Vec::is_empty) {
return false;
}
if let Some(ref exclude) = self.opts.exclude
&& exclude
.split(',')
.any(|name| name.trim() == pkg.name.as_str())
{
return false;
}
if let Some(ref only) = self.opts.only {
only.split(',').any(|name| name.trim() == pkg.name.as_str())
} else {
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 {
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;
}
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(),
);
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(), manifest_path,
token: String::new(), 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) => {
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()
}
}