use super::{
command_exists, git_worktree_state, parse_version, read_cargo_lock_version_for_package,
read_regex_version_from_content, read_version_from_path, resolve_project_root,
write_cargo_lock_version_for_package, write_version_to_path, GitWorktreeState,
};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::time::{sleep, Duration, Instant};
use toml::Value as TomlValue;
use toml_edit::{value, DocumentMut, Item, Table, Value};
const DEFAULT_METADATA_FILES: &[&str] = &[
"README.md",
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
];
const CONFIG_CANDIDATES: &[&str] = &[".xbp/workspace-release.yaml", ".xbp/workspace-release.yml"];
#[derive(Debug, Clone)]
pub struct WorkspaceVersionCommandOptions {
pub repo: Option<PathBuf>,
pub json: bool,
pub command: WorkspaceVersionCommand,
}
#[derive(Debug, Clone)]
pub enum WorkspaceVersionCommand {
Check(WorkspaceVersionCheckOptions),
Sync(WorkspaceVersionSyncOptions),
Validate(WorkspaceVersionValidateOptions),
PublishPlan,
PublishRun(WorkspacePublishRunOptions),
}
#[derive(Debug, Clone)]
pub struct WorkspaceVersionCheckOptions {
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WorkspaceVersionSyncOptions {
pub version: Option<String>,
pub write: bool,
}
#[derive(Debug, Clone)]
pub struct WorkspaceVersionValidateOptions {
pub package: Option<String>,
pub cargo_check: bool,
pub package_dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct WorkspacePublishRunOptions {
pub dry_run: bool,
pub from: Option<String>,
pub only: Option<String>,
pub continue_on_error: bool,
pub allow_dirty: bool,
pub timeout_seconds: f64,
pub poll_interval_seconds: f64,
}
#[derive(Debug, Default, Clone, Deserialize)]
struct WorkspaceReleaseConfig {
#[serde(default)]
version_coupled_manifests: Vec<String>,
#[serde(default)]
metadata_files: Vec<String>,
#[serde(default)]
publish: WorkspaceReleasePublishConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
struct WorkspaceReleasePublishConfig {
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
order: Vec<String>,
}
#[derive(Debug, Clone)]
struct ReleaseSurface {
repo_root: PathBuf,
config_path: Option<PathBuf>,
config: WorkspaceReleaseConfig,
packages: Vec<ReleasePackage>,
metadata_files: Vec<MetadataVersionFile>,
cargo_lock: Option<PathBuf>,
root_package_name: Option<String>,
}
#[derive(Debug, Clone)]
struct ReleasePackage {
name: String,
manifest_path: PathBuf,
manifest_relative: String,
version: Version,
publishable: bool,
publish_excluded: bool,
dependency_pins: Vec<LocalDependencyPin>,
internal_dependencies: Vec<String>,
}
#[derive(Debug, Clone)]
struct LocalDependencyPin {
field: String,
version: Option<String>,
}
#[derive(Debug, Clone)]
struct MetadataVersionFile {
path: PathBuf,
relative: String,
}
#[derive(Debug, Clone, Serialize)]
struct DriftEntry {
path: String,
field: String,
actual: Option<String>,
expected: String,
}
#[derive(Debug, Clone, Serialize)]
struct SyncEdit {
path: String,
field: String,
before: Option<String>,
after: String,
}
#[derive(Debug, Clone, Serialize)]
struct PublishPlanItem {
package: String,
manifest: String,
version: String,
publishable: bool,
crates_io_visible: Option<bool>,
publish_needed: bool,
blocked_by: Vec<String>,
reason: String,
}
#[derive(Debug, Clone, Serialize)]
struct ValidationCommandResult {
command: String,
success: bool,
exit_code: Option<i32>,
stderr: String,
}
#[derive(Debug, Clone, Serialize)]
struct WorkspaceCheckReport {
repo_root: String,
expected_version: String,
aligned: bool,
drift: Vec<DriftEntry>,
}
#[derive(Debug, Clone, Serialize)]
struct WorkspaceSyncReport {
repo_root: String,
expected_version: String,
write: bool,
changed: bool,
files_changed: Vec<String>,
edits: Vec<SyncEdit>,
}
#[derive(Debug, Clone, Serialize)]
struct WorkspacePublishPlanReport {
repo_root: String,
packages: Vec<PublishPlanItem>,
publish_order: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
struct WorkspaceValidateReport {
repo_root: String,
ok: bool,
issues: Vec<DriftEntry>,
commands: Vec<ValidationCommandResult>,
}
#[derive(Debug, Clone, Serialize)]
struct WorkspacePublishRunReport {
repo_root: String,
dry_run: bool,
published: Vec<String>,
skipped: Vec<String>,
failed: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct CargoMetadata {
packages: Vec<CargoMetadataPackage>,
workspace_members: Vec<String>,
workspace_root: String,
}
#[derive(Debug, Deserialize)]
struct CargoMetadataPackage {
id: String,
manifest_path: String,
}
pub async fn run_version_workspace_command(
options: WorkspaceVersionCommandOptions,
) -> Result<(), String> {
let repo_root = match options.repo {
Some(path) => path,
None => resolve_project_root(),
};
let surface = discover_release_surface(&repo_root)?;
match options.command {
WorkspaceVersionCommand::Check(check) => {
let expected = resolve_expected_version(&surface, check.version.as_deref())?;
let drift = collect_drift(&surface, &expected)?;
let report = WorkspaceCheckReport {
repo_root: display_path(&surface.repo_root),
expected_version: expected.to_string(),
aligned: drift.is_empty(),
drift,
};
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("Failed to serialize JSON report: {}", e))?
);
} else {
print_check_report(&surface, &report);
}
if report.aligned {
Ok(())
} else {
Err("Workspace release drift detected.".to_string())
}
}
WorkspaceVersionCommand::Sync(sync) => {
let expected = resolve_expected_version(&surface, sync.version.as_deref())?;
let (edits, changed_files) = apply_sync(&surface, &expected, sync.write)?;
let report = WorkspaceSyncReport {
repo_root: display_path(&surface.repo_root),
expected_version: expected.to_string(),
write: sync.write,
changed: !edits.is_empty(),
files_changed: changed_files,
edits,
};
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("Failed to serialize JSON report: {}", e))?
);
} else {
print_sync_report(&surface, &report);
}
Ok(())
}
WorkspaceVersionCommand::Validate(validate) => {
let report = run_validation(&surface, &validate)?;
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("Failed to serialize JSON report: {}", e))?
);
} else {
print_validation_report(&surface, &report);
}
if report.ok {
Ok(())
} else {
Err("Workspace validation failed.".to_string())
}
}
WorkspaceVersionCommand::PublishPlan => {
let report = build_publish_plan_report(&surface).await?;
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("Failed to serialize JSON report: {}", e))?
);
} else {
print_publish_plan_report(&surface, &report);
}
Ok(())
}
WorkspaceVersionCommand::PublishRun(run) => {
let report = run_publish(&surface, &run).await?;
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("Failed to serialize JSON report: {}", e))?
);
} else {
print_publish_run_report(&surface, &report);
}
if report.failed.is_empty() {
Ok(())
} else {
Err("Workspace publish run failed.".to_string())
}
}
}
}
fn discover_release_surface(repo_root: &Path) -> Result<ReleaseSurface, String> {
let metadata = load_cargo_metadata(repo_root)?;
let repo_root = PathBuf::from(&metadata.workspace_root);
let config_path = CONFIG_CANDIDATES
.iter()
.map(|candidate| repo_root.join(candidate))
.find(|path| path.exists());
let config = load_workspace_release_config(config_path.as_deref())?;
let workspace_member_ids: BTreeSet<String> = metadata.workspace_members.into_iter().collect();
let mut candidate_manifests = Vec::new();
for package in metadata.packages {
if !workspace_member_ids.contains(&package.id) {
continue;
}
candidate_manifests.push(PathBuf::from(package.manifest_path));
}
for relative in &config.version_coupled_manifests {
let manifest = repo_root.join(relative);
if !candidate_manifests
.iter()
.any(|existing| existing == &manifest)
{
candidate_manifests.push(manifest);
}
}
let mut basic_packages = Vec::new();
for manifest_path in candidate_manifests {
basic_packages.push(read_basic_manifest_info(&manifest_path, &config)?);
}
basic_packages.sort_by(|a, b| a.name.cmp(&b.name));
let release_names: BTreeSet<String> = basic_packages
.iter()
.map(|package| package.name.clone())
.collect();
let mut packages = Vec::new();
for basic in basic_packages {
packages.push(read_release_package(&repo_root, basic, &release_names)?);
}
let root_manifest = repo_root.join("Cargo.toml");
let root_package_name = packages
.iter()
.find(|package| package.manifest_path == root_manifest)
.map(|package| package.name.clone());
let mut seen = BTreeSet::new();
let mut metadata_files = Vec::new();
for relative in DEFAULT_METADATA_FILES
.iter()
.copied()
.chain(config.metadata_files.iter().map(String::as_str))
{
if !seen.insert(relative.to_string()) {
continue;
}
let path = repo_root.join(relative);
if !path.exists() {
continue;
}
if read_metadata_version(&path)?.is_some() {
metadata_files.push(MetadataVersionFile {
relative: normalize_relative(&repo_root, &path),
path,
});
}
}
let cargo_lock = repo_root.join("Cargo.lock");
Ok(ReleaseSurface {
repo_root,
config_path,
config,
packages,
metadata_files,
cargo_lock: cargo_lock.exists().then_some(cargo_lock),
root_package_name,
})
}
#[derive(Debug, Clone)]
struct BasicManifestInfo {
name: String,
manifest_path: PathBuf,
publishable: bool,
publish_excluded: bool,
}
fn read_basic_manifest_info(
manifest_path: &Path,
config: &WorkspaceReleaseConfig,
) -> Result<BasicManifestInfo, String> {
let content = fs::read_to_string(manifest_path)
.map_err(|e| format!("Failed to read {}: {}", manifest_path.display(), e))?;
let value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let package = value
.get("package")
.and_then(TomlValue::as_table)
.ok_or_else(|| format!("Expected [package] in {}", manifest_path.display()))?;
let name = package
.get("name")
.and_then(TomlValue::as_str)
.ok_or_else(|| format!("Missing package.name in {}", manifest_path.display()))?
.to_string();
let publishable = match package.get("publish") {
Some(TomlValue::Boolean(false)) => false,
Some(TomlValue::Array(values)) if values.is_empty() => false,
_ => true,
};
Ok(BasicManifestInfo {
name: name.clone(),
manifest_path: manifest_path.to_path_buf(),
publishable,
publish_excluded: config.publish.exclude.iter().any(|value| value == &name),
})
}
fn read_release_package(
repo_root: &Path,
basic: BasicManifestInfo,
release_names: &BTreeSet<String>,
) -> Result<ReleasePackage, String> {
let content = fs::read_to_string(&basic.manifest_path)
.map_err(|e| format!("Failed to read {}: {}", basic.manifest_path.display(), e))?;
let value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let package = value
.get("package")
.and_then(TomlValue::as_table)
.ok_or_else(|| format!("Expected [package] in {}", basic.manifest_path.display()))?;
let version = package
.get("version")
.and_then(TomlValue::as_str)
.ok_or_else(|| {
format!(
"Missing package.version in {}",
basic.manifest_path.display()
)
})
.and_then(parse_version)?;
let (dependency_pins, internal_dependencies) =
analyze_local_dependencies_from_toml(&value, release_names);
Ok(ReleasePackage {
name: basic.name,
manifest_path: basic.manifest_path.clone(),
manifest_relative: normalize_relative(repo_root, &basic.manifest_path),
version,
publishable: basic.publishable,
publish_excluded: basic.publish_excluded,
dependency_pins,
internal_dependencies,
})
}
fn analyze_local_dependencies_from_toml(
value: &TomlValue,
release_names: &BTreeSet<String>,
) -> (Vec<LocalDependencyPin>, Vec<String>) {
let mut pins = Vec::new();
let mut internal_dependencies = BTreeSet::new();
collect_dependency_pins_from_table(
value,
"",
release_names,
&mut pins,
&mut internal_dependencies,
);
pins.sort_by(|a, b| a.field.cmp(&b.field));
(pins, internal_dependencies.into_iter().collect())
}
fn collect_dependency_pins_from_table(
value: &TomlValue,
prefix: &str,
release_names: &BTreeSet<String>,
pins: &mut Vec<LocalDependencyPin>,
internal_dependencies: &mut BTreeSet<String>,
) {
let Some(table) = value.as_table() else {
return;
};
for (key, entry) in table {
let path = if prefix.is_empty() {
key.to_string()
} else {
format!("{}.{}", prefix, key)
};
if matches!(
key.as_str(),
"dependencies" | "dev-dependencies" | "build-dependencies"
) {
collect_dependency_section(
path.as_str(),
entry,
release_names,
pins,
internal_dependencies,
);
continue;
}
collect_dependency_pins_from_table(
entry,
&path,
release_names,
pins,
internal_dependencies,
);
}
}
fn collect_dependency_section(
section_name: &str,
value: &TomlValue,
release_names: &BTreeSet<String>,
pins: &mut Vec<LocalDependencyPin>,
internal_dependencies: &mut BTreeSet<String>,
) {
let Some(table) = value.as_table() else {
return;
};
for (dependency_name, dependency_value) in table {
if !release_names.contains(dependency_name) {
continue;
}
let Some(detail) = dependency_value.as_table() else {
continue;
};
let uses_workspace = detail
.get("workspace")
.and_then(TomlValue::as_bool)
.unwrap_or(false);
let uses_path = detail.contains_key("path");
if !uses_path && !uses_workspace {
continue;
}
internal_dependencies.insert(dependency_name.clone());
if !uses_path {
continue;
}
pins.push(LocalDependencyPin {
field: format!("{}.{}.version", section_name, dependency_name),
version: detail
.get("version")
.and_then(TomlValue::as_str)
.map(|value| value.to_string()),
});
}
}
fn collect_drift(surface: &ReleaseSurface, expected: &Version) -> Result<Vec<DriftEntry>, String> {
let expected_text = expected.to_string();
let mut drift = Vec::new();
for package in &surface.packages {
if package.version != *expected {
drift.push(DriftEntry {
path: package.manifest_relative.clone(),
field: "package.version".to_string(),
actual: Some(package.version.to_string()),
expected: expected_text.clone(),
});
}
for pin in &package.dependency_pins {
if pin.version.as_deref() != Some(expected_text.as_str()) {
drift.push(DriftEntry {
path: package.manifest_relative.clone(),
field: pin.field.clone(),
actual: pin.version.clone(),
expected: expected_text.clone(),
});
}
}
}
for metadata in &surface.metadata_files {
let actual = read_metadata_version(&metadata.path)?;
if actual.as_deref() != Some(expected_text.as_str()) {
drift.push(DriftEntry {
path: metadata.relative.clone(),
field: "version".to_string(),
actual,
expected: expected_text.clone(),
});
}
}
if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
for package in &surface.packages {
let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
if let Some(actual_version) = actual {
if actual_version != expected_text {
drift.push(DriftEntry {
path: "Cargo.lock".to_string(),
field: format!("package.{}.version", package.name),
actual: Some(actual_version),
expected: expected_text.clone(),
});
}
}
}
}
drift.sort_by(|a, b| a.path.cmp(&b.path).then(a.field.cmp(&b.field)));
Ok(drift)
}
fn apply_sync(
surface: &ReleaseSurface,
expected: &Version,
write: bool,
) -> Result<(Vec<SyncEdit>, Vec<String>), String> {
let release_names: BTreeSet<String> = surface
.packages
.iter()
.map(|package| package.name.clone())
.collect();
let mut edits = Vec::new();
let mut changed_files = BTreeSet::new();
let expected_text = expected.to_string();
for package in &surface.packages {
let file_edits = sync_manifest_versions(package, &release_names, &expected_text, write)?;
if !file_edits.is_empty() {
changed_files.insert(package.manifest_relative.clone());
edits.extend(file_edits);
}
}
for metadata in &surface.metadata_files {
let actual = read_metadata_version(&metadata.path)?;
if actual.as_deref() == Some(expected_text.as_str()) {
continue;
}
if write {
write_metadata_version(&metadata.path, expected)?;
}
changed_files.insert(metadata.relative.clone());
edits.push(SyncEdit {
path: metadata.relative.clone(),
field: "version".to_string(),
before: actual,
after: expected_text.clone(),
});
}
if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
for package in &surface.packages {
let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
if actual.as_deref() == Some(expected_text.as_str()) {
continue;
}
if actual.is_none() {
continue;
}
if write {
write_cargo_lock_version_for_package(cargo_lock, Some(&package.name), expected)?;
}
changed_files.insert("Cargo.lock".to_string());
edits.push(SyncEdit {
path: "Cargo.lock".to_string(),
field: format!("package.{}.version", package.name),
before: actual,
after: expected_text.clone(),
});
}
}
let changed_files = changed_files.into_iter().collect::<Vec<_>>();
Ok((edits, changed_files))
}
fn sync_manifest_versions(
package: &ReleasePackage,
release_names: &BTreeSet<String>,
expected: &str,
write: bool,
) -> Result<Vec<SyncEdit>, String> {
let content = fs::read_to_string(&package.manifest_path)
.map_err(|e| format!("Failed to read {}: {}", package.manifest_path.display(), e))?;
let mut doc = content
.parse::<DocumentMut>()
.map_err(|e| format!("Failed to parse {}: {}", package.manifest_path.display(), e))?;
let mut edits = Vec::new();
let current_package_version = doc["package"]["version"].as_str().map(str::to_string);
if current_package_version.as_deref() != Some(expected) {
edits.push(SyncEdit {
path: package.manifest_relative.clone(),
field: "package.version".to_string(),
before: current_package_version,
after: expected.to_string(),
});
if write {
doc["package"]["version"] = value(expected);
}
}
sync_dependency_tables_in_item(
doc.as_item_mut(),
"",
release_names,
expected,
&package.manifest_relative,
&mut edits,
);
if write && !edits.is_empty() {
fs::write(&package.manifest_path, doc.to_string())
.map_err(|e| format!("Failed to write {}: {}", package.manifest_path.display(), e))?;
}
Ok(edits)
}
fn sync_dependency_tables_in_item(
item: &mut Item,
prefix: &str,
release_names: &BTreeSet<String>,
expected: &str,
manifest_relative: &str,
edits: &mut Vec<SyncEdit>,
) {
let Some(table) = item.as_table_mut() else {
return;
};
let keys = table
.iter()
.map(|(key, _)| key.to_string())
.collect::<Vec<_>>();
for key in keys {
let next_prefix = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
let Some(child) = table.get_mut(&key) else {
continue;
};
if matches!(
key.as_str(),
"dependencies" | "dev-dependencies" | "build-dependencies"
) {
if let Some(dep_table) = child.as_table_mut() {
sync_dependency_entries(
dep_table,
&next_prefix,
release_names,
expected,
manifest_relative,
edits,
);
}
continue;
}
sync_dependency_tables_in_item(
child,
&next_prefix,
release_names,
expected,
manifest_relative,
edits,
);
}
}
fn sync_dependency_entries(
table: &mut Table,
section_name: &str,
release_names: &BTreeSet<String>,
expected: &str,
manifest_relative: &str,
edits: &mut Vec<SyncEdit>,
) {
let keys = table
.iter()
.map(|(key, _)| key.to_string())
.collect::<Vec<_>>();
for dependency_name in keys {
if !release_names.contains(&dependency_name) {
continue;
}
let Some(item) = table.get_mut(&dependency_name) else {
continue;
};
match item {
Item::Value(value_item) => {
let Some(inline) = value_item.as_inline_table_mut() else {
continue;
};
if inline.get("path").is_none() {
continue;
}
let before = inline
.get("version")
.and_then(Value::as_str)
.map(|value| value.to_string());
if before.as_deref() == Some(expected) {
continue;
}
inline.insert("version", Value::from(expected));
edits.push(SyncEdit {
path: manifest_relative.to_string(),
field: format!("{}.{}.version", section_name, dependency_name),
before,
after: expected.to_string(),
});
}
Item::Table(dep_table) => {
if dep_table.get("path").is_none() {
continue;
}
let before = dep_table
.get("version")
.and_then(Item::as_str)
.map(|value| value.to_string());
if before.as_deref() == Some(expected) {
continue;
}
dep_table["version"] = value(expected);
edits.push(SyncEdit {
path: manifest_relative.to_string(),
field: format!("{}.{}.version", section_name, dependency_name),
before,
after: expected.to_string(),
});
}
_ => {}
}
}
}
fn run_validation(
surface: &ReleaseSurface,
options: &WorkspaceVersionValidateOptions,
) -> Result<WorkspaceValidateReport, String> {
let mut issues = match resolve_expected_version(surface, None) {
Ok(expected) => collect_drift(surface, &expected)?,
Err(error) if error.contains("Workspace root has no package.version") => Vec::new(),
Err(error) => return Err(error),
};
if let Some(package_name) = options.package.as_deref() {
issues.retain(|issue| {
issue.path == "Cargo.lock"
|| issue.path.ends_with("README.md")
|| issue.path.ends_with("openapi.yaml")
|| issue.path.ends_with("openapi.yml")
|| issue.path.ends_with("openapi.json")
|| issue.field.contains(package_name)
|| issue.path.contains(package_name)
});
}
let mut commands = Vec::new();
if options.cargo_check {
let mut command = Command::new("cargo");
command
.current_dir(&surface.repo_root)
.arg("check")
.arg("-q");
if let Some(package) = options.package.as_deref() {
command.arg("-p").arg(package);
}
commands.push(run_command_capture(command, "cargo check -q")?);
}
if options.package_dry_run {
let packages = select_packages_for_validation(surface, options.package.as_deref())?;
for package in packages {
let mut command = Command::new("cargo");
command
.current_dir(&surface.repo_root)
.arg("publish")
.arg("--dry-run")
.arg("--locked")
.arg("--manifest-path")
.arg(&package.manifest_path);
commands.push(run_command_capture(
command,
format!(
"cargo publish --dry-run --locked --manifest-path {}",
package.manifest_relative
),
)?);
}
}
let ok = issues.is_empty() && commands.iter().all(|result| result.success);
Ok(WorkspaceValidateReport {
repo_root: display_path(&surface.repo_root),
ok,
issues,
commands,
})
}
fn select_packages_for_validation<'a>(
surface: &'a ReleaseSurface,
package_name: Option<&str>,
) -> Result<Vec<&'a ReleasePackage>, String> {
if let Some(package_name) = package_name {
let package = surface
.packages
.iter()
.find(|package| package.name == package_name)
.ok_or_else(|| format!("Unknown workspace package `{}`.", package_name))?;
return Ok(vec![package]);
}
Ok(surface
.packages
.iter()
.filter(|package| package.publishable && !package.publish_excluded)
.collect())
}
async fn build_publish_plan_report(
surface: &ReleaseSurface,
) -> Result<WorkspacePublishPlanReport, String> {
let visibility = collect_crates_io_visibility(surface).await?;
let (items, publish_order) = build_publish_plan(surface, &visibility, None)?;
Ok(WorkspacePublishPlanReport {
repo_root: display_path(&surface.repo_root),
packages: items,
publish_order,
})
}
async fn run_publish(
surface: &ReleaseSurface,
options: &WorkspacePublishRunOptions,
) -> Result<WorkspacePublishRunReport, String> {
if options.only.is_some() && options.from.is_some() {
return Err("`--only` cannot be combined with `--from`.".to_string());
}
if !options.allow_dirty {
enforce_clean_worktree(&surface.repo_root)?;
}
let validation = run_validation(
surface,
&WorkspaceVersionValidateOptions {
package: options.only.clone(),
cargo_check: false,
package_dry_run: false,
},
)?;
if !validation.ok {
return Err("Workspace has structural publish blockers. Run `xbp version workspace validate` for details.".to_string());
}
let visibility = collect_crates_io_visibility(surface).await?;
let selection = PublishSelection {
from: options.from.clone(),
only: options.only.clone(),
};
let (plan_items, publish_order) = build_publish_plan(surface, &visibility, Some(&selection))?;
let mut item_by_name = BTreeMap::new();
for item in &plan_items {
item_by_name.insert(item.package.clone(), item.clone());
}
let mut published = Vec::new();
let mut skipped = Vec::new();
let mut failed = Vec::new();
for package_name in publish_order {
let Some(item) = item_by_name.get(&package_name) else {
continue;
};
if !item.publish_needed {
skipped.push(format!("{} {}", item.package, item.reason));
continue;
}
if !item.blocked_by.is_empty() {
let message = format!("{} blocked by {}", item.package, item.blocked_by.join(", "));
failed.push(message.clone());
if !options.continue_on_error {
break;
}
continue;
}
let package = surface
.packages
.iter()
.find(|package| package.name == item.package)
.ok_or_else(|| format!("Missing package `{}` in release surface.", item.package))?;
let cargo_publish = format!(
"cargo publish --locked --manifest-path {}{}",
package.manifest_relative,
if options.allow_dirty {
" --allow-dirty"
} else {
""
}
);
println!("{}", cargo_publish);
if options.dry_run {
published.push(format!("{} (dry-run)", item.package));
continue;
}
let status = Command::new("cargo")
.current_dir(&surface.repo_root)
.arg("publish")
.arg("--locked")
.arg("--manifest-path")
.arg(&package.manifest_path)
.args(options.allow_dirty.then_some("--allow-dirty"))
.status()
.map_err(|e| {
format!(
"Failed to execute cargo publish for {}: {}",
item.package, e
)
})?;
if !status.success() {
let message = format!(
"{} publish failed with exit code {:?}",
item.package,
status.code()
);
failed.push(message.clone());
if !options.continue_on_error {
break;
}
continue;
}
wait_for_crates_io_visibility(
&item.package,
&item.version,
options.timeout_seconds,
options.poll_interval_seconds,
)
.await?;
published.push(item.package.clone());
}
Ok(WorkspacePublishRunReport {
repo_root: display_path(&surface.repo_root),
dry_run: options.dry_run,
published,
skipped,
failed,
})
}
#[derive(Debug, Clone)]
struct PublishSelection {
from: Option<String>,
only: Option<String>,
}
fn build_publish_plan(
surface: &ReleaseSurface,
visibility: &BTreeMap<String, bool>,
selection: Option<&PublishSelection>,
) -> Result<(Vec<PublishPlanItem>, Vec<String>), String> {
let ordered_packages = topological_package_order(surface)?;
let selected = resolve_selected_packages(&ordered_packages, selection)?;
let selected_set: BTreeSet<String> = selected.iter().cloned().collect();
let mut available = visibility.clone();
let mut items = Vec::new();
let mut publish_order = Vec::new();
for package_name in ordered_packages {
let package = surface
.packages
.iter()
.find(|package| package.name == package_name)
.ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
let visible = visibility.get(&package.name).copied();
let is_selected = selected_set.contains(&package.name);
let mut publish_needed = false;
let mut blocked_by = Vec::new();
let reason = if !package.publishable {
"publish disabled in package metadata".to_string()
} else if package.publish_excluded {
"publish excluded by workspace release config".to_string()
} else if !is_selected {
"not selected for this publish run".to_string()
} else if visible == Some(true) {
"already visible on crates.io".to_string()
} else {
for dependency in &package.internal_dependencies {
if available.get(dependency).copied().unwrap_or(false) {
continue;
}
blocked_by.push(dependency.clone());
}
if blocked_by.is_empty() {
publish_needed = true;
publish_order.push(package.name.clone());
available.insert(package.name.clone(), true);
"publish required".to_string()
} else {
format!("waiting for {}", blocked_by.join(", "))
}
};
items.push(PublishPlanItem {
package: package.name.clone(),
manifest: package.manifest_relative.clone(),
version: package.version.to_string(),
publishable: package.publishable && !package.publish_excluded,
crates_io_visible: visible,
publish_needed,
blocked_by,
reason,
});
}
Ok((items, publish_order))
}
fn resolve_selected_packages(
ordered_packages: &[String],
selection: Option<&PublishSelection>,
) -> Result<Vec<String>, String> {
let Some(selection) = selection else {
return Ok(ordered_packages.to_vec());
};
if let Some(only) = selection.only.as_deref() {
if !ordered_packages.iter().any(|package| package == only) {
return Err(format!("Unknown package `{}` for `--only`.", only));
}
return Ok(vec![only.to_string()]);
}
if let Some(from) = selection.from.as_deref() {
let start = ordered_packages
.iter()
.position(|package| package == from)
.ok_or_else(|| format!("Unknown package `{}` for `--from`.", from))?;
return Ok(ordered_packages[start..].to_vec());
}
Ok(ordered_packages.to_vec())
}
fn topological_package_order(surface: &ReleaseSurface) -> Result<Vec<String>, String> {
let package_names = surface
.packages
.iter()
.map(|package| package.name.clone())
.collect::<BTreeSet<_>>();
let order_overrides = surface
.config
.publish
.order
.iter()
.enumerate()
.map(|(index, name)| (name.clone(), index))
.collect::<BTreeMap<_, _>>();
let mut indegree = BTreeMap::new();
let mut reverse = BTreeMap::<String, Vec<String>>::new();
for package in &surface.packages {
let deps = package
.internal_dependencies
.iter()
.filter(|name| package_names.contains(*name))
.cloned()
.collect::<Vec<_>>();
indegree.insert(package.name.clone(), deps.len());
for dep in deps {
reverse.entry(dep).or_default().push(package.name.clone());
}
}
let mut queue = indegree
.iter()
.filter_map(|(name, degree)| (*degree == 0).then_some(name.clone()))
.collect::<Vec<_>>();
sort_package_names(&mut queue, &order_overrides);
let mut ordered = Vec::new();
while let Some(name) = queue.first().cloned() {
queue.remove(0);
ordered.push(name.clone());
if let Some(children) = reverse.get(&name) {
for child in children {
if let Some(entry) = indegree.get_mut(child) {
*entry -= 1;
if *entry == 0 {
queue.push(child.clone());
}
}
}
sort_package_names(&mut queue, &order_overrides);
}
}
if ordered.len() != surface.packages.len() {
return Err("Workspace package graph contains a dependency cycle.".to_string());
}
Ok(ordered)
}
fn sort_package_names(names: &mut [String], overrides: &BTreeMap<String, usize>) {
names.sort_by(|a, b| {
overrides
.get(a)
.copied()
.unwrap_or(usize::MAX)
.cmp(&overrides.get(b).copied().unwrap_or(usize::MAX))
.then(a.cmp(b))
});
}
async fn collect_crates_io_visibility(
surface: &ReleaseSurface,
) -> Result<BTreeMap<String, bool>, String> {
let client = crates_io_client()?;
let mut visibility = BTreeMap::new();
for package in &surface.packages {
if !package.publishable || package.publish_excluded {
visibility.insert(package.name.clone(), false);
continue;
}
visibility.insert(
package.name.clone(),
crates_io_has_exact_version(&client, &package.name, &package.version.to_string())
.await?,
);
}
Ok(visibility)
}
async fn crates_io_has_exact_version(
client: &reqwest::Client,
package: &str,
version: &str,
) -> Result<bool, String> {
let url = format!("https://crates.io/api/v1/crates/{}/{}", package, version);
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed crates.io lookup for {} {}: {}", package, version, e))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
if !response.status().is_success() {
return Err(format!(
"crates.io lookup for {} {} returned status {}",
package,
version,
response.status()
));
}
Ok(true)
}
async fn wait_for_crates_io_visibility(
package: &str,
version: &str,
timeout_seconds: f64,
poll_interval_seconds: f64,
) -> Result<(), String> {
let timeout = Duration::from_secs_f64(timeout_seconds.max(1.0));
let poll = Duration::from_secs_f64(poll_interval_seconds.max(0.5));
let deadline = Instant::now() + timeout;
let client = crates_io_client()?;
loop {
if crates_io_has_exact_version(&client, package, version).await? {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"{} {} was published, but did not become visible on crates.io within {:.0}s",
package, version, timeout_seconds
));
}
sleep(poll).await;
}
}
fn crates_io_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent(format!("xbp/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| format!("Failed to build crates.io HTTP client: {}", e))
}
fn enforce_clean_worktree(project_root: &Path) -> Result<(), String> {
let Some(GitWorktreeState { is_dirty, .. }) = git_worktree_state(project_root)? else {
return Ok(());
};
if is_dirty {
return Err(
"Workspace repo has uncommitted changes. Re-run with `--allow-dirty` to override."
.to_string(),
);
}
Ok(())
}
fn resolve_expected_version(
surface: &ReleaseSurface,
explicit: Option<&str>,
) -> Result<Version, String> {
if let Some(explicit) = explicit {
return parse_version(explicit);
}
let root_package_name = surface.root_package_name.as_ref().ok_or_else(|| {
"Workspace root has no package.version; pass `--version` explicitly.".to_string()
})?;
surface
.packages
.iter()
.find(|package| &package.name == root_package_name)
.map(|package| package.version.clone())
.ok_or_else(|| "Could not resolve the root package version.".to_string())
}
fn load_cargo_metadata(repo_root: &Path) -> Result<CargoMetadata, String> {
if !command_exists("cargo") {
return Err("`cargo` is required to inspect workspace metadata.".to_string());
}
let output = Command::new("cargo")
.current_dir(repo_root)
.args(["metadata", "--format-version", "1", "--no-deps"])
.output()
.map_err(|e| format!("Failed to run `cargo metadata`: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("`cargo metadata` failed: {}", stderr));
}
serde_json::from_slice::<CargoMetadata>(&output.stdout)
.map_err(|e| format!("Failed to parse cargo metadata JSON: {}", e))
}
fn load_workspace_release_config(path: Option<&Path>) -> Result<WorkspaceReleaseConfig, String> {
let Some(path) = path else {
return Ok(WorkspaceReleaseConfig::default());
};
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
}
fn run_command_capture(
mut command: Command,
label: impl Into<String>,
) -> Result<ValidationCommandResult, String> {
let label = label.into();
let output = command
.output()
.map_err(|e| format!("Failed to run `{}`: {}", label, e))?;
Ok(ValidationCommandResult {
command: label,
success: output.status.success(),
exit_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
})
}
fn read_metadata_version(path: &Path) -> Result<Option<String>, String> {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
match file_name {
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
read_regex_version_from_content(&content, r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
}
_ => read_version_from_path(path),
}
}
fn write_metadata_version(path: &Path, version: &Version) -> Result<(), String> {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
match file_name {
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let regex = regex::Regex::new(r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
.map_err(|e| format!("Failed to build OpenAPI regex: {}", e))?;
let updated = regex
.replace(&content, format!(" version: {}", version))
.to_string();
fs::write(path, updated)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))
}
_ => write_version_to_path(path, version).map(|_| ()),
}
}
fn normalize_relative(repo_root: &Path, path: &Path) -> String {
path.strip_prefix(repo_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn display_path(path: &Path) -> String {
path.to_string_lossy().to_string()
}
fn print_check_report(surface: &ReleaseSurface, report: &WorkspaceCheckReport) {
println!("Workspace version check");
println!("Repo: {}", surface.repo_root.display());
println!("Expected version: {}", report.expected_version);
if let Some(config_path) = &surface.config_path {
println!(
"Config: {}",
normalize_relative(&surface.repo_root, config_path)
);
}
println!(
"Status: {}",
if report.aligned {
"aligned"
} else {
"drift detected"
}
);
for entry in &report.drift {
println!(
"{} {} actual={} expected={}",
entry.path,
entry.field,
entry.actual.as_deref().unwrap_or("<missing>"),
entry.expected
);
}
}
fn print_sync_report(surface: &ReleaseSurface, report: &WorkspaceSyncReport) {
println!(
"Workspace version {}",
if report.write { "sync" } else { "sync preview" }
);
println!("Repo: {}", surface.repo_root.display());
println!("Expected version: {}", report.expected_version);
if report.edits.is_empty() {
println!("No changes needed.");
return;
}
println!("Files: {}", report.files_changed.join(", "));
for edit in &report.edits {
println!(
"{} {} {} -> {}",
edit.path,
edit.field,
edit.before.as_deref().unwrap_or("<missing>"),
edit.after
);
}
}
fn print_validation_report(surface: &ReleaseSurface, report: &WorkspaceValidateReport) {
println!("Workspace validation");
println!("Repo: {}", surface.repo_root.display());
println!("Status: {}", if report.ok { "ok" } else { "failed" });
for issue in &report.issues {
println!(
"{} {} actual={} expected={}",
issue.path,
issue.field,
issue.actual.as_deref().unwrap_or("<missing>"),
issue.expected
);
}
for command in &report.commands {
println!(
"{} [{}]",
command.command,
if command.success { "ok" } else { "failed" }
);
if !command.stderr.is_empty() {
println!("{}", command.stderr);
}
}
}
fn print_publish_plan_report(surface: &ReleaseSurface, report: &WorkspacePublishPlanReport) {
println!("Workspace publish plan");
println!("Repo: {}", surface.repo_root.display());
println!(
"Order: {}",
if report.publish_order.is_empty() {
"<none>".to_string()
} else {
report.publish_order.join(", ")
}
);
for item in &report.packages {
println!(
"{} {} visible={} needed={} reason={}",
item.package,
item.version,
item.crates_io_visible
.map(|value| value.to_string())
.unwrap_or_else(|| "n/a".to_string()),
item.publish_needed,
item.reason
);
if !item.blocked_by.is_empty() {
println!(" blocked by {}", item.blocked_by.join(", "));
}
}
}
fn print_publish_run_report(surface: &ReleaseSurface, report: &WorkspacePublishRunReport) {
println!("Workspace publish run");
println!("Repo: {}", surface.repo_root.display());
println!("Dry run: {}", report.dry_run);
if !report.published.is_empty() {
println!("Published: {}", report.published.join(", "));
}
if !report.skipped.is_empty() {
println!("Skipped: {}", report.skipped.join("; "));
}
if !report.failed.is_empty() {
println!("Failed: {}", report.failed.join("; "));
}
}
#[cfg(test)]
mod tests {
use super::{
apply_sync, build_publish_plan, collect_drift, discover_release_surface, PublishSelection,
};
use semver::Version;
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-workspace-release-{}-{}", name, nanos));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn write_file(path: &PathBuf, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(path, content).expect("write file");
}
fn create_demo_workspace() -> PathBuf {
let root = temp_dir("demo");
write_file(
&root.join("Cargo.toml"),
r#"[package]
name = "athena_rs"
version = "3.16.4"
[dependencies.alpha]
path = "crates/alpha"
version = "3.16.4"
[dependencies.beta]
path = "crates/beta"
version = "3.16.4"
[dependencies.athena-s3]
path = "crates/athena-s3"
version = "3.16.4"
[workspace]
members = ["crates/alpha", "crates/beta", "crates/athena-s3"]
resolver = "2"
"#,
);
write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
write_file(
&root.join("README.md"),
"# Athena\n\ncurrent version: `3.16.4`\n",
);
write_file(
&root.join("openapi.yaml"),
"openapi: 3.1.0\ninfo:\n title: Athena\n version: 3.16.4\n",
);
write_file(
&root.join("Cargo.lock"),
r#"version = 4
[[package]]
name = "athena_rs"
version = "3.16.4"
[[package]]
name = "alpha"
version = "3.16.4"
[[package]]
name = "beta"
version = "3.16.4"
[[package]]
name = "athena-s3"
version = "3.16.4"
"#,
);
write_file(
&root.join("crates/alpha/Cargo.toml"),
r#"[package]
name = "alpha"
version = "3.16.4"
[dependencies]
beta = { path = "../beta", version = "3.16.4" }
athena-s3 = { path = "../athena-s3", version = "3.16.4" }
"#,
);
write_file(&root.join("crates/alpha/src/lib.rs"), "pub fn alpha() {}\n");
write_file(
&root.join("crates/beta/Cargo.toml"),
r#"[package]
name = "beta"
version = "3.16.4"
"#,
);
write_file(&root.join("crates/beta/src/lib.rs"), "pub fn beta() {}\n");
write_file(
&root.join("crates/athena-s3/Cargo.toml"),
r#"[package]
name = "athena-s3"
version = "3.16.4"
"#,
);
write_file(
&root.join("crates/athena-s3/src/lib.rs"),
"pub fn athena_s3() {}\n",
);
write_file(
&root.join("crates/athena-backups/Cargo.toml"),
r#"[package]
name = "athena-backups"
version = "3.16.0"
[dependencies]
beta = { path = "../beta", version = "3.16.0" }
"#,
);
write_file(
&root.join("crates/athena-backups/src/lib.rs"),
"pub fn athena_backups() {}\n",
);
root
}
fn create_workspace_dependency_demo_workspace() -> PathBuf {
let root = temp_dir("workspace-deps");
write_file(
&root.join("Cargo.toml"),
r#"[package]
name = "xbp"
version = "10.27.0"
[dependencies]
xbp-providers.workspace = true
[workspace]
members = ["crates/http", "crates/providers"]
resolver = "2"
[workspace.dependencies]
xbp-http = { path = "crates/http", version = "0.1.0" }
xbp-providers = { path = "crates/providers", version = "0.1.0" }
"#,
);
write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
write_file(
&root.join("crates/http/Cargo.toml"),
r#"[package]
name = "xbp-http"
version = "0.1.0"
"#,
);
write_file(&root.join("crates/http/src/lib.rs"), "pub fn http() {}\n");
write_file(
&root.join("crates/providers/Cargo.toml"),
r#"[package]
name = "xbp-providers"
version = "0.1.0"
[dependencies]
xbp-http.workspace = true
"#,
);
write_file(
&root.join("crates/providers/src/lib.rs"),
"pub fn providers() {}\n",
);
root
}
#[test]
fn discovery_uses_workspace_members_and_excludes_non_member_crates() {
let root = create_demo_workspace();
let surface = discover_release_surface(&root).expect("discover");
let names = surface
.packages
.iter()
.map(|package| package.name.clone())
.collect::<Vec<_>>();
assert!(names.contains(&"athena-s3".to_string()));
assert!(!names.contains(&"athena-backups".to_string()));
}
#[test]
fn drift_reports_package_dependency_metadata_and_lock_mismatches() {
let root = create_demo_workspace();
write_file(
&root.join("crates/alpha/Cargo.toml"),
r#"[package]
name = "alpha"
version = "3.16.5"
[dependencies]
beta = { path = "../beta", version = "3.16.4" }
athena-s3 = { path = "../athena-s3" }
"#,
);
let surface = discover_release_surface(&root).expect("discover");
let expected = Version::new(3, 16, 4);
let drift = collect_drift(&surface, &expected).expect("drift");
assert!(drift.iter().any(
|entry| entry.path == "crates/alpha/Cargo.toml" && entry.field == "package.version"
));
assert!(
drift
.iter()
.any(|entry| entry.field == "dependencies.athena-s3.version"
&& entry.actual.is_none())
);
}
#[test]
fn sync_preview_and_write_updates_workspace_surface() {
let root = create_demo_workspace();
let surface = discover_release_surface(&root).expect("discover");
let expected = Version::new(3, 16, 5);
let (preview, _) = apply_sync(&surface, &expected, false).expect("preview");
assert!(!preview.is_empty());
let (written, files) = apply_sync(&surface, &expected, true).expect("write");
assert!(!written.is_empty());
assert!(files.contains(&"Cargo.toml".to_string()));
let updated = fs::read_to_string(root.join("crates/alpha/Cargo.toml")).expect("read");
assert!(updated.contains("version = \"3.16.5\""));
assert!(updated.contains("athena-s3 = { path = \"../athena-s3\", version = \"3.16.5\" }"));
}
#[test]
fn publish_plan_orders_dependencies_before_dependents() {
let root = create_demo_workspace();
let surface = discover_release_surface(&root).expect("discover");
let mut visibility = BTreeMap::new();
visibility.insert("athena_rs".to_string(), false);
visibility.insert("alpha".to_string(), false);
visibility.insert("beta".to_string(), true);
visibility.insert("athena-s3".to_string(), false);
let (items, order) = build_publish_plan(
&surface,
&visibility,
Some(&PublishSelection {
from: None,
only: None,
}),
)
.expect("plan");
let alpha_pos = order
.iter()
.position(|name| name == "alpha")
.expect("alpha");
let s3_pos = order
.iter()
.position(|name| name == "athena-s3")
.expect("s3");
assert!(s3_pos < alpha_pos);
assert!(items
.iter()
.any(|item| item.package == "athena_rs" && item.publish_needed));
}
#[test]
fn publish_plan_orders_workspace_dependencies_before_dependents() {
let root = create_workspace_dependency_demo_workspace();
let surface = discover_release_surface(&root).expect("discover");
let mut visibility = BTreeMap::new();
visibility.insert("xbp".to_string(), false);
visibility.insert("xbp-http".to_string(), false);
visibility.insert("xbp-providers".to_string(), false);
let (_, order) = build_publish_plan(
&surface,
&visibility,
Some(&PublishSelection {
from: None,
only: None,
}),
)
.expect("plan");
let xbp_pos = order.iter().position(|name| name == "xbp").expect("xbp");
let providers_pos = order
.iter()
.position(|name| name == "xbp-providers")
.expect("providers");
let http_pos = order
.iter()
.position(|name| name == "xbp-http")
.expect("http");
assert!(http_pos < providers_pos);
assert!(providers_pos < xbp_pos);
}
}