//! Version management commands and adapters.
use crate::cli::auto_commit::{commit_paths, print_skip, AutoCommitRequest, AutoCommitResult};
use crate::cli::ui::Loader;
use crate::commands::cli_session::{
fetch_linear_api_key_from_dashboard, post_version_activity, CliVersionActivityPayload,
VersionActivityLinearInitiative,
};
use crate::commands::publish::run_publish_command_with_progress_prefix;
use crate::commands::PublishCommandOptions;
use crate::config::{
global_xbp_paths, load_package_name_files_registry, load_versioning_files_registry,
resolve_github_oauth2_key, resolve_global_linear_release_config, resolve_linear_api_key,
resolve_openrouter_api_key, PackageNameLookup,
};
use crate::strategies::deployment_config::GitHubReleaseBranchSettings;
use crate::strategies::{
resolve_config_paths_for_runtime, DeploymentConfig, ServiceConfig, XbpConfig,
};
use crate::utils::{
command_exists, find_xbp_config_upwards, heal_project_xbp_config,
maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
parse_github_repo_from_remote_url, redact_remote_url_credentials,
resolve_cargo_package_version, resolve_env_placeholders, write_cargo_package_version,
};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, Select};
use regex::Regex;
use semver::Version;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use std::collections::HashMap;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::Command;
use toml::Value as TomlValue;
#[path = "version/bump.rs"]
mod bump;
#[path = "version/discover_services.rs"]
mod discover_services;
#[path = "version/domain.rs"]
mod domain;
#[path = "version/github_release.rs"]
mod github_release;
#[path = "version/release_docs.rs"]
mod release_docs;
#[path = "version/release_ledger.rs"]
mod release_ledger;
#[path = "version/release_linear.rs"]
mod release_linear;
#[path = "version/release_notes.rs"]
mod release_notes;
#[path = "version/workspace_release.rs"]
mod workspace_release;
pub use bump::run_version_bump_command;
pub use discover_services::run_version_discover_services;
pub use domain::{
check_domain_for_cloudflare_release, run_version_domain_command, VersionDomainCommand,
VersionDomainCommandOptions, VersionDomainDiagnoseOptions, VersionDomainDoctorOptions,
VersionDomainInitOptions, VersionDomainReleaseOptions, VersionDomainSyncOptions,
};
use github_release::{
create_github_release, get_github_release_by_tag, update_github_release, GithubReleaseInput,
GithubReleaseResult, GithubReleaseTagResponse,
};
use release_docs::sync_release_docs;
use release_ledger::{
find_latest_successful_release_ledger, find_latest_unfinished_release_ledger,
load_release_ledger, release_ledger_path, release_ledger_scope_slug, save_release_ledger,
ReleaseBaselineRecord, ReleaseLedger, ReleaseLedgerStatus, ReleaseLedgerStepStatus,
ReleasePublishTarget, ReleaseSelectionRecord, ReleaseVersionTarget,
};
use release_linear::{
publish_release_to_linear_initiatives, resolve_linear_release_config,
LinearReleasePublishInput, PublishedLinearInitiative, ResolvedLinearReleaseConfig,
};
use release_notes::{generate_release_notes, ReleaseNotesRequest};
pub(crate) use workspace_release::{
inspect_workspace_version_drift, resolve_manifest_workspace_publish, sync_workspace_to_version,
ManifestWorkspacePublishResolution,
};
pub use workspace_release::{
run_version_workspace_command, WorkspacePublishPlanOptions, WorkspacePublishRunOptions,
WorkspaceVersionCheckOptions, WorkspaceVersionCommand, WorkspaceVersionCommandOptions,
WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
};
#[derive(Clone, Debug)]
struct VersionObservation {
location: String,
version: Version,
}
#[derive(Clone, Debug)]
struct GitTagObservation {
version: Version,
raw_tags: Vec<String>,
}
#[derive(Clone, Debug)]
struct RegistryVersionObservation {
registry: String,
package_name: String,
source_file: String,
latest: Option<Version>,
raw_version: Option<String>,
published_at: Option<String>,
note: Option<String>,
}
#[derive(Clone, Debug)]
struct RegistryVersionDetails {
version: String,
published_at: Option<String>,
}
#[derive(Clone, Debug, Default)]
struct ChangedTargetSelection {
changed_files: Vec<String>,
version_targets: Vec<ResolvedRegistryPath>,
publish_targets: Vec<ReleasePublishTarget>,
baseline_source: String,
baseline_reference: Option<String>,
baseline_tag: Option<String>,
}
const LEDGER_STEP_SYNC_VERSION: &str = "sync_version";
const LEDGER_STEP_WORKSPACE_SYNC: &str = "workspace_sync";
const LEDGER_STEP_PUBLISH_PACKAGES: &str = "publish_packages";
const LEDGER_STEP_PUSH_TAG: &str = "push_tag";
const LEDGER_STEP_CREATE_GITHUB_RELEASE: &str = "github_release";
const LEDGER_STEP_UPLOAD_OPENAPI_ASSETS: &str = "upload_openapi_assets";
const LEDGER_STEP_PUBLISH_LINEAR: &str = "publish_linear";
const LEDGER_STEP_SYNC_RELEASE_DOCS: &str = "sync_release_docs";
const LEDGER_STEP_COMMIT_RELEASE_DOCS: &str = "commit_release_docs";
#[derive(Clone, Debug)]
struct ResolvedRegistryPath {
relative: String,
absolute: PathBuf,
cargo_package_override: Option<String>,
}
#[derive(Clone, Debug)]
struct WorkspacePrimaryCargoTarget {
manifest_relative: String,
manifest_absolute: PathBuf,
package_name: String,
}
#[derive(Clone, Debug)]
enum VersionScope {
Repository,
Crate {
crate_root: PathBuf,
crate_relative_root: String,
package_name: String,
tag_prefix: String,
},
Service {
service_root: PathBuf,
service_relative_root: String,
service_name: String,
tag_prefix: String,
cargo_package_name: Option<String>,
version_targets: Vec<String>,
/// Relative path prefixes that trigger this service for dirty-tree versioning.
watch_paths: Vec<String>,
},
}
#[derive(Default, Debug)]
struct VersionReport {
worktree: Vec<VersionObservation>,
head: Vec<VersionObservation>,
local_tags: Vec<GitTagObservation>,
remote_tags: Vec<GitTagObservation>,
registry_versions: Vec<RegistryVersionObservation>,
dirty_files: Vec<String>,
release_ledger: Option<ReleaseLedger>,
warnings: Vec<String>,
}
const VERSION_CHANGE_GUARD_FILE_NAME: &str = "version-change-guard.yaml";
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
struct VersionChangeGuardRegistry {
#[serde(default)]
entries: BTreeMap<String, VersionChangeGuardEntry>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
struct VersionChangeGuardEntry {
#[serde(default)]
pending_version_change_count: usize,
#[serde(default)]
head_commit: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct GitWorktreeState {
is_dirty: bool,
head_commit: Option<String>,
}
impl VersionReport {
fn highest_worktree(&self) -> Option<Version> {
self.worktree
.iter()
.map(|entry| entry.version.clone())
.max()
}
fn highest_head(&self) -> Option<Version> {
self.head.iter().map(|entry| entry.version.clone()).max()
}
fn highest_local_tag(&self) -> Option<Version> {
self.local_tags
.iter()
.map(|entry| entry.version.clone())
.max()
}
fn highest_remote_tag(&self) -> Option<Version> {
self.remote_tags
.iter()
.map(|entry| entry.version.clone())
.max()
}
fn highest_git(&self) -> Option<Version> {
self.highest_remote_tag()
.or_else(|| self.highest_local_tag())
}
fn highest_registry(&self) -> Option<Version> {
self.registry_versions
.iter()
.filter_map(|entry| entry.latest.clone())
.max()
}
fn highest_available(&self) -> Version {
self.highest_worktree()
.into_iter()
.chain(self.highest_head())
.chain(self.highest_git())
.chain(self.highest_registry())
.max()
.unwrap_or_else(default_version)
}
fn highest_project_local_available(&self) -> Version {
self.highest_worktree()
.into_iter()
.chain(self.highest_registry())
.max()
.unwrap_or_else(default_version)
}
fn divergent_versions(&self) -> Vec<Version> {
let mut versions = BTreeSet::new();
for entry in &self.worktree {
versions.insert(entry.version.clone());
}
for entry in &self.head {
versions.insert(entry.version.clone());
}
for entry in &self.local_tags {
versions.insert(entry.version.clone());
}
for entry in &self.remote_tags {
versions.insert(entry.version.clone());
}
for entry in &self.registry_versions {
if let Some(version) = &entry.latest {
versions.insert(version.clone());
}
}
versions.into_iter().collect()
}
}
fn resolve_release_version_from_report(
project_root: &Path,
version_scope: &VersionScope,
report: &VersionReport,
) -> Version {
if is_nested_repository_scope(project_root, version_scope) {
report.highest_project_local_available()
} else {
report.highest_available()
}
}
pub async fn run_version_command(
target: Option<String>,
git_only: bool,
_debug: bool,
) -> Result<(), String> {
if git_only && target.is_some() {
return Err("`xbp version --git` does not accept `major`, `minor`, `patch`, or explicit version values.".to_string());
}
let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let project_root: PathBuf = resolve_project_root();
let version_scope: VersionScope =
resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
let registry: Vec<String> = load_versioning_files_registry()?;
if git_only {
print_git_versions(&project_root, &version_scope)?;
return Ok(());
}
match target.as_deref() {
None => {
let mut report: VersionReport =
collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
match load_package_name_files_registry() {
Ok(lookups) => {
report.registry_versions = collect_registry_versions(
&project_root,
&invocation_dir,
&lookups,
&version_scope,
&mut report.warnings,
)
.await;
}
Err(err) => report.warnings.push(err),
}
print_version_report(&project_root, &report);
Ok(())
}
Some(bump_target @ ("major" | "minor" | "patch")) => {
enforce_version_change_guard(&project_root, Some(&version_scope))?;
let repo = project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repository")
.to_string();
let selection = resolve_changed_target_selection(
&project_root,
&invocation_dir,
®istry,
&version_scope,
&repo,
)?;
let current: Version = read_highest_version_from_targets(&selection.version_targets)?
.unwrap_or_else(|| {
resolve_current_version_for_bump(
&project_root,
&invocation_dir,
®istry,
&version_scope,
)
});
let next: Version = bump_version(¤t, bump_target);
let updated_paths =
write_version_to_selected_paths(&selection.version_targets, &next, false)?;
let updated = updated_paths.len();
println!(
"Updated {} version file(s) from {} to {}.",
updated, current, next
);
auto_commit_command_paths(
&project_root,
updated_paths,
format!("chore(version): update version to {}", next),
"xbp version",
)
.await;
record_version_change_guard(&project_root, Some(&version_scope))?;
sync_cli_version_write_activity(
&project_root,
&version_scope,
&next,
format!(
"Updated {} version file(s) from {} to {}.",
updated, current, next
),
)
.await;
Ok(())
}
Some(explicit) => {
enforce_version_change_guard(&project_root, Some(&version_scope))?;
let repo = project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repository")
.to_string();
let selection = resolve_changed_target_selection(
&project_root,
&invocation_dir,
®istry,
&version_scope,
&repo,
)?;
if let Some((package_name, version)) = parse_package_version_target(explicit)? {
let updated_paths = write_package_version_to_configured_files_with_paths(
&project_root,
&invocation_dir,
®istry,
&version_scope,
&package_name,
&version,
)?;
let updated = updated_paths.len();
println!(
"Updated {} file(s) for package `{}` to {}.",
updated, package_name, version
);
auto_commit_command_paths(
&project_root,
updated_paths,
format!("chore(version): set {} to {}", package_name, version),
"xbp version",
)
.await;
record_version_change_guard(&project_root, Some(&version_scope))?;
sync_cli_version_write_activity(
&project_root,
&version_scope,
&version,
format!(
"Updated {} file(s) for package `{}` to {}.",
updated, package_name, version
),
)
.await;
} else {
let version: Version = parse_version(explicit)?;
let updated_paths =
write_version_to_selected_paths(&selection.version_targets, &version, false)?;
let updated = updated_paths.len();
println!("Updated {} version file(s) to {}.", updated, version);
auto_commit_command_paths(
&project_root,
updated_paths,
format!("chore(version): update version to {}", version),
"xbp version",
)
.await;
record_version_change_guard(&project_root, Some(&version_scope))?;
sync_cli_version_write_activity(
&project_root,
&version_scope,
&version,
format!("Updated {} version file(s) to {}.", updated, version),
)
.await;
}
Ok(())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReleaseLatestPolicy {
True,
False,
Legacy,
}
impl ReleaseLatestPolicy {
pub(crate) fn as_github_api_value(self) -> &'static str {
match self {
Self::True => "true",
Self::False => "false",
Self::Legacy => "legacy",
}
}
}
#[derive(Debug, Clone)]
pub struct VersionReleaseOptions {
pub explicit_version: Option<String>,
pub release_flag: Option<String>,
pub allow_dirty: bool,
pub title: Option<String>,
pub notes: Option<String>,
pub notes_file: Option<PathBuf>,
pub draft: bool,
pub prerelease: bool,
pub publish: bool,
pub force: bool,
pub latest_policy: ReleaseLatestPolicy,
}
struct ReleaseWorkflowSummary {
version: Version,
tag_name: String,
release_url: String,
uploaded_openapi_assets: Vec<String>,
release_title: String,
release_notes: String,
repository_owner: String,
repository_name: String,
scope_kind: String,
scope_label: String,
published_initiatives: Vec<PublishedLinearInitiative>,
release_branch: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReleaseOpenApiAsset {
source_path: PathBuf,
source_label: String,
asset_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DirtyWorktreeAnalysis {
all_entries: Vec<String>,
safe_entries: Vec<String>,
risky_entries: Vec<String>,
}
fn parse_git_status_path(line: &str) -> Option<String> {
let line = line.trim_end();
if line.len() < 3 {
return None;
}
let path_part = line.get(2..)?.trim_start();
if path_part.is_empty() {
return None;
}
if let Some((_old, new)) = path_part.split_once(" -> ") {
return Some(new.trim().replace('\\', "/"));
}
Some(path_part.replace('\\', "/"))
}
fn is_safe_dirty_path(path: &str) -> bool {
let normalized = path.replace('\\', "/");
let segments: Vec<&str> = normalized.split('/').collect();
if segments.first() == Some(&".xbp") {
return true;
}
if segments.contains(&"target") {
return true;
}
if segments.contains(&"__pycache__") {
return true;
}
if segments.contains(&"node_modules") || segments.contains(&".next") {
return true;
}
if normalized.ends_with(".pyc") || normalized.ends_with(".pyo") {
return true;
}
false
}
fn analyze_dirty_worktree(entries: &[String]) -> DirtyWorktreeAnalysis {
let mut paths = Vec::new();
for entry in entries {
if let Some(path) = parse_git_status_path(entry) {
paths.push(path);
}
}
paths.sort();
paths.dedup();
let mut safe_entries = Vec::new();
let mut risky_entries = Vec::new();
for path in paths {
if is_safe_dirty_path(&path) {
safe_entries.push(path);
} else {
risky_entries.push(path);
}
}
DirtyWorktreeAnalysis {
all_entries: entries.to_vec(),
safe_entries,
risky_entries,
}
}
fn run_release_config_preflight(invocation_dir: &Path) -> Result<(), String> {
let Some(heal_result) = heal_project_xbp_config(invocation_dir)? else {
return Ok(());
};
println!(
" {} Auto-healed {}",
"✓".bright_green(),
heal_result.config_path.display()
);
for fix in &heal_result.fixes {
println!(" {} {}", "•".bright_cyan(), fix);
}
Ok(())
}
fn resolve_dirty_worktree_for_release(
project_root: &Path,
force: bool,
loader: Option<&Loader>,
) -> Result<bool, String> {
let dirty = git_dirty_entries(project_root)?;
if dirty.is_empty() {
return Ok(false);
}
let analysis = analyze_dirty_worktree(&dirty);
if analysis.risky_entries.is_empty() {
println!(
" {} Allowing release with {} generated/auto-healed change(s)",
"i".bright_blue(),
analysis.safe_entries.len()
);
for path in analysis.safe_entries.iter().take(4) {
println!(" {} {}", "•".bright_black(), path);
}
if analysis.safe_entries.len() > 4 {
println!(
" {} … and {} more",
"•".bright_black(),
analysis.safe_entries.len() - 4
);
}
return Ok(true);
}
if force {
return Ok(true);
}
println!(
" {} Working tree has {} uncommitted change(s)",
"!".bright_yellow(),
analysis.risky_entries.len()
);
for path in analysis.risky_entries.iter().take(8) {
println!(" {} {}", "!".bright_yellow(), path);
}
if analysis.risky_entries.len() > 8 {
println!(
" {} … and {} more",
"!".bright_yellow(),
analysis.risky_entries.len() - 8
);
}
if !analysis.safe_entries.is_empty() {
println!(
" {} Ignoring {} generated/auto-healed path(s)",
"i".bright_blue(),
analysis.safe_entries.len()
);
}
if std::io::stdin().is_terminal() {
let prompt = || {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Continue release with uncommitted changes?")
.default(false)
.interact()
};
let proceed = if let Some(loader) = loader {
loader.suspend(prompt)
} else {
prompt()
}
.map_err(|e| format!("Failed to read confirmation: {}", e))?;
if proceed {
return Ok(true);
}
return Err(
"Release cancelled. Commit/stash changes first or pass `--allow-dirty`.".to_string(),
);
}
let preview = analysis
.risky_entries
.into_iter()
.take(8)
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
preview
))
}
async fn maybe_heal_workspace_versions_for_release(
project_root: &Path,
release_version: &Version,
force: bool,
loader: &Loader,
) -> Result<(), String> {
let Some(drift) = inspect_workspace_version_drift(project_root, release_version)? else {
return Ok(());
};
println!(
" {} Workspace version drift detected ({} mismatch(es) for {})",
"!".bright_yellow(),
drift.drift_count,
drift.expected_version
);
for line in &drift.preview {
println!(" {} {}", "•".bright_yellow(), line);
}
if drift.drift_count > drift.preview.len() {
println!(
" {} … and {} more",
"•".bright_yellow(),
drift.drift_count - drift.preview.len()
);
}
let should_sync = if force {
true
} else if std::io::stdin().is_terminal() {
loader
.suspend(|| {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Sync workspace versions to {} before publishing?",
release_version
))
.default(true)
.interact()
})
.map_err(|e| format!("Failed to read confirmation: {}", e))?
} else {
return Err(format!(
"Workspace version drift detected for {}. Run `xbp version workspace sync --write --version {}` or pass `--force` to auto-sync.",
release_version, release_version
));
};
if !should_sync {
return Err("Release cancelled. Align workspace versions before publishing.".to_string());
}
let changed_files = sync_workspace_to_version(project_root, release_version)?;
if changed_files.is_empty() {
println!(
" {} Workspace versions already aligned to {}",
"✓".bright_green(),
release_version
);
return Ok(());
}
println!(
" {} Synced workspace versions to {} ({} file(s))",
"✓".bright_green(),
release_version,
changed_files.len()
);
for path in changed_files.iter().take(6) {
println!(" {} {}", "•".bright_cyan(), path);
}
if changed_files.len() > 6 {
println!(
" {} … and {} more",
"•".bright_cyan(),
changed_files.len() - 6
);
}
let changed_paths = changed_files
.into_iter()
.map(|relative| project_root.join(relative))
.collect::<Vec<_>>();
auto_commit_command_paths(
project_root,
changed_paths,
format!("chore(version): sync workspace to {}", release_version),
"xbp version release",
)
.await;
Ok(())
}
pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
let loader = Loader::start("Publishing release");
let result: Result<ReleaseWorkflowSummary, String> = async {
let VersionReleaseOptions {
explicit_version,
release_flag,
allow_dirty,
title,
notes,
notes_file,
draft,
prerelease,
publish,
force,
latest_policy,
} = options;
let mut effective_allow_dirty = allow_dirty || force;
if notes.is_some() && notes_file.is_some() {
return Err("Use either `--notes` or `--notes-file`, not both.".to_string());
}
if !command_exists("git") {
return Err(
"Git is required for `xbp version release`, but it is not installed.".to_string(),
);
}
let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let project_root: PathBuf = resolve_project_root();
let version_scope: VersionScope =
resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
let registry: Vec<String> = load_versioning_files_registry()?;
let sync_explicit_version = explicit_version.is_some();
let total_steps = 10usize + usize::from(sync_explicit_version) + usize::from(publish) * 2;
let mut step = 1usize;
loader.update(&format!(
"[{}/{}] Checking project configuration",
step, total_steps
));
run_release_config_preflight(&invocation_dir)?;
step += 1;
loader.update(&format!(
"[{}/{}] Validating git state and resolving release target",
step, total_steps
));
if !effective_allow_dirty {
effective_allow_dirty =
resolve_dirty_worktree_for_release(&project_root, force, Some(&loader))?;
}
let tag_config =
resolve_project_release_git_tag_config(&project_root, &invocation_dir).await?;
let release_flag = release_flag
.as_deref()
.map(normalize_release_flag)
.transpose()?;
let (release_version, tag_name) = if let Some(raw) = explicit_version.as_deref() {
let (version, parsed_tag_name) = parse_release_version_target(raw)?;
let version = apply_release_flag_to_version(version, release_flag.as_deref())?;
(
version.clone(),
scoped_release_tag_name(
&version_scope,
&version,
&parsed_tag_name,
tag_config.git_tag_template.as_deref(),
)?,
)
} else {
let registry: Vec<String> = load_versioning_files_registry()?;
let report: VersionReport =
collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
let release_version: Version =
apply_release_flag_to_version(
resolve_release_version_from_report(&project_root, &version_scope, &report),
release_flag.as_deref(),
)?;
let tag_name: String = default_release_tag_name(
&version_scope,
&release_version,
tag_config.git_tag_template.as_deref(),
)?;
(release_version, tag_name)
};
let repo_label = project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repository")
.to_string();
let ledger_path =
current_scope_ledger_path(&project_root, &version_scope, &repo_label, &release_version);
let mut selection = resolve_changed_target_selection(
&project_root,
&invocation_dir,
®istry,
&version_scope,
&repo_label,
)?;
let mut release_ledger = match load_release_ledger(&ledger_path)? {
Some(existing) if existing.version == release_version.to_string() => {
println!(
"{} {}",
"Resuming release ledger".bright_yellow().bold(),
ledger_path.display()
);
selection.version_targets = ledger_version_targets_to_resolved(&project_root, &existing);
if !existing.selection.publish_targets.is_empty() {
selection.publish_targets = existing.selection.publish_targets.clone();
}
selection.changed_files = existing.selection.changed_files.clone();
selection.baseline_source = existing.baseline.source.clone();
selection.baseline_reference = existing.baseline.reference.clone();
selection.baseline_tag = existing.baseline.tag.clone();
existing
}
_ => {
let ledger = initialize_release_ledger(
&version_scope,
&repo_label,
&release_version,
&tag_name,
None,
&selection,
);
save_release_ledger(&ledger_path, &ledger)?;
ledger
}
};
let release_branch_config =
resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
let prepared_release_branch = if let Some(branch_config) = &release_branch_config {
let branch = prepare_release_branch_checkout(
&project_root,
branch_config,
&release_version,
&tag_name,
&version_scope_label(&version_scope, &repo_label),
&git_head_commitish(&project_root)?,
)?;
if release_ledger.branch_name.as_deref() != Some(branch.as_str()) {
release_ledger.branch_name = Some(branch.clone());
save_release_ledger(&ledger_path, &release_ledger)?;
}
Some(branch)
} else {
None
};
print_selected_release_targets(&selection);
if sync_explicit_version {
step += 1;
loader.update(&format!(
"[{}/{}] Syncing configured version files",
step, total_steps
));
if !release_ledger.step_is_done(LEDGER_STEP_SYNC_VERSION) {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_VERSION,
None,
)?;
let updated_paths =
write_version_to_selected_paths(&selection.version_targets, &release_version, true)
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_VERSION,
error,
);
})?;
if !updated_paths.is_empty() {
auto_commit_command_paths(
&project_root,
updated_paths.clone(),
format!("chore(version): update version to {}", release_version),
"xbp version release",
)
.await;
}
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_VERSION,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"updated_paths": updated_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
})),
)?;
}
}
ensure_remote_exists(&project_root, "origin")?;
let tag_exists_local: bool = git_tag_exists(&project_root, &tag_name)?;
let tag_exists_remote: bool = git_remote_tag_exists(&project_root, "origin", &tag_name)?;
if publish {
step += 1;
loader.update(&format!(
"[{}/{}] Checking workspace version alignment",
step, total_steps
));
if !release_ledger.step_is_done(LEDGER_STEP_WORKSPACE_SYNC) {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_WORKSPACE_SYNC,
None,
)?;
maybe_heal_workspace_versions_for_release(
&project_root,
&release_version,
force,
&loader,
)
.await
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_WORKSPACE_SYNC,
error,
);
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_WORKSPACE_SYNC,
ReleaseLedgerStepStatus::Completed,
None,
)?;
}
step += 1;
loader.update(&format!(
"[{}/{}] Publishing configured packages",
step, total_steps
));
if !release_ledger.step_is_done(LEDGER_STEP_PUBLISH_PACKAGES) {
if selection.publish_targets.is_empty() {
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
ReleaseLedgerStepStatus::Skipped,
Some(serde_json::json!({ "reason": "no selected publish targets" })),
)?;
} else if verify_selected_publish_targets(
&project_root,
&selection.publish_targets,
&release_version,
)
.await?
{
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
ReleaseLedgerStepStatus::VerifiedRemote,
Some(serde_json::json!({
"targets": selection.publish_targets,
"version": release_version.to_string()
})),
)?;
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
None,
)?;
let publish_target_filter =
publish_target_filter_from_selection(&selection.publish_targets)?;
run_publish_command_with_progress_prefix(
PublishCommandOptions {
dry_run: false,
allow_dirty: effective_allow_dirty,
force,
include_prereqs: true,
target: publish_target_filter,
manifest_path: None,
expected_version: Some(release_version.to_string()),
},
&loader,
format!("[{}/{}]", step, total_steps),
)
.await
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
error,
);
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"targets": selection.publish_targets,
"version": release_version.to_string()
})),
)?;
}
}
}
step += 1;
loader.update(&format!(
"[{}/{}] Resolving GitHub repository and auth",
step, total_steps
));
let origin_url: String = git_remote_url(&project_root, "origin")?;
let (owner, repo) = parse_github_repo_from_remote_url(&origin_url).ok_or_else(|| {
format!(
"Origin remote is not a GitHub repository URL: `{}`. Use a GitHub origin like `https://github.com/<owner>/<repo>.git` or `git@github.com:<owner>/<repo>.git` and keep tokens in `GITHUB_TOKEN`/`xbp config github set-key` instead of embedding them in the remote URL.",
redact_remote_url_credentials(&origin_url)
)
})?;
let github_token: String = resolve_github_oauth2_key().ok_or_else(|| {
"No GitHub token found. Configure with `xbp config github set-key` or export `GITHUB_TOKEN`."
.to_string()
})?;
let linear_api_key: Option<String> = if let Some(key) = resolve_linear_api_key() {
Some(key)
} else {
fetch_linear_api_key_from_dashboard().await?
};
let release_title: String = title.unwrap_or_else(|| {
default_release_title(
&release_version,
release_title_subject(&version_scope, &repo),
)
});
step += 1;
loader.update(&format!(
"[{}/{}] Generating release notes",
step, total_steps
));
let release_notes_body: String = if let Some(path) = notes_file {
fs::read_to_string(&path).map_err(|e| {
format!(
"Failed to read release notes file {}: {}",
path.display(),
e
)
})?
} else if let Some(body) = notes {
body
} else {
generate_release_notes(&ReleaseNotesRequest {
project_root: &project_root,
release_title: &release_title,
current_tag_name: &tag_name,
owner: &owner,
repo: &repo,
github_token: &github_token,
linear_api_key: linear_api_key.as_deref(),
openrouter_api_key: resolve_openrouter_api_key().as_deref(),
path_filter: release_notes_scope_path(&version_scope).as_deref(),
})
.await?
};
let release_notes: String = append_release_label_footer(&release_notes_body, prerelease);
step += 1;
loader.update(&format!(
"[{}/{}] Preparing release OpenAPI assets",
step, total_steps
));
let release_openapi_assets = prepare_release_openapi_assets(
&project_root,
&invocation_dir,
&version_scope,
&release_version,
&tag_name,
)?;
step += 1;
loader.update(&format!(
"[{}/{}] Creating and pushing release tag",
step, total_steps
));
let tag_message: String = format!("Release {}", tag_name);
let target_commitish: String = git_head_commitish(&project_root)?;
let created_release_branch = if release_ledger.step_is_done(LEDGER_STEP_PUSH_TAG) {
release_ledger.branch_name.clone()
} else {
begin_release_step(&mut release_ledger, &ledger_path, LEDGER_STEP_PUSH_TAG, None)?;
let branch = if let Some(branch_name) = prepared_release_branch.as_ref() {
push_release_branch(&project_root, branch_name, &target_commitish)?;
Some(branch_name.clone())
} else {
None
};
if !tag_exists_local {
run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUSH_TAG,
error,
);
})?;
}
if !tag_exists_remote {
run_git_command(&project_root, &["push", "origin", &tag_name]).inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUSH_TAG,
error,
);
})?;
}
release_ledger.branch_name = branch.clone();
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUSH_TAG,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"tag_name": tag_name,
"branch_name": branch,
})),
)?;
branch
};
let release_input: GithubReleaseInput = GithubReleaseInput {
owner: owner.clone(),
repo: repo.clone(),
token: github_token,
tag_name: tag_name.clone(),
target_commitish,
title: release_title,
notes: release_notes,
draft,
prerelease,
latest_policy,
};
step += 1;
loader.update(&format!(
"[{}/{}] Publishing GitHub release",
step, total_steps
));
let release_result: GithubReleaseResult = if release_ledger.step_is_done(LEDGER_STEP_CREATE_GITHUB_RELEASE) {
let existing_release = get_github_release_by_tag(&release_input)
.await?
.ok_or_else(|| "Release ledger indicates a completed GitHub release step, but the release could not be found by tag.".to_string())?;
GithubReleaseResult {
id: existing_release.id,
html_url: existing_release.html_url.unwrap_or_else(|| {
format!(
"https://github.com/{}/{}/releases/tag/{}",
release_input.owner, release_input.repo, release_input.tag_name
)
}),
}
} else if let Some(existing_release) = get_github_release_by_tag(&release_input).await? {
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
ReleaseLedgerStepStatus::VerifiedRemote,
Some(serde_json::json!({
"release_id": existing_release.id,
"release_url": existing_release.html_url,
})),
)?;
GithubReleaseResult {
id: existing_release.id,
html_url: existing_release.html_url.unwrap_or_else(|| {
format!(
"https://github.com/{}/{}/releases/tag/{}",
release_input.owner, release_input.repo, release_input.tag_name
)
}),
}
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
None,
)?;
let created = match create_github_release(&release_input).await {
Ok(result) => result,
Err(create_error) => {
let existing_release: Option<GithubReleaseTagResponse> = get_github_release_by_tag(&release_input).await.map_err(|e| {
format!(
"{}\nTag `{}` is available in git, but checking existing GitHub release failed: {}",
create_error, tag_name, e
)
})?;
let Some(existing_release) = existing_release else {
let error = format!(
"{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
create_error, tag_name
);
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
&error,
);
return Err(error);
};
let needs_update: bool = existing_release.prerelease.unwrap_or(false)
!= release_input.prerelease
|| existing_release.draft.unwrap_or(false) != release_input.draft
|| release_input.latest_policy != ReleaseLatestPolicy::Legacy;
if needs_update {
update_github_release(&release_input, existing_release.id)
.await
.map_err(|e| {
let error = format!(
"{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
create_error, tag_name, e
);
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
&error,
);
error
})?
} else {
GithubReleaseResult {
id: existing_release.id,
html_url: existing_release.html_url.unwrap_or_else(|| {
format!(
"https://github.com/{}/{}/releases/tag/{}",
release_input.owner, release_input.repo, release_input.tag_name
)
}),
}
}
}
};
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"release_id": created.id,
"release_url": created.html_url,
})),
)?;
created
};
let release_url = release_result.html_url.clone();
step += 1;
loader.update(&format!(
"[{}/{}] Publishing release integrations",
step, total_steps
));
let linear_release_config =
resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
let uploaded_openapi_assets = if release_ledger.step_is_done(LEDGER_STEP_UPLOAD_OPENAPI_ASSETS) {
release_ledger
.step(LEDGER_STEP_UPLOAD_OPENAPI_ASSETS)
.and_then(|step| step.details.get("uploaded_assets"))
.and_then(serde_json::Value::as_array)
.map(|values| {
values
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
None,
)?;
let mut uploaded_assets: Vec<String> = Vec::new();
for asset in &release_openapi_assets {
github_release::upload_github_release_asset(
&release_input,
release_result.id,
&asset.source_path,
&asset.asset_name,
)
.await
.map_err(|e| {
let error = format!(
"Release `{}` was published, but uploading OpenAPI asset `{}` from `{}` failed: {}",
tag_name,
asset.asset_name,
asset.source_label,
e
);
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
&error,
);
error
})?;
uploaded_assets.push(asset.asset_name.clone());
}
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({ "uploaded_assets": uploaded_assets })),
)?;
uploaded_assets
};
let published_initiatives = if release_ledger.step_is_done(LEDGER_STEP_PUBLISH_LINEAR) {
release_ledger
.step(LEDGER_STEP_PUBLISH_LINEAR)
.and_then(|step| step.details.get("initiatives"))
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|error| format!("Failed to decode stored Linear release initiatives: {}", error))?
.unwrap_or_default()
} else if let Some(linear_release_config) = linear_release_config {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
None,
)?;
let linear_api_key: String = linear_api_key.clone().ok_or_else(|| {
"A Linear release target is configured, but no Linear API key was found. Configure `xbp config linear set-key` or save it in the dashboard settings."
.to_string()
})?;
let published = publish_release_to_linear_initiatives(&LinearReleasePublishInput {
api_key: linear_api_key,
initiative_ids: linear_release_config.initiative_ids,
organization_name: linear_release_config.organization_name,
health: linear_release_config.health,
release_title: release_input.title.clone(),
release_tag: release_input.tag_name.clone(),
release_url: release_url.clone(),
release_notes: release_input.notes.clone(),
})
.await
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
error,
);
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({ "initiatives": published })),
)?;
published
} else {
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
ReleaseLedgerStepStatus::Skipped,
Some(serde_json::json!({ "reason": "no linear release config" })),
)?;
Vec::new()
};
step += 1;
loader.update(&format!(
"[{}/{}] Syncing release docs",
step, total_steps
));
let release_doc_paths = if release_ledger.step_is_done(LEDGER_STEP_SYNC_RELEASE_DOCS) {
release_ledger
.step(LEDGER_STEP_SYNC_RELEASE_DOCS)
.and_then(|step| step.details.get("paths"))
.and_then(serde_json::Value::as_array)
.map(|paths| paths.iter().filter_map(serde_json::Value::as_str).map(PathBuf::from).collect::<Vec<_>>())
.unwrap_or_default()
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_RELEASE_DOCS,
None,
)?;
let paths = sync_release_docs(&project_root, &owner, &repo).inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_RELEASE_DOCS,
error,
);
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_RELEASE_DOCS,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"paths": paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
})),
)?;
paths
};
step += 1;
loader.update(&format!(
"[{}/{}] Auto-committing release docs",
step, total_steps
));
if !release_ledger.step_is_done(LEDGER_STEP_COMMIT_RELEASE_DOCS) {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_COMMIT_RELEASE_DOCS,
None,
)?;
auto_commit_command_paths(
&project_root,
release_doc_paths.clone(),
format!("docs(release): sync release docs for {}", tag_name),
"xbp version release",
)
.await;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_COMMIT_RELEASE_DOCS,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"paths": release_doc_paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
})),
)?;
}
complete_release_ledger(&mut release_ledger);
save_release_ledger(&ledger_path, &release_ledger)?;
let summary = ReleaseWorkflowSummary {
version: release_version.clone(),
tag_name,
release_url,
uploaded_openapi_assets,
release_title: release_input.title.clone(),
release_notes: release_input.notes.clone(),
repository_owner: owner.clone(),
repository_name: repo.clone(),
scope_kind: version_scope_kind(&version_scope).to_string(),
scope_label: version_scope_label(&version_scope, &repo),
published_initiatives,
release_branch: created_release_branch,
};
sync_cli_release_activity(&summary).await;
Ok(summary)
}
.await;
match result {
Ok(summary) => {
loader.success_with(&format!("Published {}", summary.tag_name));
println!("Released {} successfully.", summary.tag_name);
println!("GitHub release: {}", summary.release_url);
if !summary.uploaded_openapi_assets.is_empty() {
println!(
"Uploaded OpenAPI assets: {}",
summary.uploaded_openapi_assets.join(", ")
);
}
if !summary.published_initiatives.is_empty() {
println!(
"Published release update to Linear initiative(s): {}",
summary
.published_initiatives
.iter()
.map(|initiative| initiative.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
if let Some(release_branch) = summary.release_branch {
println!("Release branch: {}", release_branch);
}
println!("Updated release docs: CHANGELOG.md and SECURITY.md");
Ok(())
}
Err(error) => {
loader.fail(&error);
Err(error)
}
}
}
/// Print program version from Cargo metadata.
pub async fn print_version() {
println!("XBP Version: {}", env!("CARGO_PKG_VERSION"));
}
fn resolve_project_root() -> PathBuf {
let cwd: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
resolve_project_root_from(&cwd)
}
fn resolve_project_root_from(cwd: &Path) -> PathBuf {
if let Some(found) = find_xbp_config_upwards(cwd) {
return found.project_root;
}
if let Some(root) = git_repository_root(cwd) {
return root;
}
cwd.to_path_buf()
}
fn current_scope_slug(version_scope: &VersionScope, repo: &str) -> String {
release_ledger_scope_slug(
version_scope_kind(version_scope),
&version_scope_label(version_scope, repo),
)
}
fn current_scope_ledger_path(
project_root: &Path,
version_scope: &VersionScope,
repo: &str,
version: &Version,
) -> PathBuf {
release_ledger_path(
project_root,
¤t_scope_slug(version_scope, repo),
&version.to_string(),
)
}
fn resolve_change_baseline(
project_root: &Path,
version_scope: &VersionScope,
repo: &str,
) -> Result<(String, Option<String>, Option<String>), String> {
let scope_slug = current_scope_slug(version_scope, repo);
if let Some(ledger) = find_latest_successful_release_ledger(project_root, &scope_slug)? {
if let Some(tag) = ledger.baseline.tag.or(Some(ledger.tag_name)) {
return Ok(("release-ledger".to_string(), Some(tag.clone()), Some(tag)));
}
}
if is_nested_repository_scope(project_root, version_scope) {
return Ok(("initial-release".to_string(), None, None));
}
let tags = collect_remote_git_versions(project_root, "origin", version_scope)?;
if let Some(latest) = tags.first() {
let tag = if let Some(tag) = latest.raw_tags.first().cloned() {
tag
} else {
default_release_tag_name(version_scope, &latest.version, None)?
};
return Ok(("github-tag".to_string(), Some(tag.clone()), Some(tag)));
}
Ok(("initial-release".to_string(), None, None))
}
fn collect_changed_files_since_reference(
project_root: &Path,
baseline_ref: Option<&str>,
) -> Result<Vec<String>, String> {
let mut args = vec!["diff", "--name-only"];
if let Some(reference) = baseline_ref
.map(str::trim)
.filter(|value| !value.is_empty())
{
args.push(reference);
args.push("HEAD");
}
let output = run_git_command(project_root, &args)?;
let mut changed_files = output
.lines()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
changed_files.sort();
changed_files.dedup();
Ok(changed_files)
}
fn resolve_changed_target_selection(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
) -> Result<ChangedTargetSelection, String> {
let (baseline_source, baseline_reference, baseline_tag) =
resolve_change_baseline(project_root, version_scope, repo)?;
let changed_files =
collect_changed_files_since_reference(project_root, baseline_reference.as_deref())?;
let all_version_targets =
resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
if all_version_targets.is_empty() {
return Err(
"No configured version files were found to evaluate for release selection.".to_string(),
);
}
let version_targets = if changed_files.is_empty() || baseline_reference.is_none() {
dedupe_resolved_registry_paths(&all_version_targets)
} else {
let selected = select_changed_version_targets(
project_root,
version_scope,
&all_version_targets,
&changed_files,
);
if selected.is_empty() {
return Err(
"No version targets matched commits since the last release baseline.".to_string(),
);
}
selected
};
let publish_targets = resolve_selected_publish_targets(
project_root,
invocation_dir,
version_scope,
&version_targets,
changed_files.is_empty() || baseline_reference.is_none(),
)?;
Ok(ChangedTargetSelection {
changed_files,
version_targets,
publish_targets,
baseline_source,
baseline_reference,
baseline_tag,
})
}
fn select_changed_version_targets(
project_root: &Path,
version_scope: &VersionScope,
targets: &[ResolvedRegistryPath],
changed_files: &[String],
) -> Vec<ResolvedRegistryPath> {
if !matches!(version_scope, VersionScope::Repository) {
if changed_files
.iter()
.any(|path| scope_matches_changed_path(project_root, version_scope, path))
{
return targets.to_vec();
}
return Vec::new();
}
let mut selected = Vec::new();
let mut shared_root_change = false;
for changed_file in changed_files {
let mut matched_any = false;
for target in targets {
if changed_file_matches_version_target(changed_file, target) {
matched_any = true;
if !selected
.iter()
.any(|existing: &ResolvedRegistryPath| existing.relative == target.relative)
{
selected.push(target.clone());
}
}
}
if !matched_any {
shared_root_change = true;
}
}
if shared_root_change {
return dedupe_resolved_registry_paths(targets);
}
selected
}
fn dedupe_resolved_registry_paths(targets: &[ResolvedRegistryPath]) -> Vec<ResolvedRegistryPath> {
let mut seen = BTreeSet::new();
targets
.iter()
.filter(|target| seen.insert(target.relative.clone()))
.cloned()
.collect()
}
fn changed_file_matches_version_target(changed_file: &str, target: &ResolvedRegistryPath) -> bool {
let parent = Path::new(&target.relative)
.parent()
.map(normalized_relative_path)
.unwrap_or_else(|| ".".to_string());
if parent == "." || parent.is_empty() {
return true;
}
changed_file == parent || changed_file.starts_with(&format!("{parent}/"))
}
fn scope_matches_changed_path(
project_root: &Path,
version_scope: &VersionScope,
changed_file: &str,
) -> bool {
match version_scope {
VersionScope::Repository => true,
VersionScope::Crate { crate_root, .. } => {
path_matches_root(project_root, crate_root, changed_file)
}
VersionScope::Service {
service_root,
version_targets,
watch_paths,
..
} => service_scope_matches_path(
project_root,
service_root,
watch_paths,
version_targets,
changed_file,
),
}
}
/// Match a dirty path against a service using watch-path isolation.
///
/// Prefer explicit `watch_paths`, then directory roots derived for watching.
/// Version target *files* are not used as path prefixes (only their parents are).
fn service_scope_matches_path(
project_root: &Path,
service_root: &Path,
watch_paths: &[String],
version_targets: &[String],
path: &str,
) -> bool {
if !watch_paths.is_empty() {
return watch_paths
.iter()
.any(|watch| path_matches_watch_root(path, watch));
}
if path_matches_root(project_root, service_root, path) {
return true;
}
version_targets.iter().any(|target| {
let watch_root = watch_root_from_version_target(target);
path_matches_watch_root(path, &watch_root)
})
}
fn path_matches_watch_root(path: &str, watch_root: &str) -> bool {
let normalized = watch_root.replace('\\', "/").trim_matches('/').to_string();
if normalized.is_empty() || normalized == "." {
// Project-root watch paths intentionally match everything; callers that need
// isolation should set narrower watch_paths.
return true;
}
path == normalized || path.starts_with(&format!("{normalized}/"))
}
fn watch_root_from_version_target(target: &str) -> String {
let normalized = target.replace('\\', "/");
let path = Path::new(&normalized);
let looks_like_file = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| !ext.is_empty())
|| path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
matches!(
name,
"Cargo.toml"
| "package.json"
| "pyproject.toml"
| "composer.json"
| "Chart.yaml"
| "Chart.yml"
)
});
if looks_like_file {
path.parent()
.map(normalized_relative_path)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ".".to_string())
} else {
normalized
}
}
fn scope_watch_depth(project_root: &Path, scope: &VersionScope) -> usize {
match scope {
VersionScope::Repository => 0,
VersionScope::Crate {
crate_relative_root,
..
} => path_depth(crate_relative_root),
VersionScope::Service {
service_relative_root,
watch_paths,
..
} => {
let from_watch = watch_paths
.iter()
.map(|path| path_depth(path))
.max()
.unwrap_or(0);
let from_root = path_depth(service_relative_root);
let _ = project_root;
from_watch.max(from_root)
}
}
}
fn path_depth(path: &str) -> usize {
let normalized = path.replace('\\', "/").trim_matches('/').to_string();
if normalized.is_empty() || normalized == "." {
return 0;
}
normalized
.split('/')
.filter(|part| !part.is_empty())
.count()
}
/// Assign each dirty path to at most one nested scope (deepest watch root wins).
/// This keeps sibling services independent and nested packages from also bumping parents.
fn assign_dirty_paths_to_scopes(
project_root: &Path,
scopes: &[VersionScope],
dirty_paths: &[String],
) -> Vec<(VersionScope, Vec<String>)> {
let mut grouped: BTreeMap<String, (VersionScope, Vec<String>)> = BTreeMap::new();
for path in dirty_paths {
let mut best: Option<(usize, &VersionScope)> = None;
for scope in scopes {
if matches!(scope, VersionScope::Repository) {
continue;
}
if !scope_matches_changed_path(project_root, scope, path) {
continue;
}
let depth = scope_watch_depth(project_root, scope);
best = match best {
Some((best_depth, best_scope)) if depth < best_depth => {
Some((best_depth, best_scope))
}
Some((best_depth, best_scope)) if depth == best_depth => {
if version_scope_prompt_label(scope) < version_scope_prompt_label(best_scope) {
Some((depth, scope))
} else {
Some((best_depth, best_scope))
}
}
_ => Some((depth, scope)),
};
}
if let Some((_, scope)) = best {
let key = version_scope_guard_id(scope);
grouped
.entry(key)
.and_modify(|(_, paths)| paths.push(path.clone()))
.or_insert_with(|| (scope.clone(), vec![path.clone()]));
}
}
let mut result = grouped.into_values().collect::<Vec<_>>();
for (_, paths) in &mut result {
paths.sort();
paths.dedup();
}
result.sort_by(|(left, _), (right, _)| {
version_scope_prompt_label(left).cmp(&version_scope_prompt_label(right))
});
result
}
fn path_matches_root(project_root: &Path, scope_root: &Path, path: &str) -> bool {
let relative_root = scope_root
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| path.replace('\\', "/"));
path_matches_target(path, &relative_root)
}
fn path_matches_target(path: &str, target: &str) -> bool {
let normalized_target = target.replace('\\', "/");
if normalized_target.is_empty() || normalized_target == "." {
return true;
}
path == normalized_target || path.starts_with(&format!("{normalized_target}/"))
}
fn resolve_selected_publish_targets(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
selected_version_targets: &[ResolvedRegistryPath],
select_all: bool,
) -> Result<Vec<ReleasePublishTarget>, String> {
let Some((_, config)) = load_version_target_config(invocation_dir) else {
return Ok(Vec::new());
};
let Some(publish) = config.publish else {
return Ok(Vec::new());
};
let selected_version_paths = selected_version_targets
.iter()
.map(|target| target.relative.as_str())
.collect::<BTreeSet<_>>();
let mut targets = Vec::new();
for (kind, target) in [("npm", publish.npm), ("crates", publish.crates)] {
let Some(target) = target.filter(|value| value.enabled.unwrap_or(true)) else {
continue;
};
let manifest_path = target.manifest_path.clone().map(PathBuf::from).or_else(|| {
target
.working_directory
.clone()
.map(PathBuf::from)
.map(|directory| {
directory.join(if kind == "npm" {
"package.json"
} else {
"Cargo.toml"
})
})
});
let Some(manifest_path) = manifest_path else {
continue;
};
let relative = manifest_path
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| normalized_relative_path(&manifest_path));
let include = if select_all || selected_version_paths.contains(relative.as_str()) {
true
} else if matches!(version_scope, VersionScope::Service { .. }) {
version_scope_root(version_scope).is_some_and(|root| manifest_path.starts_with(root))
} else {
false
};
if include {
targets.push(ReleasePublishTarget {
kind: kind.to_string(),
manifest_path: relative,
package_name: target
.package_name
.clone()
.filter(|value| !value.trim().is_empty()),
});
}
}
Ok(targets)
}
fn read_highest_version_from_targets(
targets: &[ResolvedRegistryPath],
) -> Result<Option<Version>, String> {
let mut highest = None;
for target in targets {
if !target.absolute.exists() {
continue;
}
match read_version_from_resolved_path(target) {
Ok(Some(version)) => {
let parsed = parse_version(&version)?;
if highest
.as_ref()
.is_none_or(|current: &Version| parsed > *current)
{
highest = Some(parsed);
}
}
Ok(None) => {}
Err(err) => return Err(err),
}
}
Ok(highest)
}
fn write_version_to_selected_paths(
targets: &[ResolvedRegistryPath],
version: &Version,
allow_noop_when_targets_exist: bool,
) -> Result<Vec<PathBuf>, String> {
let mut updated = 0usize;
let mut matched_targets = 0usize;
let mut updated_paths = Vec::new();
let mut errors = Vec::new();
for entry in targets {
let path = &entry.absolute;
if !path.exists() {
continue;
}
matched_targets += 1;
match write_version_to_resolved_path(entry, version) {
Ok(true) => {
updated += 1;
updated_paths.push(path.clone());
}
Ok(false) => {}
Err(error) => errors.push(format!("{}: {}", path.display(), error)),
}
}
if matched_targets == 0 && errors.is_empty() {
return Err("No selected version files were found to update.".to_string());
}
if !errors.is_empty() {
return Err(format!(
"Updated {} file(s), but some selected version targets failed:\n{}",
updated,
errors.join("\n")
));
}
if updated == 0 && allow_noop_when_targets_exist {
return Ok(updated_paths);
}
Ok(updated_paths)
}
fn initialize_release_ledger(
version_scope: &VersionScope,
repo: &str,
release_version: &Version,
tag_name: &str,
branch_name: Option<String>,
selection: &ChangedTargetSelection,
) -> ReleaseLedger {
ReleaseLedger {
scope_kind: version_scope_kind(version_scope).to_string(),
scope_label: version_scope_label(version_scope, repo),
scope_slug: current_scope_slug(version_scope, repo),
version: release_version.to_string(),
tag_name: tag_name.to_string(),
branch_name,
baseline: ReleaseBaselineRecord {
source: selection.baseline_source.clone(),
reference: selection.baseline_reference.clone(),
tag: selection.baseline_tag.clone(),
},
selection: ReleaseSelectionRecord {
changed_files: selection.changed_files.clone(),
version_targets: selection
.version_targets
.iter()
.map(|target| ReleaseVersionTarget {
path: target.relative.clone(),
})
.collect(),
publish_targets: selection.publish_targets.clone(),
},
domain: None,
cloudflare_evidence: Vec::new(),
status: ReleaseLedgerStatus::InProgress,
steps: BTreeMap::new(),
created_at: Some(chrono::Utc::now()),
updated_at: Some(chrono::Utc::now()),
completed_at: None,
}
}
fn update_ledger_step(
ledger: &mut ReleaseLedger,
step_name: &str,
status: ReleaseLedgerStepStatus,
details: Option<serde_json::Value>,
last_error: Option<String>,
) {
let now = chrono::Utc::now();
let step = ledger.steps.entry(step_name.to_string()).or_default();
if step.started_at.is_none() {
step.started_at = Some(now);
}
if matches!(
status,
ReleaseLedgerStepStatus::Completed
| ReleaseLedgerStepStatus::Failed
| ReleaseLedgerStepStatus::Skipped
| ReleaseLedgerStepStatus::VerifiedRemote
) {
step.completed_at = Some(now);
}
step.status = status;
if let Some(details) = details {
step.details = details;
}
step.last_error = last_error;
ledger.updated_at = Some(now);
ledger.status = match step.status {
ReleaseLedgerStepStatus::Failed => ReleaseLedgerStatus::Failed,
_ => ReleaseLedgerStatus::InProgress,
};
}
fn complete_release_ledger(ledger: &mut ReleaseLedger) {
let now = chrono::Utc::now();
ledger.status = ReleaseLedgerStatus::Completed;
ledger.updated_at = Some(now);
ledger.completed_at = Some(now);
}
fn ledger_version_targets_to_resolved(
project_root: &Path,
ledger: &ReleaseLedger,
) -> Vec<ResolvedRegistryPath> {
ledger
.selection
.version_targets
.iter()
.map(|target| ResolvedRegistryPath {
relative: target.path.clone(),
absolute: project_root.join(&target.path),
cargo_package_override: None,
})
.collect()
}
fn print_selected_release_targets(selection: &ChangedTargetSelection) {
println!("{}", "Selected release targets".bright_cyan().bold());
if selection.version_targets.is_empty() {
println!(" {}", "No version targets selected".dimmed());
} else {
println!(" {}", "Version targets".bright_white());
for target in &selection.version_targets {
println!(" {} {}", "•".bright_cyan(), target.relative);
}
}
if selection.publish_targets.is_empty() {
println!(" {}", "Publish targets: none".dimmed());
} else {
println!(" {}", "Publish targets".bright_white());
for target in &selection.publish_targets {
println!(
" {} {} {}",
"•".bright_cyan(),
target.kind,
target.manifest_path
);
}
}
}
fn begin_release_step(
ledger: &mut ReleaseLedger,
ledger_path: &Path,
step_name: &str,
details: Option<serde_json::Value>,
) -> Result<(), String> {
update_ledger_step(
ledger,
step_name,
ReleaseLedgerStepStatus::Running,
details,
None,
);
save_release_ledger(ledger_path, ledger)
}
fn complete_release_step(
ledger: &mut ReleaseLedger,
ledger_path: &Path,
step_name: &str,
status: ReleaseLedgerStepStatus,
details: Option<serde_json::Value>,
) -> Result<(), String> {
update_ledger_step(ledger, step_name, status, details, None);
save_release_ledger(ledger_path, ledger)
}
fn fail_release_step(
ledger: &mut ReleaseLedger,
ledger_path: &Path,
step_name: &str,
error: &str,
) -> Result<(), String> {
update_ledger_step(
ledger,
step_name,
ReleaseLedgerStepStatus::Failed,
None,
Some(error.to_string()),
);
save_release_ledger(ledger_path, ledger)
}
async fn verify_selected_publish_targets(
project_root: &Path,
selected_publish_targets: &[ReleasePublishTarget],
expected_version: &Version,
) -> Result<bool, String> {
if selected_publish_targets.is_empty() {
return Ok(false);
}
let client = reqwest::Client::new();
for target in selected_publish_targets {
let manifest_path = project_root.join(&target.manifest_path);
let package_name = target
.package_name
.clone()
.or_else(|| detect_package_name_from_manifest(&target.kind, &manifest_path))
.ok_or_else(|| {
format!(
"Could not resolve package name for selected publish target `{}`.",
target.manifest_path
)
})?;
let details = fetch_registry_latest_version(&client, &target.kind, &package_name).await?;
if details.version.trim() != expected_version.to_string() {
return Ok(false);
}
}
Ok(true)
}
fn detect_package_name_from_manifest(kind: &str, manifest_path: &Path) -> Option<String> {
match kind {
"npm" => {
let content = fs::read_to_string(manifest_path).ok()?;
let json: JsonValue = serde_json::from_str(&content).ok()?;
json.get("name")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
"crates" => cargo_package_name(manifest_path).ok().flatten(),
_ => None,
}
}
fn publish_target_filter_from_selection(
selected_publish_targets: &[ReleasePublishTarget],
) -> Result<Option<String>, String> {
let kinds = selected_publish_targets
.iter()
.map(|target| target.kind.as_str())
.collect::<BTreeSet<_>>();
if kinds.is_empty() || kinds.len() > 1 {
return Ok(None);
}
let kind = kinds.iter().next().copied().unwrap_or_default();
if kind.is_empty() {
return Ok(None);
}
match kind {
"npm" | "crates" => Ok(Some(kind.to_string())),
other => Err(format!(
"Unsupported selected publish target kind `{}` in release ledger.",
other
)),
}
}
fn collect_version_report(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> VersionReport {
let mut report: VersionReport = VersionReport::default();
let repo_label = project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repository");
let scope_slug = current_scope_slug(version_scope, repo_label);
report.worktree = collect_local_versions(
project_root,
invocation_dir,
registry,
version_scope,
&mut report.warnings,
);
match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
Ok(entries) => report.head = entries,
Err(err) => report.warnings.push(err),
}
match collect_git_versions(project_root, version_scope) {
Ok(tags) => report.local_tags = tags,
Err(err) => report.warnings.push(err),
}
match collect_remote_git_versions(project_root, "origin", version_scope) {
Ok(tags) => report.remote_tags = tags,
Err(err) => report.warnings.push(err),
}
match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
Ok(files) => report.dirty_files = files,
Err(err) => report.warnings.push(err),
}
match find_latest_unfinished_release_ledger(project_root, &scope_slug) {
Ok(ledger) => report.release_ledger = ledger,
Err(err) => report.warnings.push(err),
}
report
}
fn resolve_current_version_for_bump(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> Version {
let mut _warnings = Vec::new();
let local_versions = collect_local_versions(
project_root,
invocation_dir,
registry,
version_scope,
&mut _warnings,
);
let head_versions =
collect_head_versions(project_root, invocation_dir, registry, version_scope)
.unwrap_or_default();
let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
local_versions
.iter()
.map(|entry| entry.version.clone())
.chain(head_versions.iter().map(|entry| entry.version.clone()))
.chain(local_tags.iter().map(|entry| entry.version.clone()))
.max()
.unwrap_or_else(default_version)
}
async fn auto_commit_command_paths(
project_root: &Path,
paths: Vec<PathBuf>,
message: String,
action_label: &'static str,
) {
match commit_paths(AutoCommitRequest {
project_root,
paths,
message,
action_label,
})
.await
{
Ok(AutoCommitResult::Committed(_)) => {}
Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
Err(e) => print_skip(action_label, &e),
}
}
async fn collect_registry_versions(
project_root: &Path,
invocation_dir: &Path,
lookups: &[PackageNameLookup],
version_scope: &VersionScope,
warnings: &mut Vec<String>,
) -> Vec<RegistryVersionObservation> {
let mut entries: Vec<RegistryVersionObservation> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
let client: reqwest::Client = reqwest::Client::new();
for lookup in lookups {
let dedupe_key: String = format!(
"{}|{}|{}|{}",
lookup.file, lookup.format, lookup.key, lookup.registry
);
if !seen.insert(dedupe_key) {
continue;
}
let source_file = resolve_registry_relative_path(
project_root,
invocation_dir,
version_scope,
&lookup.file,
);
let path = project_root.join(&source_file);
if !path.exists() {
continue;
}
let content: String = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
warnings.push(format!("Failed to read {}: {}", path.display(), err));
continue;
}
};
let package_name: String = match read_package_name_from_lookup(lookup, &content) {
Ok(Some(value)) => value,
Ok(None) => continue,
Err(err) => {
warnings.push(format!("{}: {}", source_file, err));
continue;
}
};
let (latest, raw_version, published_at, note) =
match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
Ok(details) => {
let parsed: Option<Version> = parse_version(&details.version).ok();
let note: Option<String> = if parsed.is_none() {
Some(format!("Non-semver registry version: {}", details.version))
} else {
None
};
(parsed, Some(details.version), details.published_at, note)
}
Err(err) => (None, None, None, Some(err)),
};
entries.push(RegistryVersionObservation {
registry: lookup.registry.clone(),
package_name,
source_file,
latest,
raw_version,
published_at,
note,
});
}
entries.sort_by(|a, b| {
a.registry
.cmp(&b.registry)
.then_with(|| a.package_name.cmp(&b.package_name))
});
entries
}
fn read_package_name_from_lookup(
lookup: &PackageNameLookup,
content: &str,
) -> Result<Option<String>, String> {
let key_parts: Vec<&str> = lookup
.key
.split('.')
.map(|part| part.trim())
.filter(|part| !part.is_empty())
.collect();
if key_parts.is_empty() {
return Err("Lookup key cannot be empty".to_string());
}
let format: String = lookup.format.trim().to_ascii_lowercase();
match format.as_str() {
"json" => {
let value: JsonValue = serde_json::from_str(content)
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(json_lookup_string(&value, &key_parts))
}
"yaml" | "yml" => {
let value: YamlValue = serde_yaml::from_str(content)
.map_err(|e| format!("Failed to parse YAML: {}", e))?;
Ok(yaml_lookup_string(&value, &key_parts))
}
"toml" => {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
Ok(toml_lookup_string(&value, &key_parts))
}
other => Err(format!("Unsupported lookup format `{}`", other)),
}
}
async fn fetch_registry_latest_version(
client: &reqwest::Client,
registry: &str,
package_name: &str,
) -> Result<RegistryVersionDetails, String> {
let normalized_registry: String = registry.trim().to_ascii_lowercase();
match normalized_registry.as_str() {
"npm" => fetch_npm_latest_version(client, package_name).await,
"crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
_ => Err(format!("Unsupported registry `{}`", registry)),
}
}
#[derive(Debug, Deserialize)]
struct NpmLatestResponse {
#[serde(rename = "dist-tags")]
dist_tags: NpmDistTags,
#[serde(default)]
time: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct NpmDistTags {
latest: String,
}
async fn fetch_npm_latest_version(
client: &reqwest::Client,
package_name: &str,
) -> Result<RegistryVersionDetails, String> {
let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
.map_err(|e| format!("Failed to build npm URL: {}", e))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| "Failed to compose npm URL segments".to_string())?;
segments.push(package_name);
}
let response: reqwest::Response = client
.get(url)
.header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
.send()
.await
.map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
if !response.status().is_success() {
return Err(format!(
"npm lookup for {} returned status {}",
package_name,
response.status()
));
}
let payload: NpmLatestResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
Ok(RegistryVersionDetails {
published_at: payload.time.get(&payload.dist_tags.latest).cloned(),
version: payload.dist_tags.latest,
})
}
#[derive(Debug, Deserialize)]
struct CratesIoMeta {
newest_version: String,
}
#[derive(Debug, Deserialize)]
struct CratesIoVersionMeta {
num: String,
created_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CratesIoLatestResponse {
#[serde(rename = "crate")]
crate_meta: CratesIoMeta,
#[serde(default)]
versions: Vec<CratesIoVersionMeta>,
}
async fn fetch_crates_latest_version(
client: &reqwest::Client,
package_name: &str,
) -> Result<RegistryVersionDetails, String> {
let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
.map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
segments.push(package_name);
}
let response: reqwest::Response = client
.get(url)
.header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
.send()
.await
.map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
if !response.status().is_success() {
return Err(format!(
"crates.io lookup for {} returned status {}",
package_name,
response.status()
));
}
let payload: CratesIoLatestResponse = response.json().await.map_err(|e| {
format!(
"Failed to parse crates.io response for {}: {}",
package_name, e
)
})?;
let published_at = payload
.versions
.iter()
.find(|version| version.num == payload.crate_meta.newest_version)
.and_then(|version| version.created_at.clone());
Ok(RegistryVersionDetails {
version: payload.crate_meta.newest_version,
published_at,
})
}
fn collect_local_versions(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
warnings: &mut Vec<String>,
) -> Vec<VersionObservation> {
let mut observed = Vec::new();
for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
let path: &PathBuf = &entry.absolute;
if !path.exists() {
continue;
}
match read_version_from_resolved_path(&entry) {
Ok(Some(version)) => {
if let Ok(parsed) = parse_version(&version) {
observed.push(VersionObservation {
location: entry.relative.clone(),
version: parsed,
});
} else {
warnings.push(format!("Ignoring non-semver version in {}", path.display()));
}
}
Ok(None) => {}
Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
}
}
observed.sort_by(|a, b| a.location.cmp(&b.location));
observed
}
fn collect_git_versions(
project_root: &Path,
version_scope: &VersionScope,
) -> Result<Vec<GitTagObservation>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping git tag inspection.".to_string());
}
let output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(["tag", "--list"])
.output()
.map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err("`git tag --list` failed in the current directory.".to_string());
}
return Err(format!("`git tag --list` failed: {}", stderr));
}
Ok(parse_local_git_tag_output_for_scope(
&String::from_utf8_lossy(&output.stdout),
version_scope,
))
}
fn collect_remote_git_versions(
project_root: &Path,
remote: &str,
version_scope: &VersionScope,
) -> Result<Vec<GitTagObservation>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping remote tag inspection.".to_string());
}
let output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(["ls-remote", "--tags", remote])
.output()
.map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
if !output.status.success() {
let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!("`git ls-remote --tags {}` failed.", remote));
}
return Err(format!(
"`git ls-remote --tags {}` failed: {}",
remote, stderr
));
}
Ok(parse_remote_git_tag_output_for_scope(
&String::from_utf8_lossy(&output.stdout),
version_scope,
))
}
fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
if tags.is_empty() {
println!("No semantic git tags found in {}.", project_root.display());
return Ok(());
}
println!("Git versions from `git tag --list`:");
for tag in tags {
if tag.raw_tags.len() > 1 {
println!(" {} ({})", tag.version, tag.raw_tags.join(", "));
} else {
println!(" {}", tag.version);
}
}
Ok(())
}
fn print_version_observations(
title: &str,
entries: &[VersionObservation],
dirty_files: Option<&[String]>,
) {
println!();
println!("{}", title.bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
if entries.is_empty() {
println!(" {}", "none found".dimmed());
return;
}
let Some(highest) = highest_version_observation(entries) else {
println!(" {}", "none found".dimmed());
return;
};
let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
println!(
" {:<28} {} ({}/{})",
"latest".bright_white(),
highest.to_string().bright_green().bold(),
latest_count,
entries.len()
);
if stale_entries.is_empty() {
return;
}
println!(" {}", "stale entries".bright_yellow().bold());
for entry in stale_entries {
let dirty: bool = dirty_files
.map(|files| files.iter().any(|file| file == &entry.location))
.unwrap_or(false);
let dirty_suffix: String = if dirty {
format!(" {}", "modified".bright_magenta())
} else {
String::new()
};
println!(
" {:<28} {}{}",
entry.location.bright_white(),
entry.version.to_string().bright_green(),
dirty_suffix
);
}
}
fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
println!();
println!("{}", title.bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
if tags.is_empty() {
println!(" {}", "none found".dimmed());
return;
}
let latest = &tags[0];
if latest.raw_tags.len() > 1 {
println!(
" {:<20} {}",
latest.version.to_string().bright_green().bold(),
latest.raw_tags.join(", ").dimmed()
);
} else {
println!(" {}", latest.version.to_string().bright_green().bold());
}
if tags.len() > 1 {
let older = tags
.iter()
.skip(1)
.take(5)
.map(|tag| tag.version.to_string())
.collect::<Vec<_>>();
println!(
" {:<20} {}",
"older tags".bright_white(),
older.join(", ").dimmed()
);
if tags.len() > 6 {
println!(
" {:<20} {}",
"older hidden".bright_white(),
format!("{} more", tags.len() - 6).dimmed()
);
}
}
}
fn collect_head_versions(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> Result<Vec<VersionObservation>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
}
let head_check = Command::new("git")
.current_dir(project_root)
.args(["rev-parse", "--verify", "HEAD"])
.output()
.map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
if !head_check.status.success() {
return Ok(Vec::new());
}
let mut observed = Vec::new();
let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
continue;
};
match read_version_from_blob_with_override(
&entry.relative,
&content,
cargo_toml_content.as_deref(),
entry.cargo_package_override.as_deref(),
) {
Ok(Some(version)) => {
if let Ok(parsed) = parse_version(&version) {
observed.push(VersionObservation {
location: entry.relative.clone(),
version: parsed,
});
}
}
Ok(None) => {}
Err(_) => {}
}
}
observed.sort_by(|a, b| a.location.cmp(&b.location));
Ok(observed)
}
fn collect_dirty_version_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> Result<Vec<String>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping worktree status inspection.".to_string());
}
let mut args = vec!["status", "--porcelain", "--"];
let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
for entry in &resolved {
args.push(entry.relative.as_str());
}
let output = Command::new("git")
.current_dir(project_root)
.args(&args)
.output()
.map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err("`git status --porcelain` failed.".to_string());
}
return Err(format!("`git status --porcelain` failed: {}", stderr));
}
let mut dirty = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.len() < 4 {
continue;
}
let path = line[3..].trim();
if !path.is_empty() {
dirty.push(path.replace('\\', "/"));
}
}
dirty.sort();
dirty.dedup();
Ok(dirty)
}
fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
let output = Command::new("git")
.current_dir(project_root)
.args(["show", &format!("HEAD:{}", relative)])
.output()
.map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!("{} is not present in HEAD", relative));
}
return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
}
String::from_utf8(output.stdout)
.map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
}
fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let tag = line.trim();
if tag.is_empty() {
continue;
}
if let Ok(version) = parse_version(tag) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
fn parse_local_git_tag_output_for_scope(
output: &str,
version_scope: &VersionScope,
) -> Vec<GitTagObservation> {
match version_scope {
VersionScope::Repository => parse_local_git_tag_output(output),
VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
parse_scoped_git_tag_output(output, tag_prefix)
}
}
}
fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
let tag = reference
.strip_prefix("refs/tags/")
.unwrap_or(reference)
.trim_end_matches("^{}")
.trim();
if tag.is_empty() {
continue;
}
if let Ok(version) = parse_version(tag) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
fn parse_remote_git_tag_output_for_scope(
output: &str,
version_scope: &VersionScope,
) -> Vec<GitTagObservation> {
match version_scope {
VersionScope::Repository => parse_remote_git_tag_output(output),
VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
let tag = reference
.strip_prefix("refs/tags/")
.unwrap_or(reference)
.trim_end_matches("^{}")
.trim();
if tag.is_empty() {
continue;
}
if let Some(version) = parse_release_family_version(tag, tag_prefix) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
}
}
fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let tag = line.trim();
if tag.is_empty() {
continue;
}
if let Some(version) = parse_release_family_version(tag, tag_prefix) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
let mut versions: Vec<GitTagObservation> = by_version
.into_iter()
.map(|(version, mut raw_tags)| {
raw_tags.sort();
raw_tags.dedup();
GitTagObservation { version, raw_tags }
})
.collect();
versions.sort_by(|a, b| b.version.cmp(&a.version));
versions
}
#[cfg(test)]
fn read_version_from_blob(
relative: &str,
content: &str,
cargo_toml_content: Option<&str>,
) -> Result<Option<String>, String> {
read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
}
fn read_version_from_blob_with_override(
relative: &str,
content: &str,
cargo_toml_content: Option<&str>,
cargo_package_override: Option<&str>,
) -> Result<Option<String>, String> {
let file_name = Path::new(relative)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
match file_name {
"README.md" => read_readme_version_from_content(content),
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
read_openapi_version_from_content(content)
}
"openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
"package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
| "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
"deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
"Cargo.toml" => read_cargo_toml_version_from_content(content),
"Cargo.lock" => read_cargo_lock_version_from_content_with_package(
content,
cargo_toml_content,
cargo_package_override,
),
"pyproject.toml" => read_pyproject_version_from_content(content),
"Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
"xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
"pom.xml" => {
read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
}
"build.gradle" | "build.gradle.kts" => {
read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
}
"mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
_ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
Some("json") => read_json_root_version_from_content(content),
Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
Some("toml") => read_toml_root_version_from_content(content),
Some("md") => read_readme_version_from_content(content),
_ => Ok(None),
},
}
}
fn print_version_report(project_root: &Path, report: &VersionReport) {
let dirty_suffix = if report.dirty_files.is_empty() {
"clean".green().to_string()
} else {
format!("dirty ({})", report.dirty_files.len())
.bright_magenta()
.to_string()
};
println!(
"\n{} {}",
"Version Summary".bright_cyan().bold(),
project_root.display().to_string().bright_white()
);
println!("{}", "─".repeat(72).bright_black());
println!(
"{:<20} {}",
"Highest available".bright_white(),
report.highest_available().to_string().bright_green().bold()
);
println!(
"{:<20} {}",
"Worktree".bright_white(),
report
.highest_worktree()
.unwrap_or_else(default_version)
.to_string()
.bright_yellow()
);
println!(
"{:<20} {}",
"Committed HEAD".bright_white(),
report
.highest_head()
.map(|v| v.to_string())
.unwrap_or_else(|| "none".dimmed().to_string())
);
println!(
"{:<20} {}",
"GitHub tags".bright_white(),
report
.highest_remote_tag()
.map(|v| v.to_string())
.unwrap_or_else(|| "none".dimmed().to_string())
);
println!(
"{:<20} {}",
"Registry latest".bright_white(),
report
.highest_registry()
.map(|v| v.to_string())
.unwrap_or_else(|| "none".dimmed().to_string())
);
println!(
"{:<20} {}",
"Local tags".bright_white(),
report
.highest_local_tag()
.map(|v| v.to_string())
.unwrap_or_else(|| "none".dimmed().to_string())
);
if let Some(ledger) = &report.release_ledger {
println!(
"{:<20} {} {}",
"Release ledger".bright_white(),
ledger.version.bright_yellow(),
format!("{:?}", ledger.status).bright_cyan()
);
}
println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
if let Some(review) = summarize_version_dirty_files(project_root, &report.dirty_files) {
println!(
"{:<20} {}",
"Suggested scope".bright_white(),
review.scope_label.bright_cyan()
);
if !review.groups.is_empty() {
println!("{:<20} {}", "Why".bright_white(), review.summary);
for group in review.groups {
println!("{:<20} {}", "", group);
}
}
}
print_version_observations(
"Worktree version files",
&report.worktree,
Some(&report.dirty_files),
);
print_version_observations("Committed HEAD version files", &report.head, None);
print_registry_observations("Published package versions", &report.registry_versions);
print_git_tag_observations("GitHub tags", &report.remote_tags);
print_git_tag_observations("Local git tags", &report.local_tags);
let divergent = report.divergent_versions();
let highest = report.highest_available();
let outdated: Vec<_> = divergent
.into_iter()
.filter(|version| version != &highest)
.collect();
println!();
println!("{}", "Divergence".bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
println!(
" {:<20} {}",
"latest target".bright_white(),
highest.to_string().bright_green().bold()
);
if !outdated.is_empty() {
for version in outdated {
println!(
" {} {}",
"•".bright_yellow(),
version.to_string().bright_yellow()
);
}
println!();
println!(
"{} {}",
"Fix local files with".bright_white(),
format!("xbp version {}", highest).black().on_bright_green()
);
} else {
println!(" {}", "all relevant sources are aligned".green());
}
if !report.warnings.is_empty() {
println!();
println!("{}", "Warnings".bright_yellow().bold());
println!("{}", "─".repeat(72).bright_black());
for warning in &report.warnings {
println!(" {} {}", "!".bright_yellow(), warning);
}
}
}
struct VersionDirtyFileReview {
scope_label: String,
summary: String,
groups: Vec<String>,
}
fn summarize_version_dirty_files(
project_root: &Path,
dirty_files: &[String],
) -> Option<VersionDirtyFileReview> {
if dirty_files.is_empty() {
return None;
}
let mut crate_names = BTreeSet::new();
let mut service_names = BTreeSet::new();
let mut touches_workspace_files = false;
let mut workspace_files = Vec::new();
let mut crate_files = Vec::new();
let mut service_files = Vec::new();
let mut other_files = Vec::new();
for file in dirty_files {
let path = Path::new(file);
if path == Path::new("Cargo.toml")
|| path == Path::new("Cargo.lock")
|| path == Path::new("README.md")
|| path == Path::new("CHANGELOG.md")
|| path == Path::new("SECURITY.md")
{
touches_workspace_files = true;
workspace_files.push(file.clone());
continue;
}
if let Some((crate_name, service_name)) = infer_version_scope_name(project_root, path) {
if let Some(crate_name) = crate_name {
crate_names.insert(crate_name);
crate_files.push(file.clone());
}
if let Some(service_name) = service_name {
service_names.insert(service_name);
service_files.push(file.clone());
}
} else {
other_files.push(file.clone());
}
}
let scope_label = if !crate_names.is_empty() && service_names.is_empty() {
if crate_names.len() == 1 {
format!("crate `{}`", crate_names.iter().next().unwrap())
} else {
format!("{} crates", crate_names.len())
}
} else if !service_names.is_empty() && crate_names.is_empty() {
if service_names.len() == 1 {
format!("service `{}`", service_names.iter().next().unwrap())
} else {
format!("{} services", service_names.len())
}
} else if touches_workspace_files {
"repository / main crates".to_string()
} else {
"repository".to_string()
};
let summary = if dirty_files.len() <= 8 {
format!("{} file(s): {}", dirty_files.len(), dirty_files.join(", "))
} else {
format!(
"{} file(s): {} ...",
dirty_files.len(),
dirty_files
.iter()
.take(8)
.cloned()
.collect::<Vec<_>>()
.join(", ")
)
};
let mut groups = Vec::new();
if !workspace_files.is_empty() {
groups.push(format!(
"Workspace files: {}",
summarize_file_group(&workspace_files)
));
}
if !crate_files.is_empty() {
groups.push(format!(
"Crate files: {}",
summarize_file_group(&crate_files)
));
}
if !service_files.is_empty() {
groups.push(format!(
"Service files: {}",
summarize_file_group(&service_files)
));
}
if !other_files.is_empty() {
groups.push(format!(
"Other files: {}",
summarize_file_group(&other_files)
));
}
Some(VersionDirtyFileReview {
scope_label,
summary,
groups,
})
}
fn summarize_file_group(files: &[String]) -> String {
if files.is_empty() {
return String::new();
}
if files.len() <= 4 {
return files.join(", ");
}
format!(
"{} (and {} more)",
files.iter().take(4).cloned().collect::<Vec<_>>().join(", "),
files.len() - 4
)
}
fn infer_version_scope_name(
project_root: &Path,
path: &Path,
) -> Option<(Option<String>, Option<String>)> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
project_root.join(path)
};
let relative = absolute.strip_prefix(project_root).ok()?;
let mut components = relative.components();
let first = components.next()?.as_os_str().to_string_lossy().to_string();
let second = components
.next()
.map(|value| value.as_os_str().to_string_lossy().to_string());
if first == "crates" {
return second.map(|crate_name| (Some(crate_name), None));
}
if first == "apps" {
return second.map(|service_name| (None, Some(service_name)));
}
None
}
fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
entries.iter().map(|entry| entry.version.clone()).max()
}
fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
let Some(highest) = highest_version_observation(entries) else {
return Vec::new();
};
entries
.iter()
.filter(|entry| entry.version < highest)
.collect()
}
fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
println!();
println!("{}", title.bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
if entries.is_empty() {
println!(" {}", "none found".dimmed());
return;
}
for entry in entries {
let latest_display = match (&entry.latest, &entry.raw_version) {
(Some(version), _) => version.to_string().bright_green().to_string(),
(None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
(None, None) => "unavailable".dimmed().to_string(),
};
let note = entry
.note
.as_ref()
.map(|value| format!(" {}", value.bright_yellow()))
.unwrap_or_default();
let published_at = entry
.published_at
.as_deref()
.map(|value| format!(" {}", format!("published {value}").dimmed()))
.unwrap_or_default();
println!(
" {:<9} {:<28} {:<16} {}{}{}",
entry.registry.bright_white(),
entry.package_name.bright_white(),
latest_display,
entry.source_file.dimmed(),
published_at,
note
);
}
}
#[cfg(test)]
fn write_version_to_configured_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
version: &Version,
) -> Result<usize, String> {
write_version_to_configured_files_with_paths(
project_root,
invocation_dir,
registry,
version_scope,
version,
)
.map(|paths| paths.len())
}
fn write_version_to_configured_files_with_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
version: &Version,
) -> Result<Vec<PathBuf>, String> {
write_version_to_configured_files_with_paths_internal(
project_root,
invocation_dir,
registry,
version_scope,
version,
false,
)
}
#[cfg(test)]
fn sync_version_to_configured_files_with_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
version: &Version,
) -> Result<Vec<PathBuf>, String> {
write_version_to_configured_files_with_paths_internal(
project_root,
invocation_dir,
registry,
version_scope,
version,
true,
)
}
fn write_version_to_configured_files_with_paths_internal(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
version: &Version,
allow_noop_when_targets_exist: bool,
) -> Result<Vec<PathBuf>, String> {
let mut updated = 0usize;
let mut matched_targets = 0usize;
let mut updated_paths = Vec::new();
let mut errors = Vec::new();
for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
let path = &entry.absolute;
if !path.exists() {
continue;
}
matched_targets += 1;
match write_version_to_resolved_path(&entry, version) {
Ok(true) => {
updated += 1;
updated_paths.push(path.clone());
}
Ok(false) => {}
Err(err) => errors.push(format!("{}: {}", path.display(), err)),
}
}
if matched_targets == 0 && errors.is_empty() {
return Err("No configured version files were found to update.".to_string());
}
if !errors.is_empty() {
return Err(format!(
"Updated {} file(s), but some version targets failed:\n{}",
updated,
errors.join("\n")
));
}
if updated == 0 && allow_noop_when_targets_exist {
return Ok(updated_paths);
}
Ok(updated_paths)
}
fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
match file_name {
"README.md" => read_readme_version(path),
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
read_openapi_version(path)
}
"openapi.json" | "swagger.json" => read_json_openapi_version(path),
"package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
| "xbp.json" => read_json_root_version(path),
"deno.json" => read_json_root_version(path),
"deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
"Cargo.toml" => read_cargo_toml_version(path),
"Cargo.lock" => read_cargo_lock_version(path),
"pyproject.toml" => read_pyproject_version(path),
"Chart.yaml" => read_yaml_root_version(path, "version"),
"xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
"pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
"build.gradle" | "build.gradle.kts" => {
read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
}
"mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
_ => match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => read_json_root_version(path),
Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
Some("toml") => read_toml_root_version(path),
Some("md") => read_readme_version(path),
_ => Ok(None),
},
}
}
fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
let path = &entry.absolute;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if file_name == "Cargo.lock" {
if let Some(package_name) = entry.cargo_package_override.as_deref() {
return read_cargo_lock_version_for_package(path, package_name);
}
}
read_version_from_path(path)
}
fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
match file_name {
"README.md" => write_readme_version(path, version).map(|_| true),
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
write_openapi_version(path, version).map(|_| true)
}
"openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
"package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
| "xbp.json" => write_json_root_version(path, version).map(|_| true),
"deno.json" => write_json_root_version(path, version).map(|_| true),
"deno.jsonc" => {
write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
}
"Cargo.toml" => write_cargo_toml_version(path, version),
"Cargo.lock" => write_cargo_lock_version(path, version),
"pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
"Chart.yaml" => write_chart_version(path, version).map(|_| true),
"xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
"pom.xml" => {
write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
}
"build.gradle" | "build.gradle.kts" => {
write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
.map(|_| true)
}
"mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
_ => match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => write_json_root_version(path, version).map(|_| true),
Some("yaml") | Some("yml") => {
write_yaml_root_version(path, "version", version).map(|_| true)
}
Some("toml") => write_toml_root_version(path, version).map(|_| true),
Some("md") => write_readme_version(path, version).map(|_| true),
_ => Err("Unsupported version file type".to_string()),
},
}
}
fn write_version_to_resolved_path(
entry: &ResolvedRegistryPath,
version: &Version,
) -> Result<bool, String> {
let path = &entry.absolute;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if file_name == "Cargo.lock" {
if let Some(package_name) = entry.cargo_package_override.as_deref() {
return write_cargo_lock_version_for_package(path, Some(package_name), version);
}
}
write_version_to_path(path, version)
}
fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: JsonValue =
serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(value
.get("version")
.and_then(JsonValue::as_str)
.map(|value| value.to_string()))
}
fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_json_root_version_from_content(&content)
}
fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: JsonValue =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
let object = value
.as_object_mut()
.ok_or_else(|| "Expected a JSON object".to_string())?;
object.insert(
"version".to_string(),
JsonValue::String(version.to_string()),
);
fs::write(
path,
serde_json::to_string_pretty(&value)
.map_err(|e| format!("Failed to serialize JSON: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
let value: YamlValue =
serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
Ok(yaml_get_string(&value, key))
}
fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_yaml_root_version_from_content(&content, key)
}
fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mapping = yaml_root_mapping_mut(&mut value)?;
mapping.insert(
YamlValue::String(key.to_string()),
YamlValue::String(version.to_string()),
);
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: YamlValue =
serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let info = yaml_get_mapping(&value, "info");
Ok(info.and_then(|mapping| {
mapping
.get(YamlValue::String("version".to_string()))
.and_then(YamlValue::as_str)
.map(|value| value.to_string())
}))
}
fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_openapi_version_from_content(&content)
}
fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: JsonValue =
serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(value
.get("info")
.and_then(JsonValue::as_object)
.and_then(|info| info.get("version"))
.and_then(JsonValue::as_str)
.map(|value| value.to_string()))
}
fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_json_openapi_version_from_content(&content)
}
fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
let info_key: YamlValue = YamlValue::String("info".to_string());
if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
}
let info: &mut YamlMapping = root
.get_mut(&info_key)
.and_then(YamlValue::as_mapping_mut)
.ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
info.insert(
YamlValue::String("version".to_string()),
YamlValue::String(version.to_string()),
);
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: JsonValue =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
let root = value
.as_object_mut()
.ok_or_else(|| "Expected a JSON object".to_string())?;
let info = root
.entry("info".to_string())
.or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
let info_object = info
.as_object_mut()
.ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
info_object.insert(
"version".to_string(),
JsonValue::String(version.to_string()),
);
fs::write(
path,
serde_json::to_string_pretty(&value)
.map_err(|e| format!("Failed to serialize JSON: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
Ok(value
.get("version")
.and_then(TomlValue::as_str)
.map(|value| value.to_string()))
}
fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_toml_root_version_from_content(&content)
}
fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let table = value
.as_table_mut()
.ok_or_else(|| "Expected a TOML table".to_string())?;
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
fs::write(
path,
toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
crate::utils::cargo_manifest::resolve_cargo_package_version_from_content(content)
}
fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
resolve_cargo_package_version(path)
}
fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
write_cargo_package_version(path, version)
}
fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let project_version = value
.get("project")
.and_then(TomlValue::as_table)
.and_then(|project| project.get("version"))
.and_then(TomlValue::as_str);
let poetry_version = value
.get("tool")
.and_then(TomlValue::as_table)
.and_then(|tool| tool.get("poetry"))
.and_then(TomlValue::as_table)
.and_then(|poetry| poetry.get("version"))
.and_then(TomlValue::as_str);
Ok(project_version
.or(poetry_version)
.map(|value| value.to_string()))
}
fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_pyproject_version_from_content(&content)
}
fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
project.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
} else if let Some(poetry) = value
.get_mut("tool")
.and_then(TomlValue::as_table_mut)
.and_then(|tool| tool.get_mut("poetry"))
.and_then(TomlValue::as_table_mut)
{
poetry.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
} else {
let table: &mut toml::map::Map<String, TomlValue> = value
.as_table_mut()
.ok_or_else(|| "Expected a TOML table".to_string())?;
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
}
fs::write(
path,
toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_cargo_lock_version_from_content(
content: &str,
cargo_toml_content: Option<&str>,
) -> Result<Option<String>, String> {
read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
}
fn read_cargo_lock_version_from_content_with_package(
content: &str,
cargo_toml_content: Option<&str>,
package_name_override: Option<&str>,
) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let package_name = if let Some(package_name_override) = package_name_override {
package_name_override.trim().to_string()
} else {
let cargo_toml_content = cargo_toml_content
.ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
cargo_package_name_from_content(cargo_toml_content)?
};
Ok(value
.get("package")
.and_then(TomlValue::as_array)
.and_then(|packages| {
packages.iter().find_map(|package| {
let table = package.as_table()?;
if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
table
.get("version")
.and_then(TomlValue::as_str)
.map(|value| value.to_string())
} else {
None
}
})
}))
}
fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let cargo_toml: String = fs::read_to_string(
path.parent()
.unwrap_or_else(|| Path::new("."))
.join("Cargo.toml"),
)
.map_err(|e| format!("Failed to read file: {}", e))?;
read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
}
fn read_cargo_lock_version_for_package(
path: &Path,
package_name: &str,
) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
}
fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
write_cargo_lock_version_for_package(path, None, version)
}
fn write_cargo_lock_version_for_package(
path: &Path,
package_name_override: Option<&str>,
version: &Version,
) -> Result<bool, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let package_name = if let Some(package_name_override) = package_name_override {
package_name_override.trim().to_string()
} else {
let Some(package_name) = cargo_package_name(path)? else {
return Ok(false);
};
package_name
};
let packages: &mut Vec<TomlValue> = value
.get_mut("package")
.and_then(TomlValue::as_array_mut)
.ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
let mut updated = false;
for package in packages {
if let Some(table) = package.as_table_mut() {
if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
updated = true;
}
}
}
if !updated {
return Err(format!(
"Could not find package `{}` in Cargo.lock",
package_name
));
}
fs::write(
path,
toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(true)
}
fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
let cargo_toml: PathBuf = path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("Cargo.toml");
let content: String = fs::read_to_string(&cargo_toml)
.map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
cargo_package_name_from_content_optional(&content)
}
fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
cargo_package_name_from_content_optional(content)?
.ok_or_else(|| "Could not determine Cargo package name".to_string())
}
fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
Ok(value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("name"))
.and_then(TomlValue::as_str)
.map(|value| value.to_string()))
}
fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
}
fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_readme_version_from_content(&content)
}
fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let marker: String = format!("current version: `{}`", version);
let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
.map_err(|e| format!("Failed to build README regex: {}", e))?;
let updated: String = if regex.is_match(&content) {
regex.replace(&content, marker.as_str()).to_string()
} else if let Some(first_break) = content.find('\n') {
let mut next = String::new();
next.push_str(&content[..=first_break]);
next.push('\n');
next.push_str(&marker);
next.push('\n');
next.push_str(&content[first_break + 1..]);
next
} else {
format!("{}\n\n{}\n", content, marker)
};
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
}
fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_regex_version_from_content(&content, pattern)
}
fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
Ok(regex
.captures(content)
.and_then(|captures| captures.get(1))
.map(|matched| matched.as_str().trim().to_string()))
}
fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
if !regex.is_match(&content) {
return Err("No version pattern found".to_string());
}
let updated: String = regex
.replace(&content, |caps: ®ex::Captures<'_>| {
caps[0].replace(&caps[1], &version.to_string())
})
.to_string();
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
}
#[cfg(test)]
fn write_package_version_to_configured_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
package_name: &str,
version: &Version,
) -> Result<usize, String> {
write_package_version_to_configured_files_with_paths(
project_root,
invocation_dir,
registry,
version_scope,
package_name,
version,
)
.map(|paths| paths.len())
}
fn write_package_version_to_configured_files_with_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
package_name: &str,
version: &Version,
) -> Result<Vec<PathBuf>, String> {
let mut updated: usize = 0usize;
let mut updated_paths = Vec::new();
let mut errors: Vec<String> = Vec::new();
for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
let path: &PathBuf = &entry.absolute;
if !path.exists() {
continue;
}
match write_package_version_to_path(path, package_name, version) {
Ok(true) => {
updated += 1;
updated_paths.push(path.clone());
}
Ok(false) => {}
Err(err) => errors.push(format!("{}: {}", path.display(), err)),
}
}
if updated == 0 && errors.is_empty() {
return Err(format!(
"No configured TOML files contained package assignment `{}`.",
package_name
));
}
if !errors.is_empty() {
return Err(format!(
"Updated {} file(s), but some package version targets failed:\n{}",
updated,
errors.join("\n")
));
}
Ok(updated_paths)
}
fn write_package_version_to_path(
path: &Path,
package_name: &str,
version: &Version,
) -> Result<bool, String> {
let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
if !is_toml {
return Ok(false);
}
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let (updated, changed) =
rewrite_toml_package_assignment_versions(&content, package_name, version)?;
if changed {
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
}
Ok(changed)
}
fn rewrite_toml_package_assignment_versions(
content: &str,
package_name: &str,
version: &Version,
) -> Result<(String, bool), String> {
let escaped_name: String = regex::escape(package_name);
let inline_pattern: String = format!(
r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
escaped_name
);
let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
let inline_regex: Regex =
Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
let string_regex: Regex =
Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
let replacement: String = version.to_string();
let after_inline: String = inline_regex
.replace_all(content, |caps: ®ex::Captures<'_>| {
format!("{}{}{}", &caps[1], replacement, &caps[3])
})
.to_string();
let after_string: String = string_regex
.replace_all(&after_inline, |caps: ®ex::Captures<'_>| {
format!("{}{}{}", &caps[1], replacement, &caps[3])
})
.to_string();
Ok((after_string.clone(), after_string != content))
}
fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
mapping.insert(
YamlValue::String("version".to_string()),
YamlValue::String(version.to_string()),
);
if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
mapping.insert(
YamlValue::String("appVersion".to_string()),
YamlValue::String(version.to_string()),
);
}
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
value
.as_mapping_mut()
.ok_or_else(|| "Expected a YAML mapping".to_string())
}
fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
value
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
.and_then(YamlValue::as_mapping)
}
fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
value
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
.and_then(YamlValue::as_str)
.map(|value| value.to_string())
}
fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
let mut current: &JsonValue = value;
for part in key_parts {
current = current.get(*part)?;
}
current.as_str().map(|value| value.to_string())
}
fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
let mut current = value;
for part in key_parts {
let mapping = current.as_mapping()?;
current = mapping.get(YamlValue::String((*part).to_string()))?;
}
current.as_str().map(|value| value.to_string())
}
fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
let mut current = value;
for part in key_parts {
current = current.get(*part)?;
}
current.as_str().map(|value| value.to_string())
}
fn parse_version(input: &str) -> Result<Version, String> {
let trimmed: &str = input.trim();
let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
}
fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("Release version cannot be empty.".to_string());
}
if let Ok(version) = parse_version(trimmed) {
return Ok((version.clone(), format!("v{}", version)));
}
let prefixed = Regex::new(
r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
)
.map_err(|e| format!("Failed to build release target parser: {}", e))?;
if let Some(captures) = prefixed.captures(trimmed) {
let semver = captures
.name("semver")
.map(|m| m.as_str())
.ok_or_else(|| format!("Invalid release target `{}`.", input))?;
let version = Version::parse(semver)
.map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
return Ok((version, trimmed.to_string()));
}
Err(format!(
"Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
input
))
}
fn normalize_release_flag(flag: &str) -> Result<String, String> {
let normalized = flag.trim().to_ascii_lowercase();
match normalized.as_str() {
"dev" | "stable" | "beta" | "alpha" | "nightly" | "exp" => Ok(normalized),
_ => Err(format!(
"Invalid release flag `{}`. Use one of: dev, stable, beta, alpha, nightly, exp.",
flag
)),
}
}
fn apply_release_flag_to_version(
mut version: Version,
flag: Option<&str>,
) -> Result<Version, String> {
let Some(flag) = flag else {
return Ok(version);
};
let flag = normalize_release_flag(flag)?;
if version.build.is_empty() {
version.build = semver::BuildMetadata::new(&flag)
.map_err(|error| format!("Invalid release flag `{}`: {}", flag, error))?;
return Ok(version);
}
if version.build.as_str() == flag {
return Ok(version);
}
Err(format!(
"Release version `{}` already has build metadata `+{}`; remove `--flag {}` or pass a matching flag.",
version,
version.build,
flag
))
}
fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
let Some((raw_package, raw_version)) = input.split_once('=') else {
return Ok(None);
};
let package_name = raw_package.trim();
if package_name.is_empty() {
return Ok(None);
}
let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
.map_err(|e| format!("Failed to build package-name validator: {}", e))?;
if !package_name_regex.is_match(package_name) {
return Err(format!(
"Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
input
));
}
let version = parse_version(raw_version.trim())?;
Ok(Some((package_name.to_string(), version)))
}
fn bump_version(current: &Version, kind: &str) -> Version {
let mut next = current.clone();
match kind {
"major" => {
next.major += 1;
next.minor = 0;
next.patch = 0;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
"minor" => {
next.minor += 1;
next.patch = 0;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
_ => {
next.patch += 1;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
}
next
}
fn default_version() -> Version {
Version::new(0, 1, 0)
}
fn resolve_registry_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> Vec<ResolvedRegistryPath> {
let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
let nested_repository_scope = is_nested_repository_scope(project_root, version_scope);
let workspace_primary_target = match version_scope {
VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
VersionScope::Crate { .. } | VersionScope::Service { .. } => None,
};
for relative in registry {
match version_scope {
VersionScope::Repository => {
if *relative == *"Cargo.lock" {
let resolved_relative = resolve_registry_relative_path(
project_root,
invocation_dir,
version_scope,
relative,
);
let absolute = project_root.join(&resolved_relative);
if nested_repository_scope && !absolute.exists() {
continue;
}
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute,
relative: resolved_relative,
cargo_package_override: workspace_primary_target
.as_ref()
.map(|target| target.package_name.clone()),
});
continue;
}
if *relative == *"Cargo.toml" {
if let Some(target) = workspace_primary_target.as_ref() {
if !seen.insert(target.manifest_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: target.manifest_absolute.clone(),
relative: target.manifest_relative.clone(),
cargo_package_override: None,
});
continue;
}
}
let resolved_relative = resolve_registry_relative_path(
project_root,
invocation_dir,
version_scope,
relative,
);
let absolute = project_root.join(&resolved_relative);
if nested_repository_scope && !absolute.exists() {
continue;
}
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute,
relative: resolved_relative,
cargo_package_override: None,
});
}
VersionScope::Crate {
crate_root,
package_name,
..
} => {
if *relative == *"Cargo.lock" {
let cargo_lock = project_root.join("Cargo.lock");
if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
resolved.push(ResolvedRegistryPath {
absolute: cargo_lock,
relative: "Cargo.lock".to_string(),
cargo_package_override: Some(package_name.clone()),
});
}
continue;
}
let preferred = crate_root.join(relative);
if !preferred.exists() {
continue;
}
let Ok(stripped) = preferred.strip_prefix(project_root) else {
continue;
};
let resolved_relative = normalized_relative_path(stripped);
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: preferred,
relative: resolved_relative,
cargo_package_override: None,
});
}
VersionScope::Service {
service_root,
cargo_package_name,
..
} => {
if *relative == *"Cargo.lock" {
let local_cargo_lock = service_root.join("Cargo.lock");
if local_cargo_lock.exists() {
let relative_path = local_cargo_lock
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| normalized_relative_path(&local_cargo_lock));
if seen.insert(relative_path.clone()) {
resolved.push(ResolvedRegistryPath {
absolute: local_cargo_lock,
relative: relative_path,
cargo_package_override: cargo_package_name.clone(),
});
}
} else if let Some(package_name) = cargo_package_name.as_ref() {
let workspace_cargo_lock = project_root.join("Cargo.lock");
if workspace_cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
resolved.push(ResolvedRegistryPath {
absolute: workspace_cargo_lock,
relative: "Cargo.lock".to_string(),
cargo_package_override: Some(package_name.clone()),
});
}
}
continue;
}
let preferred = service_root.join(relative);
if !preferred.exists() {
continue;
}
let Ok(stripped) = preferred.strip_prefix(project_root) else {
continue;
};
let resolved_relative = normalized_relative_path(stripped);
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: preferred,
relative: resolved_relative,
cargo_package_override: None,
});
}
}
}
for configured_path in
resolve_configured_version_target_paths(project_root, invocation_dir, version_scope)
{
if seen.insert(configured_path.relative.clone()) {
resolved.push(configured_path);
}
}
resolved
}
fn resolve_configured_version_target_paths(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
) -> Vec<ResolvedRegistryPath> {
let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
return Vec::new();
};
let service_targets = match version_scope {
VersionScope::Service {
version_targets, ..
} if !version_targets.is_empty() => Some(version_targets.clone()),
_ => None,
};
let manifest_paths = if let Some(service_targets) = &service_targets {
service_targets
.iter()
.cloned()
.map(PathBuf::from)
.collect::<Vec<_>>()
} else {
let mut targets = config
.version_targets
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
targets.extend(
config
.publish
.into_iter()
.flat_map(|publish| [publish.npm, publish.crates])
.flatten()
.filter_map(|target| target.manifest_path)
.map(PathBuf::from),
);
targets
};
let scope_root = match version_scope {
VersionScope::Repository => None,
VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
};
manifest_paths
.into_iter()
.filter_map(|manifest_path| {
let absolute = if manifest_path.is_absolute() {
manifest_path
} else {
config_root.join(manifest_path)
};
if !absolute.exists() {
return None;
}
if let Some(scope_root) = scope_root {
if !absolute.starts_with(scope_root) {
return None;
}
}
let relative = absolute
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| normalized_relative_path(&absolute));
Some(ResolvedRegistryPath {
relative,
absolute,
cargo_package_override: None,
})
})
.collect()
}
fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
let found = find_xbp_config_upwards(invocation_dir)?;
let config_path = if found.kind == "json" {
maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
.ok()
.flatten()
.unwrap_or_else(|| found.config_path.clone())
} else {
found.config_path.clone()
};
let kind = if config_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
.unwrap_or(false)
{
"yaml"
} else {
"json"
};
let content = fs::read_to_string(&config_path).ok()?;
let (mut config, healed): (XbpConfig, Option<String>) =
parse_config_with_auto_heal(&content, kind).ok()?;
if let Some(healed_content) = healed {
let _ = fs::write(&config_path, healed_content);
}
resolve_config_paths_for_runtime(&mut config, &found.project_root);
Some((found.project_root, config))
}
fn resolve_registry_relative_path(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
relative: &str,
) -> String {
if let Some(scope_root) = version_scope_root(version_scope) {
let preferred = scope_root.join(relative);
if preferred.exists() {
if let Ok(stripped) = preferred.strip_prefix(project_root) {
return normalized_relative_path(stripped);
}
}
return relative.replace('\\', "/");
}
if relative == "Cargo.toml" {
if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
return target.manifest_relative;
}
}
let preferred: PathBuf = invocation_dir.join(relative);
if preferred.exists() {
if let Ok(stripped) = preferred.strip_prefix(project_root) {
return normalized_relative_path(stripped);
}
}
relative.replace('\\', "/")
}
fn resolve_version_scope_with_prompt(
project_root: &Path,
invocation_dir: &Path,
) -> Result<VersionScope, String> {
let service_scopes = load_service_version_scopes(project_root, invocation_dir);
if let Some(scope) =
select_matching_service_scope(&service_scopes, project_root, invocation_dir)
{
return Ok(scope);
}
if invocation_dir == project_root && service_scopes.len() > 1 && std::io::stdin().is_terminal()
{
return prompt_for_version_scope_selection(&service_scopes);
}
Ok(resolve_version_scope(project_root, invocation_dir))
}
fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
let service_scopes = load_service_version_scopes(project_root, invocation_dir);
if let Some(service_scope) =
select_matching_service_scope(&service_scopes, project_root, invocation_dir)
{
return service_scope;
}
if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
return crate_scope;
}
VersionScope::Repository
}
fn is_nested_repository_scope(project_root: &Path, version_scope: &VersionScope) -> bool {
is_nested_repository_scope_with_git_root(
project_root,
version_scope,
enclosing_git_root(project_root).as_deref(),
)
}
fn is_nested_repository_scope_with_git_root(
project_root: &Path,
version_scope: &VersionScope,
git_root: Option<&Path>,
) -> bool {
matches!(version_scope, VersionScope::Repository)
&& git_root.is_some_and(|root| !same_filesystem_path(root, project_root))
}
fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
return Vec::new();
};
let Some(services) = config.services else {
return Vec::new();
};
let mut scopes = services
.into_iter()
.filter_map(|service| build_service_scope(project_root, service))
.collect::<Vec<_>>();
scopes.sort_by(|left, right| {
version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
});
scopes
}
fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
let version_targets = service
.version_targets
.clone()
.unwrap_or_default()
.into_iter()
.map(normalized_path_string)
.collect::<Vec<_>>();
let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
let service_relative_root = service_root
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ".".to_string());
let cargo_package_name =
resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
let tag_identity = cargo_package_name
.clone()
.unwrap_or_else(|| service.name.clone());
let watch_paths = derive_service_watch_paths(
project_root,
&service,
&version_targets,
&service_relative_root,
);
Some(VersionScope::Service {
service_root,
service_relative_root,
service_name: service.name,
tag_prefix: format!("{}-", default_release_tag_slug(&tag_identity)),
cargo_package_name,
version_targets,
watch_paths,
})
}
fn derive_service_watch_paths(
project_root: &Path,
service: &ServiceConfig,
version_targets: &[String],
service_relative_root: &str,
) -> Vec<String> {
let mut paths = Vec::new();
if let Some(explicit) = &service.watch_paths {
for path in explicit {
let relative = normalize_watch_path_relative(project_root, path);
if !relative.is_empty() {
paths.push(relative);
}
}
}
if paths.is_empty() {
if let Some(root_directory) = &service.root_directory {
let relative = normalize_watch_path_relative(project_root, root_directory);
if !relative.is_empty() && relative != "." {
paths.push(relative);
}
}
}
if paths.is_empty() {
for target in version_targets {
let relative = normalize_watch_path_relative(
project_root,
&watch_root_from_version_target(target),
);
if !relative.is_empty() && relative != "." {
paths.push(relative);
}
}
}
if paths.is_empty() && !service_relative_root.is_empty() && service_relative_root != "." {
paths.push(service_relative_root.replace('\\', "/"));
}
paths.sort();
paths.dedup();
paths
}
fn normalize_watch_path_relative(project_root: &Path, raw: &str) -> String {
let path = PathBuf::from(raw);
let relative = if path.is_absolute() {
path.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| normalized_path_string(raw.to_string()))
} else {
normalized_path_string(raw.to_string())
};
let trimmed = relative.trim_matches('/').to_string();
if trimmed.is_empty() {
".".to_string()
} else {
trimmed
}
}
fn resolve_service_scope_root(
project_root: &Path,
service: &ServiceConfig,
version_targets: &[String],
) -> Option<PathBuf> {
if !version_targets.is_empty() {
return common_parent_from_targets(project_root, version_targets);
}
service.root_directory.as_ref().map(|root| {
let path = PathBuf::from(root);
if path.is_absolute() {
path
} else {
project_root.join(path)
}
})
}
fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
let mut parents = version_targets.iter().filter_map(|target| {
let path = PathBuf::from(target);
let absolute = if path.is_absolute() {
path
} else {
project_root.join(path)
};
absolute.parent().map(Path::to_path_buf)
});
let mut common = parents.next()?;
for parent in parents {
while !parent.starts_with(&common) {
common = common.parent()?.to_path_buf();
}
}
Some(common)
}
fn resolve_service_scope_cargo_package_name(
project_root: &Path,
service_root: &Path,
version_targets: &[String],
) -> Option<String> {
let mut cargo_paths = version_targets
.iter()
.filter(|target| {
Path::new(target)
.file_name()
.and_then(|value| value.to_str())
== Some("Cargo.toml")
})
.map(|target| {
let path = PathBuf::from(target);
if path.is_absolute() {
path
} else {
project_root.join(path)
}
})
.collect::<Vec<_>>();
if cargo_paths.is_empty() {
cargo_paths.push(service_root.join("Cargo.toml"));
}
cargo_paths.into_iter().find_map(|path| {
let content = fs::read_to_string(path).ok()?;
cargo_package_name_from_content_optional(&content)
.ok()
.flatten()
})
}
fn select_matching_service_scope(
scopes: &[VersionScope],
project_root: &Path,
invocation_dir: &Path,
) -> Option<VersionScope> {
scopes
.iter()
.filter_map(|scope| {
let root = version_scope_root(scope)?;
if invocation_dir == project_root && root == project_root {
return None;
}
if invocation_dir.starts_with(root) {
Some((root.components().count(), scope.clone()))
} else {
None
}
})
.max_by_key(|(depth, _)| *depth)
.map(|(_, scope)| scope)
}
fn prompt_for_version_scope_selection(
service_scopes: &[VersionScope],
) -> Result<VersionScope, String> {
let mut items = service_scopes
.iter()
.map(version_scope_prompt_label)
.collect::<Vec<_>>();
items.push("Repository (all configured version targets)".to_string());
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Choose the project/service to version or release")
.items(&items)
.default(items.len().saturating_sub(1))
.interact_opt()
.map_err(|e| format!("Prompt failed: {}", e))?;
match selection {
Some(index) if index < service_scopes.len() => Ok(service_scopes[index].clone()),
_ => Ok(VersionScope::Repository),
}
}
fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
let cargo_toml = crate_root.join("Cargo.toml");
let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
let crate_relative_root = crate_root
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)?;
Some(VersionScope::Crate {
crate_root,
crate_relative_root,
tag_prefix: format!("{}-", default_release_tag_slug(&package_name)),
package_name,
})
}
fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
let crates_root = project_root.join("crates");
let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
let mut components = relative.components();
let crate_name = components.next()?;
Some(crates_root.join(crate_name.as_os_str()))
}
fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
match version_scope {
VersionScope::Repository => None,
VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
}
}
fn prepare_release_openapi_assets(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
release_version: &Version,
tag_name: &str,
) -> Result<Vec<ReleaseOpenApiAsset>, String> {
let spec_paths = resolve_release_openapi_specs(project_root, invocation_dir, version_scope)?;
validate_release_openapi_assets(&spec_paths, release_version)?;
spec_paths
.into_iter()
.map(|source_path| {
let file_name = source_path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| {
format!(
"Invalid OpenAPI asset path for release upload: {}",
source_path.display()
)
})?;
let asset_name = versioned_release_openapi_asset_name(file_name, tag_name)?;
let source_label = source_path
.strip_prefix(project_root)
.map(normalized_relative_path)
.unwrap_or_else(|_| normalized_relative_path(&source_path));
Ok(ReleaseOpenApiAsset {
source_path,
source_label,
asset_name,
})
})
.collect()
}
fn resolve_release_openapi_specs(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
) -> Result<Vec<PathBuf>, String> {
const PRIMARY_RELEASE_OPENAPI_FILES: &[&str] = &[
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
];
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(scope_root) = version_scope_root(version_scope) {
roots.push(scope_root.to_path_buf());
} else {
if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
roots.push(crate_root);
}
roots.push(project_root.to_path_buf());
}
let mut seen_roots: BTreeSet<PathBuf> = BTreeSet::new();
let mut seen_names: BTreeSet<String> = BTreeSet::new();
let mut resolved_paths: Vec<PathBuf> = Vec::new();
for root in roots {
if !seen_roots.insert(root.clone()) {
continue;
}
let mut root_paths: Vec<(String, PathBuf)> = Vec::new();
for file_name in PRIMARY_RELEASE_OPENAPI_FILES {
let path = root.join(file_name);
if path.is_file() {
root_paths.push(((*file_name).to_string(), path));
}
}
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(format!(
"Failed to inspect OpenAPI release assets in {}: {}",
root.display(),
error
));
}
};
let mut additional_specs: Vec<(String, PathBuf)> = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"Failed to inspect OpenAPI release assets in {}: {}",
root.display(),
error
)
})?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if !is_release_openapi_variant_file_name(file_name) {
continue;
}
additional_specs.push((file_name.to_string(), path));
}
additional_specs.sort_by(|left, right| left.0.cmp(&right.0));
root_paths.extend(additional_specs);
for (file_name, path) in root_paths {
if seen_names.insert(file_name) {
resolved_paths.push(path);
}
}
}
Ok(resolved_paths)
}
fn validate_release_openapi_assets(
spec_paths: &[PathBuf],
release_version: &Version,
) -> Result<(), String> {
let Some(primary_spec_path) = spec_paths.iter().find(|path| {
path.file_name()
.and_then(|value| value.to_str())
.map(is_primary_release_openapi_file_name)
.unwrap_or(false)
}) else {
return Ok(());
};
let declared_version = read_version_from_path(primary_spec_path)?.ok_or_else(|| {
format!(
"Release OpenAPI spec `{}` does not declare a version, so it cannot be verified against release version `{}`.",
primary_spec_path.display(),
release_version
)
})?;
let parsed_declared_version = parse_version(&declared_version).map_err(|error| {
format!(
"Release OpenAPI spec `{}` has invalid version `{}`: {}",
primary_spec_path.display(),
declared_version,
error
)
})?;
if &parsed_declared_version != release_version {
return Err(format!(
"Release OpenAPI spec `{}` version `{}` does not match release version `{}`.",
primary_spec_path.display(),
declared_version,
release_version
));
}
Ok(())
}
fn versioned_release_openapi_asset_name(file_name: &str, tag_name: &str) -> Result<String, String> {
let path = Path::new(file_name);
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
let extension = path
.extension()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
Ok(format!("{}-{}.{}", stem, tag_name, extension))
}
fn is_primary_release_openapi_file_name(file_name: &str) -> bool {
matches!(
file_name,
"openapi.yaml"
| "openapi.yml"
| "openapi.json"
| "swagger.yaml"
| "swagger.yml"
| "swagger.json"
)
}
fn is_release_openapi_variant_file_name(file_name: &str) -> bool {
if is_primary_release_openapi_file_name(file_name) {
return false;
}
let Some((stem, extension)) = file_name.rsplit_once('.') else {
return false;
};
if !matches!(extension, "yaml" | "yml" | "json") {
return false;
}
stem.starts_with("openapi-") || stem.starts_with("swagger-")
}
fn normalized_path_string(path: String) -> String {
path.replace('\\', "/")
}
fn slugify_scope_name(value: &str) -> String {
let mut slug = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
while slug.contains("--") {
slug = slug.replace("--", "-");
}
slug.trim_matches('-').to_string()
}
fn resolve_workspace_primary_cargo_target(
project_root: &Path,
) -> Option<WorkspacePrimaryCargoTarget> {
let workspace_manifest = project_root.join("Cargo.toml");
let content = fs::read_to_string(&workspace_manifest).ok()?;
let value: TomlValue = toml::from_str(&content).ok()?;
if value.get("package").and_then(TomlValue::as_table).is_some() {
return None;
}
let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
let mut candidate_roots = workspace
.get("default-members")
.and_then(TomlValue::as_array)
.map(|members| {
members
.iter()
.filter_map(TomlValue::as_str)
.map(str::trim)
.filter(|member| !member.is_empty() && !member.contains('*'))
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if candidate_roots.is_empty() {
let members = workspace
.get("members")
.and_then(TomlValue::as_array)
.map(|members| {
members
.iter()
.filter_map(TomlValue::as_str)
.map(str::trim)
.filter(|member| !member.is_empty() && !member.contains('*'))
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if members.len() == 1 {
candidate_roots = members;
}
}
candidate_roots.sort();
candidate_roots.dedup();
if candidate_roots.len() != 1 {
return None;
}
let crate_relative_root = candidate_roots.into_iter().next()?;
let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
Some(WorkspacePrimaryCargoTarget {
manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
manifest_absolute,
package_name,
})
}
#[cfg(test)]
fn resolve_release_publish_target_filter(
invocation_dir: &Path,
version_scope: &VersionScope,
) -> Result<Option<String>, String> {
let VersionScope::Service {
service_name,
service_root,
version_targets,
..
} = version_scope
else {
return Ok(None);
};
let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
return Ok(None);
};
let Some(publish) = config.publish else {
return Ok(None);
};
let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
.into_iter()
.filter_map(|(kind, target)| {
let target = target?;
if target.enabled.unwrap_or(true) {
Some((kind, target))
} else {
None
}
})
.collect::<Vec<_>>();
if enabled_targets.is_empty() {
return Ok(None);
}
let matched = enabled_targets
.iter()
.filter_map(|(kind, target)| {
let manifest_path = target.manifest_path.as_ref()?;
let absolute = if Path::new(manifest_path).is_absolute() {
PathBuf::from(manifest_path)
} else {
project_root.join(manifest_path)
};
let relative = absolute
.strip_prefix(&project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| normalized_relative_path(&absolute));
if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
{
Some((*kind).to_string())
} else {
None
}
})
.collect::<Vec<_>>();
if matched.is_empty() {
return Err(format!(
"`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
service_name
));
}
if matched.len() == enabled_targets.len() {
if matched.len() == 1 {
return Ok(matched.into_iter().next());
}
return Ok(None);
}
if matched.len() == 1 {
return Ok(matched.into_iter().next());
}
Err(format!(
"Service `{}` matches multiple publish targets while other publish targets are also enabled. Narrow the publish config before using `xbp version release --publish` for this service.",
service_name
))
}
async fn resolve_effective_linear_release_config(
project_root: &Path,
invocation_dir: &Path,
) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
let global_config = resolve_global_linear_release_config();
let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
config.linear.and_then(|linear| linear.release)
} else {
None
};
Ok(resolve_linear_release_config(global_config, project_config)
.map(|config| resolve_linear_release_placeholders(project_root, config)))
}
fn resolve_linear_release_placeholders(
project_root: &Path,
mut config: ResolvedLinearReleaseConfig,
) -> ResolvedLinearReleaseConfig {
let mut env_map = HashMap::new();
for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
}
if let Some(organization_name) = config.organization_name.clone() {
env_map.insert("organization_name".to_string(), organization_name);
}
let resolved = resolve_env_placeholders(project_root, &env_map);
config.initiative_ids = config
.initiative_ids
.iter()
.enumerate()
.map(|(index, initiative_id)| {
resolved
.get(&format!("initiative_id_{}", index))
.cloned()
.unwrap_or_else(|| initiative_id.clone())
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect();
config.organization_name = resolved
.get("organization_name")
.cloned()
.or(config.organization_name)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
config
}
async fn resolve_project_github_release_branch_config(
_project_root: &Path,
invocation_dir: &Path,
) -> Result<Option<GitHubReleaseBranchSettings>, String> {
if let Some(found) = find_xbp_config_upwards(invocation_dir) {
let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
Ok(config.github_release_branch_settings())
} else {
Ok(None)
}
}
#[derive(Debug, Clone, Default)]
struct ReleaseGitTagConfig {
git_tag_template: Option<String>,
}
async fn resolve_project_release_git_tag_config(
_project_root: &Path,
invocation_dir: &Path,
) -> Result<ReleaseGitTagConfig, String> {
if let Some(found) = find_xbp_config_upwards(invocation_dir) {
let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
Ok(ReleaseGitTagConfig {
git_tag_template: config
.release
.and_then(|release| release.git_tag_template)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
})
} else {
Ok(ReleaseGitTagConfig::default())
}
}
fn normalized_relative_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn same_filesystem_path(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
match (left.canonicalize(), right.canonicalize()) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
fn enclosing_git_root(dir: &Path) -> Option<PathBuf> {
for ancestor in dir.ancestors() {
if ancestor.join(".git").exists() {
return Some(ancestor.to_path_buf());
}
}
git_repository_root(dir)
}
fn git_repository_root(dir: &Path) -> Option<PathBuf> {
if !command_exists("git") {
return None;
}
let output: std::process::Output = Command::new("git")
.current_dir(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if root.is_empty() {
None
} else {
Some(PathBuf::from(root))
}
}
fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
let output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(args)
.output()
.map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
if !output.status.success() {
let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!(
"`git {}` failed with status {}",
args.join(" "),
output.status
));
}
return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
Ok(output
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect())
}
fn version_change_guard_state_path() -> Result<PathBuf, String> {
let paths = global_xbp_paths()?;
Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
}
fn version_change_guard_repo_key(project_root: &Path) -> String {
fs::canonicalize(project_root)
.unwrap_or_else(|_| project_root.to_path_buf())
.to_string_lossy()
.replace('\\', "/")
}
fn version_scope_guard_id(scope: &VersionScope) -> String {
match scope {
VersionScope::Repository => "repository".to_string(),
VersionScope::Crate {
package_name,
crate_relative_root,
..
} => format!("crate:{package_name}:{crate_relative_root}"),
VersionScope::Service {
service_name,
service_relative_root,
..
} => format!("service:{service_name}:{service_relative_root}"),
}
}
fn version_change_guard_entry_key(project_root: &Path, scope: Option<&VersionScope>) -> String {
let repo = version_change_guard_repo_key(project_root);
match scope {
Some(scope) => format!("{repo}::{}", version_scope_guard_id(scope)),
None => format!("{repo}::repository"),
}
}
fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
if !path.exists() {
return Ok(VersionChangeGuardRegistry::default());
}
let content = fs::read_to_string(path).map_err(|e| {
format!(
"Failed to read version-change guard state {}: {}",
path.display(),
e
)
})?;
Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
}
fn save_version_change_guard_registry(
path: &Path,
registry: &VersionChangeGuardRegistry,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create guard state directory {}: {}",
parent.display(),
e
)
})?;
}
let content = serde_yaml::to_string(registry)
.map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
fs::write(path, content).map_err(|e| {
format!(
"Failed to write version-change guard state {}: {}",
path.display(),
e
)
})
}
fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
if !command_exists("git") {
return Ok(None);
}
let status_output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(["status", "--porcelain"])
.output()
.map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
if !status_output.status.success() {
return Ok(None);
}
let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
.lines()
.any(|line| !line.trim().is_empty());
let head_output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(["rev-parse", "HEAD"])
.output()
.map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
let head_commit: Option<String> = if head_output.status.success() {
let value: String = String::from_utf8_lossy(&head_output.stdout)
.trim()
.to_string();
if value.is_empty() {
None
} else {
Some(value)
}
} else {
None
};
Ok(Some(GitWorktreeState {
is_dirty,
head_commit,
}))
}
fn should_clear_version_change_guard(
entry: &VersionChangeGuardEntry,
state: &GitWorktreeState,
) -> bool {
if entry.pending_version_change_count == 0 {
return true;
}
if !state.is_dirty {
return true;
}
match (&entry.head_commit, &state.head_commit) {
(Some(previous), Some(current)) => previous != current,
(Some(_), None) => true,
_ => false,
}
}
fn enforce_version_change_guard(
project_root: &Path,
scope: Option<&VersionScope>,
) -> Result<(), String> {
let Some(state) = git_worktree_state(project_root)? else {
return Ok(());
};
let state_path: PathBuf = version_change_guard_state_path()?;
let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
let repo_key: String = version_change_guard_repo_key(project_root);
let entry_key = version_change_guard_entry_key(project_root, scope);
let mut changed = false;
// Clear stale entries for this repo (scoped + legacy unscoped keys).
let keys_to_check = registry
.entries
.keys()
.filter(|key| {
*key == &repo_key || key.starts_with(&format!("{repo_key}::")) || *key == &entry_key
})
.cloned()
.collect::<Vec<_>>();
for key in keys_to_check {
if let Some(entry) = registry.entries.get(&key).cloned() {
if should_clear_version_change_guard(&entry, &state) {
registry.entries.remove(&key);
changed = true;
}
}
}
if changed {
save_version_change_guard_registry(&state_path, ®istry)?;
}
if state.is_dirty {
// Per-scope isolation: only block re-versioning the *same* scope while dirty.
// Other services/projects in the same dirty worktree may still version independently.
if let Some(entry) = registry.entries.get(&entry_key) {
if entry.pending_version_change_count >= 1 {
let scope_label = scope
.map(version_scope_prompt_label)
.unwrap_or_else(|| "Repository".to_string());
return Err(format!(
"Cannot run another version change for `{scope_label}` on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first (other services remain free to version independently). Guard state: {}",
entry.pending_version_change_count,
state_path.display()
));
}
}
// Legacy repo-wide guard (pre-isolation): still honor if present.
if let Some(entry) = registry.entries.get(&repo_key) {
if entry.pending_version_change_count >= 1 {
return Err(format!(
"Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
entry.pending_version_change_count,
state_path.display()
));
}
}
}
Ok(())
}
fn record_version_change_guard(
project_root: &Path,
scope: Option<&VersionScope>,
) -> Result<(), String> {
let Some(state) = git_worktree_state(project_root)? else {
return Ok(());
};
let state_path: PathBuf = version_change_guard_state_path()?;
let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
let entry_key = version_change_guard_entry_key(project_root, scope);
if state.is_dirty {
registry.entries.insert(
entry_key,
VersionChangeGuardEntry {
pending_version_change_count: 1,
head_commit: state.head_commit,
},
);
} else {
registry.entries.remove(&entry_key);
}
save_version_change_guard_registry(&state_path, ®istry)
}
fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
Ok(!output.trim().is_empty())
}
fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
let remotes: String = run_git_command(project_root, &["remote"])?;
let exists: bool = remotes.lines().any(|line| line.trim() == remote);
if exists {
Ok(())
} else {
Err(format!(
"Git remote `{}` is not configured for this repository.",
remote
))
}
}
fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
run_git_command(project_root, &["remote", "get-url", remote])
}
fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
let query: String = format!("refs/tags/{}", tag);
let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
Ok(!output.trim().is_empty())
}
fn git_head_commitish(project_root: &Path) -> Result<String, String> {
let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
if commitish.is_empty() {
Err("Unable to resolve HEAD commit for release target.".to_string())
} else {
Ok(commitish)
}
}
fn render_release_branch_name(
naming_template: &str,
release_version: &Version,
tag_name: &str,
service_name: &str,
) -> Result<String, String> {
let service_name = sanitize_release_branch_segment(service_name);
let branch_name = naming_template
.replace("{VERSION}", &release_version.to_string())
.replace("{TAG}", tag_name)
.replace("{SERVICE_NAME}", &service_name)
.replace("${GITHUB_VERSION}", &release_version.to_string())
.replace("${GITHUB_TAG}", tag_name)
.replace("${SERVICE_NAME}", &service_name);
let branch_name = branch_name.trim();
if branch_name.is_empty() {
return Err(
"GitHub release branch naming template resolved to an empty branch name.".to_string(),
);
}
Ok(branch_name.to_string())
}
fn sanitize_release_branch_segment(value: &str) -> String {
value
.trim()
.chars()
.map(|character| match character {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
fn git_local_branch_commit(
project_root: &Path,
branch_name: &str,
) -> Result<Option<String>, String> {
let output = Command::new("git")
.current_dir(project_root)
.args([
"rev-parse",
"--verify",
&format!("refs/heads/{}", branch_name),
])
.output()
.map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
if !output.status.success() {
return Ok(None);
}
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
if commit.is_empty() {
Ok(None)
} else {
Ok(Some(commit))
}
}
fn git_current_branch(project_root: &Path) -> Result<Option<String>, String> {
let branch_name = run_git_command(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
if branch_name.is_empty() || branch_name == "HEAD" {
Ok(None)
} else {
Ok(Some(branch_name))
}
}
fn prepare_release_branch_checkout(
project_root: &Path,
branch_config: &GitHubReleaseBranchSettings,
release_version: &Version,
tag_name: &str,
service_name: &str,
target_commitish: &str,
) -> Result<String, String> {
let branch_name = render_release_branch_name(
&branch_config.naming_template,
release_version,
tag_name,
service_name,
)?;
if git_current_branch(project_root)?.as_deref() == Some(branch_name.as_str()) {
return Ok(branch_name);
}
if git_local_branch_commit(project_root, &branch_name)?.is_none() {
run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
}
run_git_command(project_root, &["checkout", &branch_name]).map_err(|error| {
format!(
"Failed to switch to configured release branch `{}`: {}",
branch_name, error
)
})?;
Ok(branch_name)
}
fn push_release_branch(
project_root: &Path,
branch_name: &str,
target_commitish: &str,
) -> Result<(), String> {
if let Some(existing_local_commit) = git_local_branch_commit(project_root, branch_name)? {
if existing_local_commit != target_commitish {
return Err(format!(
"Configured release branch `{}` points to {}, expected {} before push.",
branch_name, existing_local_commit, target_commitish
));
}
} else {
return Err(format!(
"Configured release branch `{}` does not exist locally when preparing to push.",
branch_name
));
}
run_git_command(
project_root,
&[
"push",
"-u",
"origin",
&format!("{}:refs/heads/{}", branch_name, branch_name),
],
)
.map(|_| ())
}
fn release_tag_family(tag_name: &str) -> String {
if tag_name.starts_with('v')
&& tag_name
.chars()
.nth(1)
.map(|ch| ch.is_ascii_digit())
.unwrap_or(false)
{
return "v".to_string();
}
let mut family: String = String::new();
for ch in tag_name.chars() {
if ch.is_ascii_digit() {
break;
}
family.push(ch);
}
family
}
fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
if family == "v" {
return parse_version(tag).ok();
}
tag.strip_prefix(family)
.and_then(|rest| parse_version(rest).ok())
}
fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
let range: String = format!("{}..HEAD", tag);
run_git_command(project_root, &["rev-list", "--count", &range])
.ok()
.and_then(|raw| raw.trim().parse::<usize>().ok())
}
fn previous_release_tag(
project_root: &Path,
current_tag_name: &str,
) -> Result<Option<String>, String> {
let family: String = release_tag_family(current_tag_name);
let tag_pattern: String = format!("{}*", family);
let merged_tags: String = run_git_command(
project_root,
&["tag", "--merged", "HEAD", "--list", &tag_pattern],
)?;
let mut best: Option<(usize, String)> = None;
for raw in merged_tags.lines() {
let tag = raw.trim();
if tag.is_empty() || tag == current_tag_name {
continue;
}
if parse_release_family_version(tag, &family).is_none() {
continue;
}
let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
continue;
};
if distance == 0 {
continue;
}
match &best {
None => best = Some((distance, tag.to_string())),
Some((best_distance, _)) if distance < *best_distance => {
best = Some((distance, tag.to_string()))
}
_ => {}
}
}
Ok(best.map(|(_, tag)| tag))
}
fn default_release_title(version: &Version, repo: &str) -> String {
format!("{} - {}", version, repo)
}
fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
match version_scope {
VersionScope::Repository => repo,
VersionScope::Crate { package_name, .. } => package_name.as_str(),
VersionScope::Service { service_name, .. } => service_name.as_str(),
}
}
fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
match version_scope {
VersionScope::Repository => "repository",
VersionScope::Crate { .. } => "crate",
VersionScope::Service { .. } => "service",
}
}
fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
match version_scope {
VersionScope::Repository => repo.to_string(),
VersionScope::Crate { package_name, .. } => package_name.clone(),
VersionScope::Service { service_name, .. } => service_name.clone(),
}
}
fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
match version_scope {
VersionScope::Repository => "Repository".to_string(),
VersionScope::Crate {
package_name,
crate_relative_root,
..
} => format!("{} ({})", package_name, crate_relative_root),
VersionScope::Service {
service_name,
service_relative_root,
..
} => format!("{} ({})", service_name, service_relative_root),
}
}
fn default_release_tag_name(
version_scope: &VersionScope,
version: &Version,
git_tag_template: Option<&str>,
) -> Result<String, String> {
if let Some(template) = git_tag_template {
return render_release_git_tag_template(template, version_scope, version);
}
let tag_name = match version_scope {
VersionScope::Repository => format!("v{}", version),
VersionScope::Crate { package_name, .. } => {
format!("{}-{}", default_release_tag_slug(package_name), version)
}
VersionScope::Service {
service_name,
cargo_package_name,
..
} => {
let identity = cargo_package_name.as_deref().unwrap_or(service_name);
format!("{}-{}", default_release_tag_slug(identity), version)
}
};
Ok(tag_name)
}
fn scoped_release_tag_name(
version_scope: &VersionScope,
version: &Version,
parsed_tag_name: &str,
git_tag_template: Option<&str>,
) -> Result<String, String> {
if git_tag_template.is_some() {
return default_release_tag_name(version_scope, version, git_tag_template);
}
match version_scope {
VersionScope::Repository => Ok(release_tag_name_with_version(parsed_tag_name, version)),
VersionScope::Crate { .. } | VersionScope::Service { .. } => {
if release_tag_family(parsed_tag_name) == "v" {
default_release_tag_name(version_scope, version, None)
} else {
Ok(release_tag_name_with_version(parsed_tag_name, version))
}
}
}
}
fn release_tag_name_with_version(parsed_tag_name: &str, version: &Version) -> String {
let family = release_tag_family(parsed_tag_name);
if parse_release_family_version(parsed_tag_name, &family).is_some() {
format!("{family}{version}")
} else {
parsed_tag_name.to_string()
}
}
fn render_release_git_tag_template(
template: &str,
version_scope: &VersionScope,
version: &Version,
) -> Result<String, String> {
let scope_name = version_scope_label(version_scope, "repository");
let package_full = version_scope_package_identity(version_scope).unwrap_or(&scope_name);
let package_name = unscoped_npm_package_name(package_full);
let package_scope = npm_package_scope(package_full).unwrap_or("");
let flag = version.build.as_str();
let mut rendered = template.trim().to_string();
for (token, value) in [
("VERSION", version.to_string()),
("GITHUB_VERSION", version.to_string()),
("FLAG", flag.to_string()),
("PACKAGE_NAME", default_release_tag_slug(package_name)),
("PACKAGE_FULL_NAME", slugify_scope_name(package_full)),
("PACKAGE_SCOPE", slugify_scope_name(package_scope)),
("SCOPE_NAME", slugify_scope_name(&scope_name)),
("SCOPE_SLUG", slugify_scope_name(&scope_name)),
] {
rendered = rendered.replace(&format!("{{{token}}}"), &value);
rendered = rendered.replace(&format!("${{{token}}}"), &value);
}
let rendered = rendered.trim();
if rendered.is_empty() {
return Err("Release git tag template resolved to an empty tag name.".to_string());
}
if rendered.contains('{') || rendered.contains('}') {
return Err(format!(
"Release git tag template `{}` contains an unknown token after rendering: `{}`.",
template, rendered
));
}
Ok(rendered.to_string())
}
fn version_scope_package_identity(version_scope: &VersionScope) -> Option<&str> {
match version_scope {
VersionScope::Repository => None,
VersionScope::Crate { package_name, .. } => Some(package_name.as_str()),
VersionScope::Service {
service_name,
cargo_package_name,
..
} => Some(
cargo_package_name
.as_deref()
.unwrap_or(service_name.as_str()),
),
}
}
fn unscoped_npm_package_name(package_name: &str) -> &str {
let trimmed = package_name.trim();
if let Some(rest) = trimmed.strip_prefix('@') {
return rest
.split_once('/')
.map(|(_, name)| name)
.unwrap_or(trimmed);
}
trimmed
}
fn npm_package_scope(package_name: &str) -> Option<&str> {
let rest = package_name.trim().strip_prefix('@')?;
rest.split_once('/').map(|(scope, _)| scope)
}
fn default_release_tag_slug(value: &str) -> String {
slugify_scope_name(unscoped_npm_package_name(value))
}
fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
match version_scope {
VersionScope::Repository => None,
VersionScope::Crate {
crate_relative_root,
..
} => Some(crate_relative_root.clone()),
VersionScope::Service {
service_relative_root,
..
} if service_relative_root == "." => None,
VersionScope::Service {
service_relative_root,
..
} => Some(service_relative_root.clone()),
}
}
fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
return (None, None);
};
let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
return (None, None);
};
(Some(owner), Some(repo))
}
fn published_initiatives_to_activity(
initiatives: &[PublishedLinearInitiative],
) -> Vec<VersionActivityLinearInitiative> {
initiatives
.iter()
.map(|initiative| VersionActivityLinearInitiative {
id: initiative.id.clone(),
name: initiative.name.clone(),
url: initiative.url.clone(),
})
.collect()
}
async fn sync_cli_version_write_activity(
project_root: &Path,
version_scope: &VersionScope,
version: &Version,
message: String,
) {
let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
let scope_label = version_scope_label(
version_scope,
repository_name.as_deref().unwrap_or_else(|| {
project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repository")
}),
);
let payload = CliVersionActivityPayload {
command_kind: "version".to_string(),
repository_owner,
repository_name,
scope_kind: version_scope_kind(version_scope).to_string(),
scope_label,
version: version.to_string(),
tag_name: None,
title: None,
release_url: None,
message_markdown: Some(message),
published_initiatives: Vec::new(),
};
if let Err(error) = post_version_activity(&payload).await {
eprintln!("Warning: {}", error);
}
}
async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
let payload = CliVersionActivityPayload {
command_kind: "version_release".to_string(),
repository_owner: Some(summary.repository_owner.clone()),
repository_name: Some(summary.repository_name.clone()),
scope_kind: summary.scope_kind.clone(),
scope_label: summary.scope_label.clone(),
version: summary.version.to_string(),
tag_name: Some(summary.tag_name.clone()),
title: Some(summary.release_title.clone()),
release_url: Some(summary.release_url.clone()),
message_markdown: Some(summary.release_notes.clone()),
published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
};
if let Err(error) = post_version_activity(&payload).await {
eprintln!("Warning: {}", error);
}
}
fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
let mut rendered_notes: String = notes.trim_end().to_string();
if !rendered_notes.is_empty() {
rendered_notes.push('\n');
}
rendered_notes.push_str("Release label: ");
rendered_notes.push_str(release_label);
rendered_notes.push('\n');
rendered_notes.push_str("Generated by XBP ");
rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
rendered_notes
}
#[cfg(test)]
mod tests {
use super::github_release::{
github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
github_release_update_endpoint,
};
use super::release_docs::{
release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
};
use super::release_notes::{
build_fallback_sections, collect_linear_issue_identifiers,
deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
};
use super::{
analyze_dirty_worktree, append_release_label_footer, apply_release_flag_to_version,
bump_version, cargo_package_name, collect_version_report, current_scope_ledger_path,
default_release_tag_name, default_release_title, git_current_branch, git_head_commitish,
highest_version_observation, is_safe_dirty_path, normalized_relative_path,
parse_git_status_path, parse_github_repo_from_remote_url, parse_local_git_tag_output,
parse_local_git_tag_output_for_scope, parse_package_version_target,
parse_release_version_target, parse_remote_git_tag_output, parse_version,
prepare_release_branch_checkout, prepare_release_openapi_assets, read_cargo_lock_version,
read_cargo_lock_version_for_package, read_cargo_toml_version,
read_highest_version_from_targets, read_json_openapi_version, read_json_root_version,
read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
read_version_from_path, read_yaml_root_version, redact_remote_url_credentials,
render_release_branch_name, resolve_change_baseline,
resolve_configured_version_target_paths, resolve_linear_release_placeholders,
resolve_project_root_from, resolve_registry_paths, resolve_release_openapi_specs,
resolve_release_publish_target_filter, resolve_release_version_from_report,
resolve_version_scope, rewrite_toml_package_assignment_versions, scoped_release_tag_name,
select_changed_version_targets, should_clear_version_change_guard,
stale_version_observations, sync_version_to_configured_files_with_paths,
write_cargo_lock_version, write_cargo_toml_version, write_chart_version,
write_json_openapi_version, write_json_root_version, write_openapi_version,
write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
write_regex_version, write_toml_root_version, write_version_to_configured_files,
write_version_to_configured_files_with_paths, write_version_to_selected_paths,
write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
VersionObservation, VersionScope,
};
use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
use crate::config::PackageNameLookup;
use crate::strategies::deployment_config::GitHubReleaseBranchSettings;
use semver::Version;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos: u128 = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
fs::create_dir_all(&dir).expect("create temp dir");
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("create temp xbp dir");
fs::write(xbp_dir.join("xbp.yaml"), "version_targets: []\n")
.expect("write temp xbp config");
dir
}
fn git(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.status()
.expect("run git");
assert!(
status.success(),
"git {:?} failed in {}",
args,
dir.display()
);
}
#[test]
fn parse_git_status_path_handles_renames() {
assert_eq!(
parse_git_status_path("R old-name -> new-name/Cargo.toml"),
Some("new-name/Cargo.toml".to_string())
);
assert_eq!(
parse_git_status_path(" M .xbp/xbp.yaml"),
Some(".xbp/xbp.yaml".to_string())
);
}
#[test]
fn is_safe_dirty_path_recognizes_generated_paths() {
assert!(is_safe_dirty_path(".xbp/xbp.yaml"));
assert!(is_safe_dirty_path("crates/cli/target/debug/xbp"));
assert!(is_safe_dirty_path(
"scripts/__pycache__/publish.cpython-312.pyc"
));
assert!(!is_safe_dirty_path("crates/cli/src/main.rs"));
assert!(!is_safe_dirty_path("Cargo.toml"));
}
#[test]
fn analyze_dirty_worktree_splits_safe_and_risky_entries() {
let analysis = analyze_dirty_worktree(&[
" M .xbp/xbp.yaml".to_string(),
" M crates/cli/src/main.rs".to_string(),
]);
assert_eq!(analysis.safe_entries, vec![".xbp/xbp.yaml".to_string()]);
assert_eq!(
analysis.risky_entries,
vec!["crates/cli/src/main.rs".to_string()]
);
}
fn write_multi_service_config(dir: &Path) {
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: xbp
version: 10.30.0
port: 3000
build_dir: ./
services:
- name: cli
target: rust
branch: main
port: 8080
root_directory: ./
version_targets:
- crates/cli/Cargo.toml
- name: web
target: nextjs
branch: main
port: 3001
root_directory: apps/web
version_targets:
- apps/web/package.json
publish:
npm:
enabled: true
manifest_path: apps/web/package.json
crates:
enabled: true
manifest_path: crates/cli/Cargo.toml
version_targets:
- crates/cli/Cargo.toml
- apps/web/package.json
"#,
)
.expect("write xbp config");
}
#[test]
fn parses_prefixed_semver() {
assert_eq!(
parse_version("v1.2.3").expect("version"),
Version::new(1, 2, 3)
);
}
#[test]
fn rejects_invalid_semver() {
let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
assert!(error.contains("Invalid semantic version"));
}
#[test]
fn release_target_parser_supports_plain_semver() {
let (version, tag_name) =
parse_release_version_target("1.2.3-alpha.1").expect("release target");
assert_eq!(version.major, 1);
assert_eq!(version.minor, 2);
assert_eq!(version.patch, 3);
assert_eq!(version.pre.as_str(), "alpha.1");
assert_eq!(tag_name, "v1.2.3-alpha.1");
}
#[test]
fn release_target_parser_supports_prefixed_semver() {
let (version, tag_name) =
parse_release_version_target("studio-0.3.2-alpha").expect("release target");
assert_eq!(version.major, 0);
assert_eq!(version.minor, 3);
assert_eq!(version.patch, 2);
assert_eq!(version.pre.as_str(), "alpha");
assert_eq!(tag_name, "studio-0.3.2-alpha");
}
#[test]
fn release_flag_adds_build_metadata() {
let version = apply_release_flag_to_version(Version::new(1, 17, 0), Some("nightly"))
.expect("flagged version");
assert_eq!(version.to_string(), "1.17.0+nightly");
}
#[test]
fn release_flag_rejects_conflicting_build_metadata() {
let version = Version::parse("1.17.0+nightly").expect("version");
let error =
apply_release_flag_to_version(version, Some("beta")).expect_err("conflicting flag");
assert!(error.contains("already has build metadata"));
}
#[test]
fn release_flag_updates_explicit_prefixed_tag_version() {
let version = apply_release_flag_to_version(Version::new(1, 17, 0), Some("nightly"))
.expect("flagged version");
let tag = scoped_release_tag_name(
&VersionScope::Repository,
&version,
"athena-auth-ui-1.17.0",
None,
)
.expect("tag name");
assert_eq!(tag, "athena-auth-ui-1.17.0+nightly");
}
#[test]
fn bumps_versions_correctly() {
let base: Version = Version::new(0, 1, 0);
assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
}
#[test]
fn version_change_guard_clears_when_worktree_is_clean() {
let entry = VersionChangeGuardEntry {
pending_version_change_count: 1,
head_commit: Some("abc123".to_string()),
};
let state = GitWorktreeState {
is_dirty: false,
head_commit: Some("abc123".to_string()),
};
assert!(should_clear_version_change_guard(&entry, &state));
}
#[test]
fn version_change_guard_clears_when_head_changes() {
let entry = VersionChangeGuardEntry {
pending_version_change_count: 1,
head_commit: Some("abc123".to_string()),
};
let state = GitWorktreeState {
is_dirty: true,
head_commit: Some("def456".to_string()),
};
assert!(should_clear_version_change_guard(&entry, &state));
}
#[test]
fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
let entry = VersionChangeGuardEntry {
pending_version_change_count: 1,
head_commit: Some("abc123".to_string()),
};
let state = GitWorktreeState {
is_dirty: true,
head_commit: Some("abc123".to_string()),
};
assert!(!should_clear_version_change_guard(&entry, &state));
}
#[test]
fn render_release_branch_name_replaces_supported_tokens() {
let branch = render_release_branch_name(
"releases/${GITHUB_VERSION}/${GITHUB_TAG}",
&Version::new(10, 27, 0),
"v10.27.0",
"xbp",
)
.expect("branch name");
assert_eq!(branch, "releases/10.27.0/v10.27.0");
}
#[test]
fn render_release_branch_name_supports_service_name_template() {
let branch = render_release_branch_name(
"releases/{VERSION}-{SERVICE_NAME}",
&Version::new(10, 27, 0),
"v10.27.0",
"athena/api",
)
.expect("branch name");
assert_eq!(branch, "releases/10.27.0-athena-api");
}
#[test]
fn prepare_release_branch_checkout_switches_commits_off_current_branch() {
let dir: PathBuf = temp_dir("release-branch-checkout");
fs::write(dir.join("README.md"), "# demo\n").expect("write readme");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "initial"]);
git(&dir, &["branch", "-M", "main"]);
let main_commit = git_head_commitish(&dir).expect("main commit");
let branch = prepare_release_branch_checkout(
&dir,
&GitHubReleaseBranchSettings {
naming_template: "releases/{VERSION}-{SERVICE_NAME}".to_string(),
},
&Version::new(2, 13, 0),
"v2.13.0",
"athena-js",
&main_commit,
)
.expect("prepare release branch");
assert_eq!(branch, "releases/2.13.0-athena-js");
assert_eq!(
git_current_branch(&dir).expect("current branch"),
Some(branch.clone())
);
fs::write(dir.join("README.md"), "# demo\n\nrelease\n").expect("update readme");
git(&dir, &["add", "README.md"]);
git(&dir, &["commit", "-m", "release commit"]);
let release_commit = git_head_commitish(&dir).expect("release commit");
assert_ne!(release_commit, main_commit);
git(&dir, &["checkout", "main"]);
assert_eq!(git_head_commitish(&dir).expect("main head"), main_commit);
git(&dir, &["checkout", &branch]);
assert_eq!(
git_head_commitish(&dir).expect("release head"),
release_commit
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn resolve_linear_release_placeholders_reads_env_files() {
let temp_dir = std::env::temp_dir().join(format!(
"xbp-linear-release-placeholders-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&temp_dir).expect("temp dir");
fs::write(
temp_dir.join(".env.local"),
"LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
)
.expect("env file");
let resolved = resolve_linear_release_placeholders(
&temp_dir,
ResolvedLinearReleaseConfig {
initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
health: "on_track".to_string(),
},
);
assert_eq!(
resolved.initiative_ids,
vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
);
assert_eq!(
resolved.organization_name.as_deref(),
Some("suits-formations")
);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn version_change_guard_clears_when_pending_count_is_zero() {
let entry = VersionChangeGuardEntry {
pending_version_change_count: 0,
head_commit: Some("abc123".to_string()),
};
let state = GitWorktreeState {
is_dirty: true,
head_commit: Some("abc123".to_string()),
};
assert!(should_clear_version_change_guard(&entry, &state));
}
#[test]
fn parse_package_version_target_supports_assignment_syntax() {
let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
.expect("parse")
.expect("target");
assert_eq!(parsed.0, "demo_pkg".to_string());
assert_eq!(parsed.1, Version::new(1, 2, 3));
}
#[test]
fn parse_package_version_target_rejects_invalid_package_names() {
let error: String = parse_package_version_target("bad package=1.2.3")
.expect_err("invalid package target should fail");
assert!(error.contains("Invalid package target"));
}
#[test]
fn parse_package_version_target_returns_none_without_assignment() {
assert!(parse_package_version_target("1.2.3")
.expect("parse")
.is_none());
}
#[test]
fn parse_package_version_target_returns_none_for_empty_package_name() {
assert!(parse_package_version_target(" =1.2.3")
.expect("parse")
.is_none());
}
#[test]
fn bumping_clears_prerelease_and_build_metadata() {
let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
}
#[test]
fn cargo_toml_adapter_reads_and_writes() {
let dir: PathBuf = temp_dir("cargo");
let path: PathBuf = dir.join("Cargo.toml");
fs::write(
&path,
r#"[package]
name = "xbp"
version = "1.0.0"
"#,
)
.expect("write Cargo.toml");
assert_eq!(
read_cargo_toml_version(&path).expect("read"),
Some("1.0.0".to_string())
);
write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_workspace_package_version_reads_and_writes_from_virtual_root() {
let dir: PathBuf = temp_dir("workspace-root-version");
let crate_dir = dir.join("crates").join("demo");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::write(
dir.join("Cargo.toml"),
r#"[workspace]
members = ["crates/demo"]
resolver = "2"
[workspace.package]
version = "0.1.0"
"#,
)
.expect("write workspace root");
fs::write(
crate_dir.join("Cargo.toml"),
r#"[package]
name = "demo"
version = { workspace = true }
"#,
)
.expect("write crate manifest");
assert_eq!(
read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read root"),
Some("0.1.0".to_string())
);
assert_eq!(
read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
Some("0.1.0".to_string())
);
write_cargo_toml_version(&dir.join("Cargo.toml"), &Version::new(0, 2, 0)).expect("write");
assert_eq!(
read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
Some("0.2.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn json_root_adapter_reads_and_writes() {
let dir: PathBuf = temp_dir("json");
let path: PathBuf = dir.join("package.json");
fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
assert_eq!(
read_json_root_version(&path).expect("read"),
Some("1.4.0".to_string())
);
write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("1.5.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn yaml_root_adapter_reads_and_writes() {
let dir: PathBuf = temp_dir("yaml");
let path: PathBuf = dir.join("xbp.yaml");
fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
assert_eq!(
read_yaml_root_version(&path, "version").expect("read"),
Some("0.2.0".to_string())
);
write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("0.3.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn toml_root_adapter_reads_and_writes() {
let dir: PathBuf = temp_dir("toml");
let path: PathBuf = dir.join("config.toml");
fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
assert_eq!(
read_toml_root_version(&path).expect("read"),
Some("3.1.4".to_string())
);
write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
assert_eq!(
read_toml_root_version(&path).expect("read"),
Some("3.2.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn openapi_adapter_reads_and_writes_nested_version() {
let dir: PathBuf = temp_dir("openapi");
let path: PathBuf = dir.join("openapi.yaml");
fs::write(
&path,
"openapi: 3.0.3\ninfo:\n title: Test\n version: 1.2.3\n",
)
.expect("write openapi");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("1.2.3".to_string())
);
write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("2.0.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn openapi_writer_creates_missing_info_mapping() {
let dir: PathBuf = temp_dir("openapi-missing-info");
let path: PathBuf = dir.join("openapi.yaml");
fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("4.0.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn json_openapi_adapter_reads_and_writes_nested_version() {
let dir: PathBuf = temp_dir("openapi-json");
let path: PathBuf = dir.join("openapi.json");
fs::write(
&path,
r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
)
.expect("write openapi json");
assert_eq!(
read_json_openapi_version(&path).expect("read"),
Some("1.2.3".to_string())
);
write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
assert_eq!(
read_json_openapi_version(&path).expect("read"),
Some("2.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn json_openapi_writer_creates_missing_info_object() {
let dir: PathBuf = temp_dir("openapi-json-missing-info");
let path: PathBuf = dir.join("openapi.json");
fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
assert_eq!(
read_json_openapi_version(&path).expect("read"),
Some("4.0.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_reader_prefers_project_version() {
let dir: PathBuf = temp_dir("pyproject-project");
let path: PathBuf = dir.join("pyproject.toml");
fs::write(
&path,
"[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
)
.expect("write pyproject");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("0.8.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_reader_falls_back_to_poetry_version() {
let dir: PathBuf = temp_dir("pyproject-poetry");
let path: PathBuf = dir.join("pyproject.toml");
fs::write(
&path,
"[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
)
.expect("write pyproject");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("1.9.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_writer_updates_project_table() {
let dir: PathBuf = temp_dir("pyproject-write-project");
let path: PathBuf = dir.join("pyproject.toml");
fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
.expect("write pyproject");
write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_writer_updates_poetry_table() {
let dir: PathBuf = temp_dir("pyproject-write-poetry");
let path: PathBuf = dir.join("pyproject.toml");
fs::write(
&path,
"[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
)
.expect("write pyproject");
write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("2.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_lock_reader_and_writer_follow_package_name() {
let dir: PathBuf = temp_dir("cargo-lock");
let cargo_toml: PathBuf = dir.join("Cargo.toml");
let cargo_lock: PathBuf = dir.join("Cargo.lock");
fs::write(
&cargo_toml,
r#"[package]
name = "xbp"
version = "1.0.0"
"#,
)
.expect("write Cargo.toml");
fs::write(
&cargo_lock,
r#"version = 4
[[package]]
name = "xbp"
version = "1.0.0"
[[package]]
name = "other"
version = "9.9.9"
"#,
)
.expect("write Cargo.lock");
assert_eq!(
read_cargo_lock_version(&cargo_lock).expect("read"),
Some("1.0.0".to_string())
);
write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
assert_eq!(
read_cargo_lock_version(&cargo_lock).expect("read"),
Some("1.0.1".to_string())
);
let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_lock_writer_errors_when_package_missing() {
let dir: PathBuf = temp_dir("cargo-lock-missing");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
)
.expect("write Cargo.toml");
let cargo_lock: PathBuf = dir.join("Cargo.lock");
fs::write(
&cargo_lock,
"version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
)
.expect("write Cargo.lock");
let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
.expect_err("missing package should fail");
assert!(error.contains("Could not find package `xbp`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_package_name_reads_package_section() {
let dir: PathBuf = temp_dir("cargo-package-name");
let cargo_lock: PathBuf = dir.join("Cargo.lock");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
)
.expect("write Cargo.toml");
fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
assert_eq!(
cargo_package_name(&cargo_lock).expect("name"),
Some("xbp-cli".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_toml_writer_skips_workspace_manifest_without_package() {
let dir: PathBuf = temp_dir("cargo-workspace-manifest");
let path: PathBuf = dir.join("Cargo.toml");
fs::write(
&path,
"[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
)
.expect("write Cargo.toml");
let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
assert!(changed);
let content = fs::read_to_string(&path).expect("read Cargo.toml");
assert!(content.contains("[workspace.package]"));
assert!(content.contains("version = \"2.0.0\""));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
let dir: PathBuf = temp_dir("workspace-cargo-skip");
fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
)
.expect("write Cargo.toml");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
)
.expect("write Cargo.lock");
fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
.expect("write README");
let updated = write_version_to_configured_files(
&dir,
&dir,
&[
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
],
&VersionScope::Repository,
&Version::new(1, 1, 0),
)
.expect("write versions");
assert_eq!(updated, 2);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read"),
Some("1.1.0".to_string())
);
let cargo_content = fs::read_to_string(dir.join("Cargo.toml")).expect("read Cargo.toml");
assert!(cargo_content.contains("version = \"1.1.0\""));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn repository_scope_prefers_workspace_default_member_manifest() {
let dir: PathBuf = temp_dir("workspace-default-member-path");
let crate_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::write(
dir.join("Cargo.toml"),
"[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
)
.expect("write workspace cargo");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
)
.expect("write crate cargo");
let resolved = super::resolve_registry_relative_path(
&dir,
&dir,
&VersionScope::Repository,
"Cargo.toml",
);
assert_eq!(resolved, "crates/cli/Cargo.toml");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn root_discovered_service_does_not_hijack_repository_scope() {
let dir: PathBuf = temp_dir("root-discovered-service-scope");
let crate_dir: PathBuf = dir.join("crates").join("cli");
let xbp_dir: PathBuf = dir.join(".xbp");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
dir.join("Cargo.toml"),
"[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
)
.expect("write workspace cargo");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
)
.expect("write crate cargo");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
)
.expect("write cargo lock");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: xbp
version: 10.35.0
port: 3000
build_dir: ./
services:
- name: xbp-2
target: python
branch: main
port: 8001
root_directory: ./
version_targets:
- Cargo.toml
- name: crates-cli
target: rust
branch: main
port: 8002
root_directory: crates/cli
version_targets:
- crates/cli/Cargo.toml
publish:
crates:
enabled: true
package_name: xbp
working_directory: crates/cli
manifest_path: crates/cli/Cargo.toml
version_targets:
- Cargo.toml
- crates/cli/Cargo.toml
"#,
)
.expect("write xbp config");
assert!(matches!(
resolve_version_scope(&dir, &dir),
VersionScope::Repository
));
let resolved = resolve_registry_paths(
&dir,
&dir,
&["Cargo.toml".to_string(), "Cargo.lock".to_string()],
&VersionScope::Repository,
);
assert!(resolved
.iter()
.any(|path| path.relative == "crates/cli/Cargo.toml"));
assert_eq!(
read_highest_version_from_targets(&resolved).expect("read selected versions"),
Some(Version::new(10, 35, 0))
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
let dir: PathBuf = temp_dir("workspace-default-member-writer");
let crate_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::write(
dir.join("Cargo.toml"),
"[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
)
.expect("write workspace cargo");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
)
.expect("write crate cargo");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
)
.expect("write cargo lock");
fs::write(
dir.join("README.md"),
"# XBP\n\ncurrent version: `10.21.0`\n",
)
.expect("write readme");
let updated = write_version_to_configured_files(
&dir,
&dir,
&[
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
],
&VersionScope::Repository,
&Version::new(10, 22, 0),
)
.expect("write versions");
assert_eq!(updated, 3);
assert_eq!(
read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
Some("10.22.0".to_string())
);
assert_eq!(
read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
Some("10.22.0".to_string())
);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read readme"),
Some("10.22.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
let dir: PathBuf = temp_dir("publish-manifest-version-target");
let package_dir = dir.join("packages").join("heroui");
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&package_dir).expect("create package dir");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: athena-auth-ui
version: 0.3.1
port: 4000
build_dir: ./
publish:
npm:
enabled: true
working_directory: packages/heroui
manifest_path: packages/heroui/package.json
"#,
)
.expect("write xbp config");
fs::write(
dir.join("package.json"),
r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
)
.expect("write root package");
fs::write(
package_dir.join("package.json"),
r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
)
.expect("write package manifest");
let updated = write_version_to_configured_files(
&dir,
&dir,
&["package.json".to_string()],
&VersionScope::Repository,
&Version::new(0, 3, 1),
)
.expect("write versions");
assert_eq!(updated, 2);
assert_eq!(
read_json_root_version(&dir.join("package.json")).expect("read root package"),
Some("0.3.1".to_string())
);
assert_eq!(
read_json_root_version(&package_dir.join("package.json")).expect("read package"),
Some("0.3.1".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_writer_updates_version_targets_from_xbp_config() {
let dir: PathBuf = temp_dir("explicit-version-targets");
let app_dir = dir.join("apps").join("web");
let cli_dir = dir.join("crates").join("cli");
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&app_dir).expect("create app dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: xbp
version: 10.30.0
port: 3000
build_dir: ./
version_targets:
- crates/cli/Cargo.toml
- apps/web/package.json
"#,
)
.expect("write xbp config");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cli Cargo.toml");
fs::write(
app_dir.join("package.json"),
r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
)
.expect("write app package");
let updated = write_version_to_configured_files(
&dir,
&dir,
&[".xbp/xbp.yaml".to_string()],
&VersionScope::Repository,
&Version::new(10, 30, 0),
)
.expect("write versions");
assert_eq!(updated, 3);
assert_eq!(
read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
Some("10.30.0".to_string())
);
assert_eq!(
read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
Some("10.30.0".to_string())
);
assert_eq!(
read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
Some("10.30.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
let package_dir = dir.join("packages").join("heroui");
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&package_dir).expect("create package dir");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: athena-auth-ui
version: 0.3.0
port: 4000
build_dir: ./
publish:
npm:
enabled: true
working_directory: packages/heroui
manifest_path: packages/heroui/package.json
"#,
)
.expect("write xbp config");
fs::write(
dir.join("package.json"),
r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
)
.expect("write root package");
fs::write(
package_dir.join("package.json"),
r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
)
.expect("write package manifest");
let _updated_paths = sync_version_to_configured_files_with_paths(
&dir,
&dir,
&["package.json".to_string()],
&VersionScope::Repository,
&Version::new(0, 3, 0),
)
.expect("sync versions");
assert_eq!(
read_json_root_version(&dir.join("package.json")).expect("read root package"),
Some("0.3.0".to_string())
);
assert_eq!(
read_json_root_version(&package_dir.join("package.json")).expect("read package"),
Some("0.3.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn readme_adapter_updates_current_version_marker() {
let dir: PathBuf = temp_dir("readme");
let path: PathBuf = dir.join("README.md");
fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
assert_eq!(
read_readme_version(&path).expect("read"),
Some("1.2.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn readme_writer_inserts_marker_when_missing() {
let dir: PathBuf = temp_dir("readme-insert");
let path: PathBuf = dir.join("README.md");
fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
let content: String = fs::read_to_string(&path).expect("read readme");
assert!(content.contains("current version: `3.0.0`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn regex_adapter_reads_and_writes_versions() {
let dir: PathBuf = temp_dir("regex");
let path: PathBuf = dir.join("build.gradle");
fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
assert_eq!(
read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
Some("5.4.3".to_string())
);
write_regex_version(
&path,
r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
&Version::new(5, 5, 0),
)
.expect("write");
assert_eq!(
read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
Some("5.5.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn regex_writer_errors_without_matching_pattern() {
let dir: PathBuf = temp_dir("regex-miss");
let path: PathBuf = dir.join("build.gradle");
fs::write(&path, "group = 'demo'\n").expect("write gradle");
let error: String = write_regex_version(
&path,
r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
&Version::new(1, 0, 0),
)
.expect_err("missing version should fail");
assert!(error.contains("No version pattern found"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
let original: &str = r#"[dependencies]
serde = "1.0.219"
tokio = { version = "1.44.1", features = ["full"] }
"#;
let (updated, changed) =
rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
.expect("rewrite");
assert!(changed);
assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
let (updated, changed) =
rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
.expect("rewrite");
assert!(changed);
assert!(updated.contains(r#"serde = "1.1.0""#));
}
#[test]
fn package_version_writer_updates_registry_toml_targets() {
let dir: PathBuf = temp_dir("package-version-registry");
let cargo_toml: PathBuf = dir.join("Cargo.toml");
fs::write(
&cargo_toml,
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
serde = "1.0.219"
tokio = { version = "1.44.1", features = ["full"] }
"#,
)
.expect("write Cargo.toml");
let updated: usize = write_package_version_to_configured_files(
&dir,
&dir,
&["Cargo.toml".to_string()],
&VersionScope::Repository,
"tokio",
&Version::new(1, 45, 1),
)
.expect("update package assignment");
assert_eq!(updated, 1);
let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn package_version_writer_errors_when_package_assignment_not_found() {
let dir: PathBuf = temp_dir("package-version-missing");
let cargo_toml: PathBuf = dir.join("Cargo.toml");
fs::write(
&cargo_toml,
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
serde = "1.0.219"
"#,
)
.expect("write Cargo.toml");
let error: String = write_package_version_to_configured_files(
&dir,
&dir,
&["Cargo.toml".to_string()],
&VersionScope::Repository,
"tokio",
&Version::new(1, 45, 1),
)
.expect_err("missing package assignment should fail");
assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn chart_writer_updates_app_version_when_present() {
let dir: PathBuf = temp_dir("chart");
let path: PathBuf = dir.join("Chart.yaml");
fs::write(
&path,
"apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
)
.expect("write chart");
write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
let content: String = fs::read_to_string(&path).expect("read chart");
assert!(content.contains("version: 0.2.0"));
assert!(content.contains("appVersion: 0.2.0"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_deduplicates_registry_entries() {
let dir: PathBuf = temp_dir("dedupe");
let readme: PathBuf = dir.join("README.md");
fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
let updated: usize = write_version_to_configured_files(
&dir,
&dir,
&[
"README.md".to_string(),
"README.md".to_string(),
"missing.md".to_string(),
],
&VersionScope::Repository,
&Version::new(1, 1, 0),
)
.expect("write versions");
assert_eq!(updated, 1);
assert_eq!(
read_readme_version(&readme).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_prefers_invocation_directory_targets() {
let dir: PathBuf = temp_dir("invocation-precedence");
let app_dir: PathBuf = dir.join("apps").join("web");
fs::create_dir_all(&app_dir).expect("create app dir");
let root_package: PathBuf = dir.join("package.json");
let app_package: PathBuf = app_dir.join("package.json");
fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
.expect("write root package");
fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
.expect("write app package");
let updated: usize = write_version_to_configured_files(
&dir,
&app_dir,
&["package.json".to_string()],
&VersionScope::Repository,
&Version::new(2, 14, 0),
)
.expect("write versions");
assert_eq!(updated, 1);
assert_eq!(
read_json_root_version(&root_package).expect("read root"),
Some("9.9.9".to_string())
);
assert_eq!(
read_json_root_version(&app_package).expect("read app"),
Some("2.14.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn resolve_project_root_prefers_nearest_xbp_config() {
let dir: PathBuf = temp_dir("nested-xbp-project-root");
let service_root: PathBuf = dir.join("services").join("athena-auth");
let nested_dir: PathBuf = service_root.join("src");
let service_xbp_dir: PathBuf = service_root.join(".xbp");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
fs::write(
service_xbp_dir.join("xbp.yaml"),
"project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
)
.expect("write service xbp config");
assert_eq!(resolve_project_root_from(&nested_dir), service_root);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn nested_xbp_repository_scope_does_not_touch_outer_root_version_files() {
let dir: PathBuf = temp_dir("nested-xbp-version-scope");
let outer_xbp_dir = dir.join(".xbp");
let service_root: PathBuf = dir.join("services").join("athena-auth");
let nested_dir: PathBuf = service_root.join("src");
let service_xbp_dir: PathBuf = service_root.join(".xbp");
fs::create_dir_all(&outer_xbp_dir).expect("create outer xbp dir");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
fs::write(
outer_xbp_dir.join("xbp.yaml"),
"project_name: root\nversion: 9.9.9\nport: 3000\nbuild_dir: ./\n",
)
.expect("write outer xbp config");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"root\"\nversion = \"9.9.9\"\n",
)
.expect("write outer cargo");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"root\"\nversion = \"9.9.9\"\n",
)
.expect("write outer cargo lock");
fs::write(
dir.join("README.md"),
"# root\n\ncurrent version: `9.9.9`\n",
)
.expect("write outer readme");
fs::write(
dir.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: Root\n version: 9.9.9\n",
)
.expect("write outer openapi");
fs::write(
dir.join("package.json"),
r#"{ "name": "root", "version": "9.9.9" }"#,
)
.expect("write outer package");
fs::write(
service_xbp_dir.join("xbp.yaml"),
"project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
)
.expect("write service xbp config");
fs::write(
service_root.join("Cargo.toml"),
"[package]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
)
.expect("write service cargo");
fs::write(
service_root.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
)
.expect("write service cargo lock");
fs::write(
service_root.join("README.md"),
"# athena-auth\n\ncurrent version: `3.22.0`\n",
)
.expect("write service readme");
fs::write(
service_root.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: Athena Auth\n version: 3.22.0\n",
)
.expect("write service openapi");
fs::write(
service_root.join("package.json"),
r#"{ "name": "athena-auth", "version": "3.22.0" }"#,
)
.expect("write service package");
let project_root = resolve_project_root_from(&nested_dir);
let updated_paths = write_version_to_configured_files_with_paths(
&project_root,
&nested_dir,
&[
".xbp/xbp.yaml".to_string(),
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
"openapi.yaml".to_string(),
"package.json".to_string(),
],
&VersionScope::Repository,
&Version::new(3, 22, 1),
)
.expect("write nested project versions");
let updated_relative = updated_paths
.iter()
.map(|path| path.strip_prefix(&project_root).expect("strip prefix"))
.map(normalized_relative_path)
.collect::<Vec<_>>();
assert_eq!(
updated_relative,
vec![
".xbp/xbp.yaml".to_string(),
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
"openapi.yaml".to_string(),
"package.json".to_string(),
]
);
assert_eq!(
read_yaml_root_version(&outer_xbp_dir.join("xbp.yaml"), "version")
.expect("read outer xbp"),
Some("9.9.9".to_string())
);
assert_eq!(
read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read outer cargo"),
Some("9.9.9".to_string())
);
assert_eq!(
read_cargo_lock_version(&dir.join("Cargo.lock")).expect("read outer cargo lock"),
Some("9.9.9".to_string())
);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read outer readme"),
Some("9.9.9".to_string())
);
assert_eq!(
read_openapi_version(&dir.join("openapi.yaml")).expect("read outer openapi"),
Some("9.9.9".to_string())
);
assert_eq!(
read_json_root_version(&dir.join("package.json")).expect("read outer package"),
Some("9.9.9".to_string())
);
assert_eq!(
read_yaml_root_version(&service_xbp_dir.join("xbp.yaml"), "version")
.expect("read service xbp"),
Some("3.22.1".to_string())
);
assert_eq!(
read_cargo_toml_version(&service_root.join("Cargo.toml")).expect("read service cargo"),
Some("3.22.1".to_string())
);
assert_eq!(
read_cargo_lock_version(&service_root.join("Cargo.lock"))
.expect("read service cargo lock"),
Some("3.22.1".to_string())
);
assert_eq!(
read_readme_version(&service_root.join("README.md")).expect("read service readme"),
Some("3.22.1".to_string())
);
assert_eq!(
read_openapi_version(&service_root.join("openapi.yaml")).expect("read service openapi"),
Some("3.22.1".to_string())
);
assert_eq!(
read_json_root_version(&service_root.join("package.json"))
.expect("read service package"),
Some("3.22.1".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn nested_repository_scope_limits_targets_and_release_version_to_local_project() {
let dir: PathBuf = temp_dir("nested-repo-local-release");
let package_root: PathBuf = dir.join("packages").join("athena-js");
let package_xbp_dir = package_root.join(".xbp");
fs::create_dir_all(&package_xbp_dir).expect("create package xbp dir");
fs::write(
package_xbp_dir.join("xbp.yaml"),
r#"project_name: athena-js
version: 2.13.0
publish:
npm:
enabled: true
manifest_path: package.json
"#,
)
.expect("write package xbp config");
fs::write(
package_root.join("package.json"),
r#"{ "name": "@xylex-group/athena", "version": "2.13.0" }"#,
)
.expect("write package manifest");
fs::write(
package_root.join("README.md"),
"# athena-js\n\ncurrent version: `2.13.0`\n",
)
.expect("write package readme");
fs::write(
dir.join("README.md"),
"# athena\n\ncurrent version: `3.27.0`\n",
)
.expect("write outer readme");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "initial"]);
git(&dir, &["tag", "v3.27.0"]);
let scope = resolve_version_scope(&package_root, &package_root);
assert!(matches!(scope, VersionScope::Repository));
let registry = vec![
".xbp/xbp.yaml".to_string(),
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
"package.json".to_string(),
];
let resolved = resolve_registry_paths(&package_root, &package_root, ®istry, &scope);
assert_eq!(
resolved
.iter()
.map(|entry| entry.relative.clone())
.collect::<Vec<_>>(),
vec![
".xbp/xbp.yaml".to_string(),
"README.md".to_string(),
"package.json".to_string(),
]
);
let report = collect_version_report(&package_root, &package_root, ®istry, &scope);
assert_eq!(report.highest_available(), Version::new(3, 27, 0));
assert_eq!(
resolve_release_version_from_report(&package_root, &scope, &report),
Version::new(2, 13, 0)
);
let baseline =
resolve_change_baseline(&package_root, &scope, "athena-js").expect("baseline");
assert_eq!(baseline.0, "initial-release".to_string());
assert_eq!(baseline.1, None);
assert_eq!(baseline.2, None);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn resolve_version_scope_detects_crate_scoped_invocation() {
let dir: PathBuf = temp_dir("crate-scope");
let crate_dir: PathBuf = dir.join("crates").join("alpha");
let nested_dir: PathBuf = crate_dir.join("src");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
)
.expect("write Cargo.toml");
let scope = resolve_version_scope(&dir, &nested_dir);
match scope {
VersionScope::Crate {
package_name,
crate_relative_root,
tag_prefix,
..
} => {
assert_eq!(package_name, "alpha-crate");
assert_eq!(crate_relative_root, "crates/alpha");
assert_eq!(tag_prefix, "alpha-crate-");
}
_ => panic!("expected crate scope"),
}
let _ = fs::remove_dir_all(dir);
}
#[test]
fn resolve_version_scope_detects_service_scoped_invocation() {
let dir: PathBuf = temp_dir("service-scope");
let service_dir: PathBuf = dir.join("apps").join("web");
let nested_dir: PathBuf = service_dir.join("src");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &nested_dir);
match scope {
VersionScope::Service {
service_name,
service_relative_root,
tag_prefix,
..
} => {
assert_eq!(service_name, "web");
assert_eq!(service_relative_root, "apps/web");
assert_eq!(tag_prefix, "web-");
}
_ => panic!("expected service scope"),
}
let _ = fs::remove_dir_all(dir);
}
#[test]
fn service_scoped_configured_targets_only_include_selected_service() {
let dir: PathBuf = temp_dir("service-targets");
let service_dir: PathBuf = dir.join("apps").join("web");
let nested_dir: PathBuf = service_dir.join("src");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &nested_dir);
let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].relative, "apps/web/package.json");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn service_scoped_release_publish_filter_selects_matching_target() {
let dir: PathBuf = temp_dir("service-publish-filter");
let service_dir: PathBuf = dir.join("apps").join("web");
let nested_dir: PathBuf = service_dir.join("src");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &nested_dir);
let publish_target =
resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
assert_eq!(publish_target.as_deref(), Some("npm"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn current_scope_ledger_path_uses_repo_local_scope_directory() {
let dir: PathBuf = temp_dir("ledger-path");
let scope = resolve_version_scope(&dir, &dir);
let path = current_scope_ledger_path(&dir, &scope, "xbp", &Version::new(10, 30, 0));
assert_eq!(
path,
dir.join(".xbp")
.join("releases")
.join("repository-xbp")
.join("10.30.0.yaml")
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn repository_change_selection_matches_only_changed_target_manifest_root() {
let dir: PathBuf = temp_dir("selection-direct");
let service_dir: PathBuf = dir.join("apps").join("web");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(service_dir.join("src")).expect("create service dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &dir);
let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
let selected = select_changed_version_targets(
&dir,
&scope,
&targets,
&["apps/web/src/app.tsx".into()],
);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].relative, "apps/web/package.json");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn repository_change_selection_fans_out_on_shared_root_change() {
let dir: PathBuf = temp_dir("selection-shared");
let service_dir: PathBuf = dir.join("apps").join("web");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(service_dir.join("src")).expect("create service dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &dir);
let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
let selected =
select_changed_version_targets(&dir, &scope, &targets, &["README.md".into()]);
assert_eq!(selected.len(), 2);
assert_eq!(
selected
.iter()
.map(|target| target.relative.as_str())
.collect::<Vec<_>>(),
vec!["crates/cli/Cargo.toml", "apps/web/package.json"]
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn selected_version_writer_updates_only_selected_targets() {
let dir: PathBuf = temp_dir("selection-write");
let service_dir: PathBuf = dir.join("apps").join("web");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(service_dir.join("src")).expect("create service dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &dir);
let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
let selected = select_changed_version_targets(
&dir,
&scope,
&targets,
&["apps/web/src/app.tsx".into()],
);
let updated = write_version_to_selected_paths(&selected, &Version::new(10, 30, 0), false)
.expect("write selected targets");
assert_eq!(updated.len(), 1);
assert_eq!(
read_json_root_version(&service_dir.join("package.json")).expect("read web package"),
Some("10.30.0".to_string())
);
assert_eq!(
read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
Some("10.29.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
let dir: PathBuf = temp_dir("crate-writer");
let crate_dir: PathBuf = dir.join("crates").join("alpha");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
)
.expect("write crate Cargo.toml");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
)
.expect("write Cargo.lock");
fs::write(
dir.join("README.md"),
"# root\n\ncurrent version: `9.9.9`\n",
)
.expect("write root readme");
let scope = resolve_version_scope(&dir, &crate_dir);
let updated = write_version_to_configured_files(
&dir,
&crate_dir,
&[
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
],
&scope,
&Version::new(1, 3, 0),
)
.expect("write versions");
assert_eq!(updated, 2);
assert_eq!(
read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
Some("1.3.0".to_string())
);
assert_eq!(
read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
.expect("read cargo lock"),
Some("1.3.0".to_string())
);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read readme"),
Some("9.9.9".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_resolution_prefers_crate_scope() {
let dir: PathBuf = temp_dir("release-openapi-crate");
let crate_dir: PathBuf = dir.join("crates").join("monitor");
let nested_dir: PathBuf = crate_dir.join("src");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
let crate_openapi: PathBuf = crate_dir.join("openapi.json");
fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
let scope = resolve_version_scope(&dir, &nested_dir);
let resolved = resolve_release_openapi_specs(&dir, &nested_dir, &scope)
.expect("crate-scoped openapi")
.into_iter()
.next()
.expect("crate-scoped openapi");
assert_eq!(resolved, crate_openapi);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_resolution_falls_back_to_repo_root() {
let dir: PathBuf = temp_dir("release-openapi-root");
let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
fs::create_dir_all(&crate_dir).expect("create crate dir");
let root_openapi: PathBuf = dir.join("openapi.json");
fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
let scope = resolve_version_scope(&dir, &dir);
let resolved = resolve_release_openapi_specs(&dir, &crate_dir, &scope)
.expect("repo root openapi")
.into_iter()
.next()
.expect("repo root openapi");
assert_eq!(resolved, root_openapi);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_assets_use_versioned_release_tag_names() {
let dir: PathBuf = temp_dir("release-openapi-assets");
fs::write(
dir.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: Athena RS\n version: 3.15.2\n",
)
.expect("write http openapi");
fs::write(
dir.join("openapi-wss.yaml"),
"openapi: 3.0.3\ninfo:\n title: Athena WSS\n version: 1.3.0\n",
)
.expect("write wss openapi");
let assets = prepare_release_openapi_assets(
&dir,
&dir,
&VersionScope::Repository,
&Version::new(3, 15, 2),
"v3.15.2",
)
.expect("prepare release assets");
assert_eq!(
assets
.iter()
.map(|asset| asset.asset_name.as_str())
.collect::<Vec<_>>(),
vec!["openapi-v3.15.2.yaml", "openapi-wss-v3.15.2.yaml"]
);
assert_eq!(assets[0].source_path, dir.join("openapi.yaml"));
assert_eq!(assets[1].source_path, dir.join("openapi-wss.yaml"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_assets_reject_http_version_mismatch() {
let dir: PathBuf = temp_dir("release-openapi-assets-mismatch");
fs::write(
dir.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: Athena RS\n version: 3.15.1\n",
)
.expect("write http openapi");
let error = prepare_release_openapi_assets(
&dir,
&dir,
&VersionScope::Repository,
&Version::new(3, 15, 2),
"v3.15.2",
)
.expect_err("mismatched OpenAPI version should fail");
assert!(error.contains("does not match release version"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
let dir: PathBuf = temp_dir("invocation-dedupe");
let app_dir: PathBuf = dir.join("apps").join("web");
fs::create_dir_all(&app_dir).expect("create app dir");
let app_package: PathBuf = app_dir.join("package.json");
fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
.expect("write app package");
let updated: usize = write_version_to_configured_files(
&dir,
&app_dir,
&[
"package.json".to_string(),
"apps/web/package.json".to_string(),
],
&VersionScope::Repository,
&Version::new(2, 14, 0),
)
.expect("write versions");
assert_eq!(updated, 1);
assert_eq!(
read_json_root_version(&app_package).expect("read app"),
Some("2.14.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_errors_when_no_targets_exist() {
let dir: PathBuf = temp_dir("no-targets");
let error: String = write_version_to_configured_files(
&dir,
&dir,
&["missing.toml".to_string()],
&VersionScope::Repository,
&Version::new(1, 0, 0),
)
.expect_err("missing targets should fail");
assert!(error.contains("No configured version files were found"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn remote_git_tag_parser_deduplicates_peeled_refs() {
let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
"abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
assert_eq!(
parsed[1].version,
Version::parse("0.1.7-exp").expect("version")
);
assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
}
#[test]
fn local_git_tag_parser_normalizes_prefixed_versions() {
let parsed: Vec<crate::commands::version::GitTagObservation> =
parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].version, Version::new(1, 0, 0));
assert_eq!(
parsed[0].raw_tags,
vec!["1.0.0".to_string(), "v1.0.0".to_string()]
);
}
#[test]
fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
let scope = VersionScope::Crate {
crate_root: PathBuf::from("/tmp/crates/alpha"),
crate_relative_root: "crates/alpha".to_string(),
package_name: "alpha-crate".to_string(),
tag_prefix: "alpha-crate-".to_string(),
};
let parsed = parse_local_git_tag_output_for_scope(
"alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
&scope,
);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].version, Version::new(1, 2, 0));
assert_eq!(parsed[1].version, Version::new(1, 0, 0));
}
#[test]
fn crate_scoped_release_tags_default_to_package_prefix() {
let scope = VersionScope::Crate {
crate_root: PathBuf::from("/tmp/crates/alpha"),
crate_relative_root: "crates/alpha".to_string(),
package_name: "alpha-crate".to_string(),
tag_prefix: "alpha-crate-".to_string(),
};
assert_eq!(
default_release_tag_name(&scope, &Version::new(1, 2, 3), None).expect("default tag"),
"alpha-crate-1.2.3"
);
}
#[test]
fn scoped_release_tags_strip_npm_scope_by_default() {
let scope = VersionScope::Service {
service_root: PathBuf::from("/tmp/packages/heroui"),
service_relative_root: "packages/heroui".to_string(),
service_name: "@xylex-group/athena-auth-ui".to_string(),
tag_prefix: "athena-auth-ui-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["packages/heroui".to_string()],
};
let version = Version::parse("1.17.0+nightly").expect("version");
assert_eq!(
default_release_tag_name(&scope, &version, None).expect("default tag"),
"athena-auth-ui-1.17.0+nightly"
);
}
#[test]
fn custom_release_git_tag_template_can_use_full_package_slug() {
let scope = VersionScope::Service {
service_root: PathBuf::from("/tmp/packages/heroui"),
service_relative_root: "packages/heroui".to_string(),
service_name: "@xylex-group/athena-auth-ui".to_string(),
tag_prefix: "athena-auth-ui-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["packages/heroui".to_string()],
};
let version = Version::parse("1.17.0+nightly").expect("version");
assert_eq!(
default_release_tag_name(&scope, &version, Some("{PACKAGE_FULL_NAME}-{VERSION}"))
.expect("custom tag"),
"xylex-group-athena-auth-ui-1.17.0+nightly"
);
}
#[test]
fn blob_reader_handles_head_readme_versions() {
assert_eq!(
read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
.expect("read"),
Some("0.4.0".to_string())
);
}
#[test]
fn blob_reader_handles_head_cargo_lock_versions() {
let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
let cargo_lock: &str =
"version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
assert_eq!(
read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
Some("0.2.0".to_string())
);
}
#[test]
fn package_name_lookup_reads_json_name_for_npm() {
let lookup: PackageNameLookup = PackageNameLookup {
file: "package.json".to_string(),
format: "json".to_string(),
key: "name".to_string(),
registry: "npm".to_string(),
};
assert_eq!(
read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
.expect("read"),
Some("@xylex/athena-mcp".to_string())
);
}
#[test]
fn package_name_lookup_reads_toml_nested_package_name() {
let lookup: PackageNameLookup = PackageNameLookup {
file: "Cargo.toml".to_string(),
format: "toml".to_string(),
key: "package.name".to_string(),
registry: "crates.io".to_string(),
};
assert_eq!(
read_package_name_from_lookup(
&lookup,
"[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
)
.expect("read"),
Some("athena-mcp".to_string())
);
}
#[test]
fn package_name_lookup_errors_on_unknown_format() {
let lookup: PackageNameLookup = PackageNameLookup {
file: "meta.txt".to_string(),
format: "ini".to_string(),
key: "name".to_string(),
registry: "npm".to_string(),
};
let error = read_package_name_from_lookup(&lookup, "name=demo")
.expect_err("unsupported format should fail");
assert!(error.contains("Unsupported lookup format"));
}
#[test]
fn highest_version_observation_returns_max_version() {
let entries: Vec<VersionObservation> = vec![
VersionObservation {
location: "README.md".to_string(),
version: Version::new(1, 0, 0),
},
VersionObservation {
location: "Cargo.toml".to_string(),
version: Version::new(1, 2, 0),
},
];
assert_eq!(
highest_version_observation(&entries).expect("max version"),
Version::new(1, 2, 0)
);
}
#[test]
fn stale_version_observations_only_returns_outdated_entries() {
let entries: Vec<VersionObservation> = vec![
VersionObservation {
location: "README.md".to_string(),
version: Version::new(1, 1, 0),
},
VersionObservation {
location: "Cargo.toml".to_string(),
version: Version::new(1, 2, 0),
},
VersionObservation {
location: "openapi.yaml".to_string(),
version: Version::new(1, 0, 5),
},
];
let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
assert_eq!(stale.len(), 2);
assert!(stale.iter().any(|entry| entry.location == "README.md"));
assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
}
#[test]
fn parses_github_remote_urls() {
assert_eq!(
parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url(
"https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
),
Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url(
"https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
),
Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
None
);
}
#[test]
fn redacts_credentials_in_remote_urls() {
let redacted = redact_remote_url_credentials(
"https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
);
assert!(redacted.contains("REDACTED"));
assert!(!redacted.contains("ghp_secretvalue"));
let username_only = redact_remote_url_credentials(
"https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
);
assert!(username_only.contains("REDACTED@github.com"));
assert!(!username_only.contains("floris-xlx@github.com"));
let ssh_remote =
redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
}
#[test]
fn builds_github_release_urls_with_encoded_tag_segments() {
let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
assert_eq!(
create_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
);
let lookup_url =
github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
.expect("url");
assert_eq!(
lookup_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
);
let update_url =
github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
assert_eq!(
update_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
);
let lookup_with_special_tag = github_release_by_tag_endpoint(
"SuitsBooks",
"suits-invoicing",
"release candidate/v0.0.1+build",
)
.expect("url");
assert_eq!(
lookup_with_special_tag.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
);
}
#[test]
fn builds_github_release_asset_urls() {
let list_url =
github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
assert_eq!(
list_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
);
let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
.expect("url");
assert_eq!(
delete_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
);
let upload_url = github_release_asset_upload_endpoint(
"SuitsBooks",
"suits-invoicing",
42,
"openapi spec.json",
)
.expect("url");
assert_eq!(
upload_url.as_str(),
"https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
);
}
#[test]
fn maps_release_latest_policy_to_github_api_values() {
assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
}
#[test]
fn release_channel_from_semver_prerelease_labels() {
let stable = Version::parse("3.6.2").expect("version");
let nightly = Version::parse("3.6.2-nightly.1").expect("version");
let experimental = Version::parse("0.1.1-alpha.1").expect("version");
assert_eq!(release_channel(&stable), "stable");
assert_eq!(release_channel(&nightly), "nightly");
assert_eq!(release_channel(&experimental), "experimental");
}
#[test]
fn renders_release_docs_from_entries() {
let entries = vec![
ReleaseDocEntry {
tag: "v3.6.2".to_string(),
version: Version::parse("3.6.2").expect("version"),
date: "2026-04-27".to_string(),
},
ReleaseDocEntry {
tag: "docs-0.1.1-alpha.1".to_string(),
version: Version::parse("0.1.1-alpha.1").expect("version"),
date: "2026-04-20".to_string(),
},
];
let changelog = render_changelog("xylex-group", "athena", &entries);
assert!(changelog.contains("## [3.6.2]"));
assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
assert!(changelog.contains("Release channel: stable"));
assert!(changelog.contains("Release channel: experimental"));
let security = render_security_policy(&entries);
assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
}
#[test]
fn formats_release_commit_lines_with_sha_and_pr_links() {
let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
let formatted =
format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
.expect("formatted line");
assert_eq!(
formatted,
"[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
);
}
#[test]
fn formats_release_commit_lines_with_linear_links_when_available() {
let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
let issue_infos = BTreeMap::from([(
"SUI-1336".to_string(),
LinearIssueInfo {
title: "Release flow".to_string(),
url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
},
)]);
let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
.expect("formatted line");
assert_eq!(
formatted,
"[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Fix release flow for [SUI-1336](https://linear.app/suitsbooks/issue/SUI-1336/release-flow) ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
);
}
#[test]
fn renders_release_notes_in_requested_layout() {
let commits = vec![
ReleaseCommitEntry {
full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
short_sha: "abcdef1".to_string(),
subject: "Improve release docs (#42)".to_string(),
date: "2026-05-24".to_string(),
},
ReleaseCommitEntry {
full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
short_sha: "fedcba9".to_string(),
subject: "Fix release flow for SUI-1336".to_string(),
date: "2026-05-25".to_string(),
},
];
let pull_request_infos = BTreeMap::from([(
"42".to_string(),
super::release_notes::GithubPullRequestInfo {
title: "Improve release docs".to_string(),
url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
},
)]);
let issue_infos = BTreeMap::from([(
"SUI-1336".to_string(),
LinearIssueInfo {
title: "Release flow".to_string(),
url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
},
)]);
let sections = build_fallback_sections(&commits);
let rendered = render_release_notes(&ReleaseNotesRenderInput {
release_title: "1.7.0 - athena-auth",
current_tag_name: "v1.7.0",
owner: "xylex-group",
repo: "athena-auth",
previous_tag: Some("v1.6.0"),
sections: §ions,
commit_entries: &commits,
pull_request_infos: &pull_request_infos,
linear_issue_infos: &issue_infos,
});
assert_eq!(
rendered,
"# [1.7.0](https://github.com/xylex-group/athena-auth/releases/tag/v1.7.0) - [athena-auth](https://github.com/xylex-group/athena-auth)\n\n## What's Changed\n\nComparing changes since [v1.6.0](https://github.com/xylex-group/athena-auth/releases/tag/v1.6.0).\n\n### Documentation & Tooling\n\nDocumentation and tooling changes grouped around dependency updates and developer guidance.\n\n- [abcdef1](https://github.com/xylex-group/athena-auth/commit/abcdef1234567890abcdef1234567890abcdef12) Updated documentation around release docs.\n- [#42](https://github.com/xylex-group/athena-auth/pull/42) Improve release docs\n\n### Maintenance\n\nGeneral maintenance changes grouped into the main release summary.\n\n- [fedcba9](https://github.com/xylex-group/athena-auth/commit/fedcba9876543210fedcba9876543210fedcba98) Improved general behavior through release flow.\n- [SUI-1336](https://linear.app/suitsbooks/issue/SUI-1336/release-flow) Release flow\n\n---\n\nRelease: [v1.7.0](https://github.com/xylex-group/athena-auth/releases/tag/v1.7.0)"
);
}
#[test]
fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
let commits = vec![
ReleaseCommitEntry {
full_sha: "a".repeat(40),
short_sha: "aaaaaaa".to_string(),
subject: "Fix SUI-1336 and SUI-1440".to_string(),
date: "2026-05-24".to_string(),
},
ReleaseCommitEntry {
full_sha: "b".repeat(40),
short_sha: "bbbbbbb".to_string(),
subject: "Touch SUI-1336 again".to_string(),
date: "2026-05-25".to_string(),
},
];
assert_eq!(
collect_linear_issue_identifiers(&commits),
vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
);
}
#[test]
fn release_title_defaults_to_version_and_repo() {
assert_eq!(
default_release_title(&Version::new(1, 7, 0), "athena-auth"),
"1.7.0 - athena-auth"
);
}
#[test]
fn deduplicates_release_commit_entries_by_exact_subject() {
let commits = vec![
ReleaseCommitEntry {
full_sha: "a".repeat(40),
short_sha: "aaaaaaa".to_string(),
subject: "Improve release docs".to_string(),
date: "2026-05-24".to_string(),
},
ReleaseCommitEntry {
full_sha: "b".repeat(40),
short_sha: "bbbbbbb".to_string(),
subject: "Improve release docs".to_string(),
date: "2026-05-25".to_string(),
},
];
let deduplicated = deduplicate_release_commit_entries(&commits);
assert_eq!(deduplicated.len(), 1);
assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
}
#[test]
fn fallback_sections_collapse_related_commit_themes() {
let commits = vec![
ReleaseCommitEntry {
full_sha: "a".repeat(40),
short_sha: "chat001".to_string(),
subject: "Add optimistic chat retries".to_string(),
date: "2026-06-01".to_string(),
},
ReleaseCommitEntry {
full_sha: "b".repeat(40),
short_sha: "chat002".to_string(),
subject: "Persist deleted-message state in chat".to_string(),
date: "2026-06-01".to_string(),
},
ReleaseCommitEntry {
full_sha: "c".repeat(40),
short_sha: "file001".to_string(),
subject: "Fix upload UTF-8 audit retry handling".to_string(),
date: "2026-06-01".to_string(),
},
ReleaseCommitEntry {
full_sha: "d".repeat(40),
short_sha: "ath001".to_string(),
subject: "Migrate form progress routes to Athena".to_string(),
date: "2026-06-01".to_string(),
},
ReleaseCommitEntry {
full_sha: "e".repeat(40),
short_sha: "ath002".to_string(),
subject: "Update Athena models and package wiring".to_string(),
date: "2026-06-01".to_string(),
},
];
let sections = build_fallback_sections(&commits);
assert_eq!(sections.len(), 3);
assert_eq!(sections[0].title, "Cases & Communication");
assert!(!sections[0].summary.is_empty());
assert_eq!(sections[0].bullets.len(), 2);
assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
assert!(sections[0].bullets[0].summary.contains("chat"));
assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
assert!(sections[0].bullets[1].summary.contains("deleted-message"));
assert_eq!(sections[1].title, "Reliability");
assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
assert_eq!(sections[2].title, "Athena Migration");
assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
}
#[test]
fn appends_release_label_footer_for_pre_release() {
let with_label = append_release_label_footer("# Release", true);
assert_eq!(
with_label,
format!(
"# Release\nRelease label: Pre-release\nGenerated by XBP {}",
env!("CARGO_PKG_VERSION")
)
);
}
}