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 crate::utils::resolve_cargo_package_version;
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 std::thread;
use std::time::Duration as StdDuration;
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(WorkspacePublishPlanOptions),
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 WorkspacePublishPlanOptions {
pub only: Option<String>,
pub include_prereqs: bool,
}
#[derive(Debug, Clone)]
pub struct WorkspacePublishRunOptions {
pub dry_run: bool,
pub from: Option<String>,
pub only: Option<String>,
pub include_prereqs: bool,
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>,
publish_internal_dependencies: Vec<String>,
publish_missing_version_pins: 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)]
pub(crate) struct PublishPlanItem {
pub package: String,
pub manifest: String,
pub version: String,
pub publishable: bool,
pub crates_io_visible: Option<bool>,
pub publish_needed: bool,
pub blocked_by: Vec<String>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
struct ValidationCommandResult {
command: String,
success: bool,
exit_code: Option<i32>,
stderr: String,
#[serde(skip_serializing_if = "Option::is_none")]
warning: Option<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,
requested_package: Option<String>,
included_prereqs: Vec<String>,
required_closure: Vec<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,
requested_package: Option<String>,
included_prereqs: Vec<String>,
required_closure: Vec<String>,
published: Vec<String>,
skipped: Vec<String>,
failed: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct WorkspacePublishCommandTarget {
pub package: String,
pub version: String,
pub manifest_path: PathBuf,
pub manifest_relative: String,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ManifestWorkspacePublishResolution {
pub workspace_root: PathBuf,
pub requested_package: String,
pub included_prereqs: Vec<String>,
pub required_closure: Vec<String>,
pub packages: Vec<PublishPlanItem>,
pub publish_order: Vec<WorkspacePublishCommandTarget>,
}
#[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(plan) => {
let report = build_publish_plan_report(&surface, &plan).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)?;
discover_release_surface_from_metadata(metadata)
}
fn discover_release_surface_from_manifest(manifest_path: &Path) -> Result<ReleaseSurface, String> {
let metadata = load_cargo_metadata_for_manifest(manifest_path)?;
discover_release_surface_from_metadata(metadata)
}
fn discover_release_surface_from_metadata(
metadata: CargoMetadata,
) -> Result<ReleaseSurface, String> {
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 version = resolve_cargo_package_version(&basic.manifest_path)?
.ok_or_else(|| {
format!(
"Missing package.version in {}",
basic.manifest_path.display()
)
})
.and_then(|value| parse_version(&value))?;
let dependency_analysis = 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: dependency_analysis.dependency_pins,
publish_internal_dependencies: dependency_analysis.publish_internal_dependencies,
publish_missing_version_pins: dependency_analysis.publish_missing_version_pins,
})
}
#[derive(Debug, Default)]
struct DependencyAnalysis {
dependency_pins: Vec<LocalDependencyPin>,
publish_internal_dependencies: Vec<String>,
publish_missing_version_pins: Vec<String>,
}
fn analyze_local_dependencies_from_toml(
value: &TomlValue,
release_names: &BTreeSet<String>,
) -> DependencyAnalysis {
let mut pins = Vec::new();
let mut publish_internal_dependencies = BTreeSet::new();
let mut publish_missing_version_pins = Vec::new();
collect_dependency_pins_from_table(
value,
"",
release_names,
&mut pins,
&mut publish_internal_dependencies,
&mut publish_missing_version_pins,
);
pins.sort_by(|a, b| a.field.cmp(&b.field));
publish_missing_version_pins.sort();
DependencyAnalysis {
dependency_pins: pins,
publish_internal_dependencies: publish_internal_dependencies.into_iter().collect(),
publish_missing_version_pins,
}
}
fn collect_dependency_pins_from_table(
value: &TomlValue,
prefix: &str,
release_names: &BTreeSet<String>,
pins: &mut Vec<LocalDependencyPin>,
publish_internal_dependencies: &mut BTreeSet<String>,
publish_missing_version_pins: &mut Vec<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"
) {
let publish_relevant = !matches!(key.as_str(), "dev-dependencies");
collect_dependency_section(
path.as_str(),
entry,
release_names,
publish_relevant,
pins,
publish_internal_dependencies,
publish_missing_version_pins,
);
continue;
}
collect_dependency_pins_from_table(
entry,
&path,
release_names,
pins,
publish_internal_dependencies,
publish_missing_version_pins,
);
}
}
fn collect_dependency_section(
section_name: &str,
value: &TomlValue,
release_names: &BTreeSet<String>,
publish_relevant: bool,
pins: &mut Vec<LocalDependencyPin>,
publish_internal_dependencies: &mut BTreeSet<String>,
publish_missing_version_pins: &mut Vec<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;
}
if publish_relevant {
publish_internal_dependencies.insert(dependency_name.clone());
}
if !uses_path {
continue;
}
let field = format!("{}.{}.version", section_name, dependency_name);
let version = detail
.get("version")
.and_then(TomlValue::as_str)
.map(|value| value.to_string());
if publish_relevant && version.is_none() {
publish_missing_version_pins.push(field.clone());
}
pins.push(LocalDependencyPin { field, version });
}
}
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 {
commands.extend(run_package_dry_run_validation(surface, options.package.as_deref())?);
}
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())
}
fn select_ordered_packages_for_validation<'a>(
surface: &'a ReleaseSurface,
package_name: Option<&str>,
) -> Result<Vec<&'a ReleasePackage>, String> {
let selected = select_packages_for_validation(surface, package_name)?;
let selected_names: BTreeSet<String> = selected
.iter()
.map(|package| package.name.clone())
.collect();
let ordered_names = topological_package_order(surface)?;
Ok(ordered_names
.into_iter()
.filter(|name| selected_names.contains(name))
.filter_map(|name| selected.iter().find(|package| package.name == name).copied())
.collect())
}
fn run_package_dry_run_validation(
surface: &ReleaseSurface,
package_name: Option<&str>,
) -> Result<Vec<ValidationCommandResult>, String> {
let packages = select_ordered_packages_for_validation(surface, package_name)?;
let mut results = Vec::new();
for package in packages {
let label = format!(
"cargo publish --dry-run --locked --no-verify --manifest-path {}",
package.manifest_relative
);
let repo_root = surface.repo_root.clone();
let manifest_path = package.manifest_path.clone();
let mut result = run_publish_dry_run_capture(&repo_root, &manifest_path, &label)?;
if !result.success {
if is_unpublished_workspace_dependency_error(&result.stderr) {
let fallback_label = format!(
"cargo package --allow-dirty --no-verify --manifest-path {}",
package.manifest_relative
);
let fallback_result =
run_package_capture(&repo_root, &manifest_path, &fallback_label)?;
if fallback_result.success {
result = ValidationCommandResult {
command: fallback_label,
success: true,
exit_code: fallback_result.exit_code,
stderr: fallback_result.stderr,
warning: Some(format!(
"Registry dry-run skipped for `{}`: workspace dependencies are not on crates.io yet. Local packaging succeeded.",
package.name
)),
};
} else {
result.stderr = append_validation_hint(&result.stderr);
}
} else {
result.stderr = append_validation_hint(&result.stderr);
}
}
results.push(result);
}
Ok(results)
}
fn is_unpublished_workspace_dependency_error(stderr: &str) -> bool {
stderr.contains("no matching package named")
|| stderr.contains("failed to select a version for the requirement")
}
fn is_transient_registry_error(stderr: &str) -> bool {
stderr.contains("Could not resolve host")
|| stderr.contains("Could not resolve hostname")
|| stderr.contains("failed to update registry")
|| stderr.contains("download of config.json failed")
}
fn append_validation_hint(stderr: &str) -> String {
let mut message = stderr.to_string();
if stderr.contains("Access is denied") || stderr.contains("os error 5") {
message.push_str(
"\nHint: close any running `xbp` processes before package dry-run validation on Windows.",
);
}
if is_transient_registry_error(stderr) {
message.push_str(
"\nHint: crates.io was unreachable. Check network connectivity and retry.",
);
}
if is_unpublished_workspace_dependency_error(stderr) {
message.push_str(
"\nHint: publish workspace dependencies first, or rely on the local `cargo package` fallback.",
);
}
message
}
fn run_publish_dry_run_capture(
repo_root: &Path,
manifest_path: &Path,
label: &str,
) -> Result<ValidationCommandResult, String> {
let build = || {
let mut command = Command::new("cargo");
command
.current_dir(repo_root)
.arg("publish")
.arg("--dry-run")
.arg("--locked")
.arg("--no-verify")
.arg("--manifest-path")
.arg(manifest_path);
command
};
run_command_capture_with_retry(build, label)
}
fn run_package_capture(
repo_root: &Path,
manifest_path: &Path,
label: &str,
) -> Result<ValidationCommandResult, String> {
let build = || {
let mut command = Command::new("cargo");
command
.current_dir(repo_root)
.arg("package")
.arg("--allow-dirty")
.arg("--no-verify")
.arg("--manifest-path")
.arg(manifest_path);
command
};
run_command_capture_with_retry(build, label)
}
fn run_command_capture_with_retry(
build_command: impl Fn() -> Command,
label: &str,
) -> Result<ValidationCommandResult, String> {
let first = run_command_capture(build_command(), label)?;
if first.success || !is_transient_registry_error(&first.stderr) {
return Ok(first);
}
thread::sleep(StdDuration::from_secs(2));
let mut retry = run_command_capture(build_command(), format!("{label} (retry)"))?;
if !retry.success {
retry.stderr = append_validation_hint(&retry.stderr);
}
Ok(retry)
}
async fn build_publish_plan_report(
surface: &ReleaseSurface,
options: &WorkspacePublishPlanOptions,
) -> Result<WorkspacePublishPlanReport, String> {
let visibility = collect_crates_io_visibility(surface).await?;
let plan = build_publish_plan(
surface,
&visibility,
Some(&PublishSelection {
from: None,
only: options.only.clone(),
include_prereqs: options.include_prereqs,
}),
)?;
Ok(WorkspacePublishPlanReport {
repo_root: display_path(&surface.repo_root),
requested_package: plan.requested_package,
included_prereqs: plan.included_prereqs,
required_closure: plan.required_closure,
packages: plan.items,
publish_order: plan.publish_order,
})
}
#[derive(Debug, Clone)]
pub(crate) struct WorkspaceVersionDriftSummary {
pub expected_version: Version,
pub drift_count: usize,
pub preview: Vec<String>,
}
pub(crate) fn inspect_workspace_version_drift(
repo_root: &Path,
expected_version: &Version,
) -> Result<Option<WorkspaceVersionDriftSummary>, String> {
let surface = match discover_release_surface(repo_root) {
Ok(surface) => surface,
Err(_) => return Ok(None),
};
if surface.packages.len() <= 1 && surface.config_path.is_none() {
return Ok(None);
}
let drift = collect_drift(&surface, expected_version)?;
if drift.is_empty() {
return Ok(None);
}
let preview = drift
.iter()
.take(6)
.map(|entry| {
format!(
"{} {}: {} -> {}",
entry.path,
entry.field,
entry.actual.as_deref().unwrap_or("<missing>"),
entry.expected
)
})
.collect();
Ok(Some(WorkspaceVersionDriftSummary {
expected_version: expected_version.clone(),
drift_count: drift.len(),
preview,
}))
}
pub(crate) fn sync_workspace_to_version(
repo_root: &Path,
expected_version: &Version,
) -> Result<Vec<String>, String> {
let surface = discover_release_surface(repo_root)?;
let (_edits, changed_files) = apply_sync(&surface, expected_version, true)?;
Ok(changed_files)
}
pub(crate) async fn resolve_manifest_workspace_publish(
manifest_path: &Path,
include_prereqs: bool,
) -> Result<ManifestWorkspacePublishResolution, String> {
let surface = discover_release_surface_from_manifest(manifest_path)?;
let package = surface
.packages
.iter()
.find(|package| same_path(&package.manifest_path, manifest_path))
.ok_or_else(|| {
format!(
"Manifest {} is not a publishable package in the resolved Cargo workspace.",
manifest_path.display()
)
})?;
let visibility = collect_crates_io_visibility(&surface).await?;
let plan = build_publish_plan(
&surface,
&visibility,
Some(&PublishSelection {
from: None,
only: Some(package.name.clone()),
include_prereqs,
}),
)?;
Ok(ManifestWorkspacePublishResolution {
workspace_root: surface.repo_root.clone(),
requested_package: package.name.clone(),
included_prereqs: plan.included_prereqs,
required_closure: plan.required_closure,
packages: plan.items,
publish_order: build_publish_command_targets(&surface, &plan.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(),
include_prereqs: options.include_prereqs,
};
let plan = build_publish_plan(surface, &visibility, Some(&selection))?;
let blockers = collect_publish_blockers(&plan.items);
if !blockers.is_empty() {
return Err(render_workspace_publish_blockers(
surface,
&plan,
Some(options),
&blockers,
));
}
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 &plan.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,
requested_package: plan.requested_package,
included_prereqs: plan.included_prereqs,
required_closure: plan.required_closure,
published,
skipped,
failed,
})
}
fn build_publish_command_targets(
surface: &ReleaseSurface,
publish_order: &[String],
) -> Result<Vec<WorkspacePublishCommandTarget>, String> {
let mut targets = Vec::new();
for package_name in publish_order {
let package = surface
.packages
.iter()
.find(|package| &package.name == package_name)
.ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
targets.push(WorkspacePublishCommandTarget {
package: package.name.clone(),
version: package.version.to_string(),
manifest_path: package.manifest_path.clone(),
manifest_relative: package.manifest_relative.clone(),
});
}
Ok(targets)
}
#[derive(Debug, Clone)]
struct PublishSelection {
from: Option<String>,
only: Option<String>,
include_prereqs: bool,
}
#[derive(Debug, Clone)]
struct ResolvedPublishSelection {
requested_package: Option<String>,
selected_packages: Vec<String>,
included_prereqs: Vec<String>,
required_closure: Vec<String>,
}
#[derive(Debug, Clone)]
struct BuiltPublishPlan {
requested_package: Option<String>,
included_prereqs: Vec<String>,
required_closure: Vec<String>,
items: Vec<PublishPlanItem>,
publish_order: Vec<String>,
}
fn build_publish_plan(
surface: &ReleaseSurface,
visibility: &BTreeMap<String, bool>,
selection: Option<&PublishSelection>,
) -> Result<BuiltPublishPlan, String> {
let ordered_packages = topological_package_order(surface)?;
let selected = resolve_selected_packages(surface, &ordered_packages, selection)?;
let selected_set: BTreeSet<String> = selected.selected_packages.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 {
if !selected_set.contains(&package_name) {
continue;
}
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 mut publish_needed = false;
let mut blocked_by = Vec::new();
let reason = if visible == Some(true) {
"already visible on crates.io".to_string()
} else 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 !package.publish_missing_version_pins.is_empty() {
format!(
"missing version pins for {}",
package.publish_missing_version_pins.join(", ")
)
} else {
for dependency in &package.publish_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
&& package.publish_missing_version_pins.is_empty(),
crates_io_visible: visible,
publish_needed,
blocked_by,
reason,
});
}
Ok(BuiltPublishPlan {
requested_package: selected.requested_package,
included_prereqs: selected.included_prereqs,
required_closure: selected.required_closure,
items,
publish_order,
})
}
fn resolve_selected_packages(
surface: &ReleaseSurface,
ordered_packages: &[String],
selection: Option<&PublishSelection>,
) -> Result<ResolvedPublishSelection, String> {
let Some(selection) = selection else {
return Ok(ResolvedPublishSelection {
requested_package: None,
selected_packages: ordered_packages.to_vec(),
included_prereqs: Vec::new(),
required_closure: 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));
}
let required_closure = collect_publish_closure(surface, only, ordered_packages)?;
let selected_packages = if selection.include_prereqs {
required_closure.clone()
} else {
vec![only.to_string()]
};
let included_prereqs = required_closure
.iter()
.filter(|package| package.as_str() != only)
.cloned()
.collect::<Vec<_>>();
return Ok(ResolvedPublishSelection {
requested_package: Some(only.to_string()),
selected_packages,
included_prereqs: if selection.include_prereqs {
included_prereqs
} else {
Vec::new()
},
required_closure,
});
}
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(ResolvedPublishSelection {
requested_package: None,
selected_packages: ordered_packages[start..].to_vec(),
included_prereqs: Vec::new(),
required_closure: ordered_packages[start..].to_vec(),
});
}
Ok(ResolvedPublishSelection {
requested_package: None,
selected_packages: ordered_packages.to_vec(),
included_prereqs: Vec::new(),
required_closure: ordered_packages.to_vec(),
})
}
fn collect_publish_closure(
surface: &ReleaseSurface,
root_package: &str,
ordered_packages: &[String],
) -> Result<Vec<String>, String> {
let mut by_name = BTreeMap::new();
for package in &surface.packages {
by_name.insert(package.name.as_str(), package);
}
let mut visited = BTreeSet::new();
collect_publish_closure_visit(root_package, &by_name, &mut visited)?;
Ok(ordered_packages
.iter()
.filter(|package| visited.contains(package.as_str()))
.cloned()
.collect())
}
fn collect_publish_closure_visit<'a>(
package_name: &'a str,
by_name: &BTreeMap<&'a str, &'a ReleasePackage>,
visited: &mut BTreeSet<&'a str>,
) -> Result<(), String> {
if !visited.insert(package_name) {
return Ok(());
}
let package = by_name
.get(package_name)
.ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
for dependency in &package.publish_internal_dependencies {
collect_publish_closure_visit(dependency, by_name, visited)?;
}
Ok(())
}
fn collect_publish_blockers(items: &[PublishPlanItem]) -> Vec<String> {
items
.iter()
.filter(|item| {
item.crates_io_visible != Some(true)
&& (!item.blocked_by.is_empty() || !item.publishable)
})
.map(|item| {
if !item.blocked_by.is_empty() {
format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
} else {
format!("{} {}", item.package, item.reason)
}
})
.collect()
}
fn render_workspace_publish_blockers(
surface: &ReleaseSurface,
plan: &BuiltPublishPlan,
run_options: Option<&WorkspacePublishRunOptions>,
blockers: &[String],
) -> String {
let mut message = String::new();
message.push_str("Workspace publish is blocked.\n");
message.push_str(&format!("Repo: {}\n", surface.repo_root.display()));
if let Some(requested_package) = plan.requested_package.as_deref() {
message.push_str(&format!("Requested package: {}\n", requested_package));
}
if !plan.included_prereqs.is_empty() {
message.push_str(&format!(
"Auto-included prerequisites: {}\n",
plan.included_prereqs.join(", ")
));
}
if !plan.required_closure.is_empty() {
message.push_str(&format!(
"Required publish order: {}\n",
plan.required_closure.join(" -> ")
));
}
message.push_str("Blockers:\n");
for blocker in blockers {
message.push_str(&format!("- {}\n", blocker));
}
if let (Some(requested_package), Some(run_options)) =
(plan.requested_package.as_deref(), run_options)
{
if !run_options.include_prereqs {
message.push_str(&format!(
"Rerun with prerequisites: xbp version workspace publish run --repo {} --only {} --include-prereqs{}\n",
quote_argument(&surface.repo_root),
requested_package,
if run_options.allow_dirty {
" --allow-dirty"
} else {
""
}
));
}
}
message.trim_end().to_string()
}
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
.publish_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 publish-relevant 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> {
let mut command = Command::new("cargo");
command
.current_dir(repo_root)
.args(["metadata", "--format-version", "1", "--no-deps"]);
load_cargo_metadata_command(&mut command)
}
fn load_cargo_metadata_for_manifest(manifest_path: &Path) -> Result<CargoMetadata, String> {
let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let mut command = Command::new("cargo");
command
.current_dir(manifest_dir)
.arg("metadata")
.arg("--format-version")
.arg("1")
.arg("--no-deps")
.arg("--manifest-path")
.arg(manifest_path);
load_cargo_metadata_command(&mut command)
}
fn load_cargo_metadata_command(command: &mut Command) -> Result<CargoMetadata, String> {
if !command_exists("cargo") {
return Err("`cargo` is required to inspect workspace metadata.".to_string());
}
let output = command
.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(),
warning: None,
})
}
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 same_path(left: &Path, right: &Path) -> bool {
fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
== fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
}
fn quote_argument(path: &Path) -> String {
let value = path.to_string_lossy();
if value.contains(' ') {
format!("\"{}\"", value)
} else {
value.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 {
let status = if command.success {
if command.warning.is_some() {
"ok (with warning)"
} else {
"ok"
}
} else {
"failed"
};
println!("{} [{}]", command.command, status);
if let Some(warning) = command.warning.as_deref() {
println!("warning: {}", warning);
}
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());
if let Some(requested_package) = report.requested_package.as_deref() {
println!("Requested package: {}", requested_package);
}
if !report.included_prereqs.is_empty() {
println!(
"Auto-included prerequisites: {}",
report.included_prereqs.join(", ")
);
}
if !report.required_closure.is_empty() {
println!("Required closure: {}", report.required_closure.join(" -> "));
}
println!(
"Publish 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 let Some(requested_package) = report.requested_package.as_deref() {
println!("Requested package: {}", requested_package);
}
if !report.included_prereqs.is_empty() {
println!(
"Auto-included prerequisites: {}",
report.included_prereqs.join(", ")
);
}
if !report.required_closure.is_empty() {
println!("Required closure: {}", report.required_closure.join(" -> "));
}
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 plan = build_publish_plan(
&surface,
&visibility,
Some(&PublishSelection {
from: None,
only: None,
include_prereqs: false,
}),
)
.expect("plan");
let alpha_pos = plan
.publish_order
.iter()
.position(|name| name == "alpha")
.expect("alpha");
let s3_pos = plan
.publish_order
.iter()
.position(|name| name == "athena-s3")
.expect("s3");
assert!(s3_pos < alpha_pos);
assert!(plan
.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 plan = build_publish_plan(
&surface,
&visibility,
Some(&PublishSelection {
from: None,
only: None,
include_prereqs: false,
}),
)
.expect("plan");
let xbp_pos = plan
.publish_order
.iter()
.position(|name| name == "xbp")
.expect("xbp");
let providers_pos = plan
.publish_order
.iter()
.position(|name| name == "xbp-providers")
.expect("providers");
let http_pos = plan
.publish_order
.iter()
.position(|name| name == "xbp-http")
.expect("http");
assert!(http_pos < providers_pos);
assert!(providers_pos < xbp_pos);
}
#[test]
fn publish_plan_only_with_prereqs_limits_to_minimal_closure() {
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(), false);
visibility.insert("athena-s3".to_string(), false);
let plan = build_publish_plan(
&surface,
&visibility,
Some(&PublishSelection {
from: None,
only: Some("alpha".to_string()),
include_prereqs: true,
}),
)
.expect("plan");
let package_names = plan
.items
.iter()
.map(|item| item.package.as_str())
.collect::<Vec<_>>();
assert_eq!(package_names.len(), 3);
assert!(!package_names.contains(&"athena_rs"));
assert_eq!(plan.required_closure.len(), 3);
assert!(plan.required_closure.contains(&"alpha".to_string()));
assert!(plan.required_closure.contains(&"beta".to_string()));
assert!(plan.required_closure.contains(&"athena-s3".to_string()));
assert_eq!(plan.included_prereqs.len(), 2);
assert!(plan.included_prereqs.contains(&"beta".to_string()));
assert!(plan.included_prereqs.contains(&"athena-s3".to_string()));
assert_eq!(plan.publish_order.len(), 3);
let alpha_pos = plan
.publish_order
.iter()
.position(|package| package == "alpha")
.expect("alpha in publish order");
let beta_pos = plan
.publish_order
.iter()
.position(|package| package == "beta")
.expect("beta in publish order");
let s3_pos = plan
.publish_order
.iter()
.position(|package| package == "athena-s3")
.expect("athena-s3 in publish order");
assert!(beta_pos < alpha_pos);
assert!(s3_pos < alpha_pos);
}
#[test]
fn publish_plan_only_without_prereqs_reports_blocked_package() {
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(), false);
visibility.insert("athena-s3".to_string(), false);
let plan = build_publish_plan(
&surface,
&visibility,
Some(&PublishSelection {
from: None,
only: Some("alpha".to_string()),
include_prereqs: false,
}),
)
.expect("plan");
assert_eq!(plan.required_closure.len(), 3);
assert!(plan.required_closure.contains(&"alpha".to_string()));
assert!(plan.required_closure.contains(&"beta".to_string()));
assert!(plan.required_closure.contains(&"athena-s3".to_string()));
assert!(plan.included_prereqs.is_empty());
assert!(plan.publish_order.is_empty());
assert_eq!(plan.items.len(), 1);
assert_eq!(plan.items[0].package, "alpha");
assert_eq!(plan.items[0].blocked_by.len(), 2);
assert!(plan.items[0].blocked_by.contains(&"beta".to_string()));
assert!(plan.items[0].blocked_by.contains(&"athena-s3".to_string()));
}
#[test]
fn publish_plan_ignores_dev_dependencies_for_publish_blockers() {
let root = temp_dir("publish-dev-deps");
write_file(
&root.join("Cargo.toml"),
r#"[package]
name = "demo-root"
version = "1.0.0"
[workspace]
members = ["crates/app", "crates/dev-helper"]
resolver = "2"
"#,
);
write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
write_file(
&root.join("crates/app/Cargo.toml"),
r#"[package]
name = "demo-app"
version = "1.0.0"
[dev-dependencies]
dev-helper = { path = "../dev-helper", version = "1.0.0" }
"#,
);
write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
write_file(
&root.join("crates/dev-helper/Cargo.toml"),
r#"[package]
name = "dev-helper"
version = "1.0.0"
"#,
);
write_file(
&root.join("crates/dev-helper/src/lib.rs"),
"pub fn helper() {}\n",
);
let surface = discover_release_surface(&root).expect("discover");
let mut visibility = BTreeMap::new();
visibility.insert("demo-root".to_string(), false);
visibility.insert("demo-app".to_string(), false);
visibility.insert("dev-helper".to_string(), false);
let plan = build_publish_plan(
&surface,
&visibility,
Some(&PublishSelection {
from: None,
only: Some("demo-app".to_string()),
include_prereqs: false,
}),
)
.expect("plan");
assert_eq!(plan.required_closure, vec!["demo-app"]);
assert_eq!(plan.publish_order, vec!["demo-app"]);
assert!(plan.items[0].blocked_by.is_empty());
}
}