//! Version management commands and adapters.
use crate::cli::auto_commit::{commit_paths, print_skip, AutoCommitRequest, AutoCommitResult};
use crate::cli::ui::Loader;
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, XbpConfig};
use crate::utils::{
command_exists, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
parse_config_with_auto_heal, parse_github_repo_from_remote_url, redact_remote_url_credentials,
resolve_env_placeholders,
};
use colored::Colorize;
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::path::{Path, PathBuf};
use std::process::Command;
use toml::Value as TomlValue;
#[path = "version/github_release.rs"]
mod github_release;
#[path = "version/release_docs.rs"]
mod release_docs;
#[path = "version/release_linear.rs"]
mod release_linear;
#[path = "version/release_notes.rs"]
mod release_notes;
#[path = "version/workspace_release.rs"]
mod workspace_release;
use github_release::{
create_github_release, get_github_release_by_tag, update_github_release,
upload_github_release_asset, GithubReleaseInput, GithubReleaseResult, GithubReleaseTagResponse,
};
use release_docs::sync_release_docs;
use release_linear::{
publish_release_to_linear_initiatives, resolve_linear_release_config,
LinearReleasePublishInput, ResolvedLinearReleaseConfig,
};
use release_notes::{generate_release_notes, ReleaseNotesRequest};
pub use workspace_release::{
run_version_workspace_command, 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>,
note: Option<String>,
}
#[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,
},
}
#[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>,
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 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()
}
}
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(&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)?;
let current: Version = 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_configured_files_with_paths(
&project_root,
&invocation_dir,
®istry,
&version_scope,
&next,
)?;
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)?;
Ok(())
}
Some(explicit) => {
enforce_version_change_guard(&project_root)?;
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)?;
} else {
let version: Version = parse_version(explicit)?;
let updated_paths = write_version_to_configured_files_with_paths(
&project_root,
&invocation_dir,
®istry,
&version_scope,
&version,
)?;
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)?;
}
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 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 latest_policy: ReleaseLatestPolicy,
}
struct ReleaseWorkflowSummary {
tag_name: String,
release_url: String,
uploaded_openapi_asset: Option<String>,
published_initiatives: Vec<String>,
release_branch: Option<String>,
}
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,
allow_dirty,
title,
notes,
notes_file,
draft,
prerelease,
publish,
latest_policy,
} = options;
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(&project_root, &invocation_dir);
let sync_explicit_version = explicit_version.is_some();
let total_steps = 8usize + usize::from(sync_explicit_version) + usize::from(publish);
let mut step = 1usize;
loader.update(&format!(
"[{}/{}] Validating git state and resolving release target",
step, total_steps
));
if !allow_dirty {
let dirty: Vec<String> = git_dirty_entries(&project_root)?;
if !dirty.is_empty() {
let preview = dirty.into_iter().take(8).collect::<Vec<_>>().join(", ");
return Err(format!(
"Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
preview
));
}
}
let (release_version, tag_name) = if let Some(raw) = explicit_version.as_deref() {
let (version, parsed_tag_name) = parse_release_version_target(&raw)?;
(
version.clone(),
scoped_release_tag_name(&version_scope, &version, &parsed_tag_name),
)
} 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 = report.highest_available();
let tag_name: String = default_release_tag_name(&version_scope, &release_version);
(release_version, tag_name)
};
if sync_explicit_version {
step += 1;
loader.update(&format!(
"[{}/{}] Syncing configured version files",
step, total_steps
));
let registry: Vec<String> = load_versioning_files_registry()?;
let updated_paths = sync_version_to_configured_files_with_paths(
&project_root,
&invocation_dir,
®istry,
&version_scope,
&release_version,
)?;
if !updated_paths.is_empty() {
auto_commit_command_paths(
&project_root,
updated_paths,
format!("chore(version): update version to {}", release_version),
"xbp version release",
)
.await;
}
}
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!(
"[{}/{}] Publishing configured packages",
step, total_steps
));
run_publish_command_with_progress_prefix(
PublishCommandOptions {
dry_run: false,
allow_dirty,
target: None,
expected_version: Some(release_version.to_string()),
},
&loader,
format!("[{}/{}]", step, total_steps),
)
.await?;
}
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 release_title: String = title.unwrap_or_else(|| {
default_release_title(
&release_version,
release_title_subject(&version_scope, &repo),
)
});
let release_branch_config =
resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
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: resolve_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!(
"[{}/{}] 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 let Some(branch_config) = &release_branch_config {
Some(ensure_release_branch(
&project_root,
branch_config,
&release_version,
&tag_name,
&target_commitish,
)?)
} else {
None
};
if !tag_exists_local {
run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])?;
}
if !tag_exists_remote {
run_git_command(&project_root, &["push", "origin", &tag_name])?;
}
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 = 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 {
return Err(format!(
"{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
create_error, tag_name
));
};
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| {
format!(
"{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
create_error, tag_name, e
)
})?
} 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
)
}),
}
}
}
};
let release_url = release_result.html_url.clone();
step += 1;
loader.update(&format!(
"[{}/{}] Publishing release integrations",
step, total_steps
));
let openapi_path = resolve_release_openapi_spec(&project_root, &invocation_dir);
let linear_release_config =
resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
let openapi_release_input = release_input.clone();
let linear_release_input = release_input.clone();
let openapi_project_root = project_root.clone();
let openapi_tag_name = tag_name.clone();
let linear_tag_name = tag_name.clone();
let linear_release_url = release_url.clone();
let openapi_future = async move {
if let Some(openapi_path) = openapi_path {
upload_github_release_asset(
&openapi_release_input,
release_result.id,
&openapi_path,
)
.await
.map_err(|e| {
format!(
"Release `{}` was published, but uploading OpenAPI asset `{}` failed: {}",
openapi_tag_name,
openapi_path.display(),
e
)
})?;
Ok(Some(
openapi_path
.strip_prefix(&openapi_project_root)
.map(normalized_relative_path)
.unwrap_or_else(|_| normalized_relative_path(&openapi_path)),
))
} else {
Ok(None)
}
};
let linear_future = async move {
if let Some(linear_release_config) = linear_release_config {
let linear_api_key: String = resolve_linear_api_key().ok_or_else(|| {
"A Linear release target is configured, but no Linear API key was found. Configure `xbp config linear set-key`."
.to_string()
})?;
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: linear_release_input.title.clone(),
release_tag: linear_release_input.tag_name.clone(),
release_url: linear_release_url,
release_notes: linear_release_input.notes.clone(),
})
.await
.map_err(|e| {
format!(
"Release `{}` was published, but publishing to configured Linear initiatives failed: {}",
linear_tag_name, e
)
})
} else {
Ok(Vec::new())
}
};
let (uploaded_openapi_asset, published_initiatives) =
tokio::try_join!(openapi_future, linear_future)?;
step += 1;
loader.update(&format!(
"[{}/{}] Syncing release docs",
step, total_steps
));
let release_doc_paths = sync_release_docs(&project_root, &owner, &repo)?;
step += 1;
loader.update(&format!(
"[{}/{}] Auto-committing release docs",
step, total_steps
));
auto_commit_command_paths(
&project_root,
release_doc_paths,
format!("docs(release): sync release docs for {}", tag_name),
"xbp version release",
)
.await;
Ok(ReleaseWorkflowSummary {
tag_name,
release_url,
uploaded_openapi_asset,
published_initiatives,
release_branch: created_release_branch,
})
}
.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 let Some(openapi_asset) = summary.uploaded_openapi_asset {
println!("Uploaded OpenAPI asset: {}", openapi_asset);
}
if !summary.published_initiatives.is_empty() {
println!(
"Published release update to Linear initiative(s): {}",
summary.published_initiatives.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("."));
if let Some(root) = git_repository_root(&cwd) {
return root;
}
if let Some(found) = find_xbp_config_upwards(&cwd) {
return found.project_root;
}
cwd
}
fn collect_version_report(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> VersionReport {
let mut report: VersionReport = VersionReport::default();
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),
}
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, note) =
match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
Ok(version) => {
let parsed: Option<Version> = parse_version(&version).ok();
let note: Option<String> = if parsed.is_none() {
Some(format!("Non-semver registry version: {}", version))
} else {
None
};
(parsed, Some(version), note)
}
Err(err) => (None, None, Some(err)),
};
entries.push(RegistryVersionObservation {
registry: lookup.registry.clone(),
package_name,
source_file,
latest,
raw_version,
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<String, 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 {
version: String,
}
async fn fetch_npm_latest_version(
client: &reqwest::Client,
package_name: &str,
) -> Result<String, 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);
segments.push("latest");
}
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(payload.version)
}
#[derive(Debug, Deserialize)]
struct CratesIoResponse {
#[serde(rename = "crate")]
crate_meta: CratesIoMeta,
}
#[derive(Debug, Deserialize)]
struct CratesIoMeta {
newest_version: String,
}
async fn fetch_crates_latest_version(
client: &reqwest::Client,
package_name: &str,
) -> Result<String, 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: CratesIoResponse = response.json().await.map_err(|e| {
format!(
"Failed to parse crates.io response for {}: {}",
package_name, e
)
})?;
Ok(payload.crate_meta.newest_version)
}
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 {
println!(
" {:<20} {}",
"older tags".bright_white(),
format!("{} hidden", tags.len() - 1).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, .. } => 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, .. } => {
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())
);
println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
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);
}
}
}
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();
println!(
" {:<9} {:<28} {:<16} {}{}",
entry.registry.bright_white(),
entry.package_name.bright_white(),
latest_display,
entry.source_file.dimmed(),
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,
)
}
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> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
Ok(value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
.map(|value| value.to_string()))
}
fn read_cargo_toml_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_cargo_toml_version_from_content(&content)
}
fn write_cargo_toml_version(path: &Path, 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 Some(package) = value.get_mut("package").and_then(TomlValue::as_table_mut) else {
return Ok(false);
};
package.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))?;
Ok(true)
}
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 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 workspace_primary_target = match version_scope {
VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
VersionScope::Crate { .. } => 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,
);
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: project_root.join(&resolved_relative),
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,
);
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: project_root.join(&resolved_relative),
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,
});
}
}
}
for configured_path in
resolve_configured_publish_manifest_paths(project_root, invocation_dir, version_scope)
{
if seen.insert(configured_path.relative.clone()) {
resolved.push(configured_path);
}
}
resolved
}
fn resolve_configured_publish_manifest_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 manifest_paths = config
.publish
.into_iter()
.flat_map(|publish| [publish.npm, publish.crates])
.flatten()
.filter_map(|target| target.manifest_path)
.map(PathBuf::from)
.collect::<Vec<_>>();
let crate_root = match version_scope {
VersionScope::Repository => None,
VersionScope::Crate { crate_root, .. } => Some(crate_root),
};
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(crate_root) = crate_root {
if !absolute.starts_with(crate_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()?;
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 VersionScope::Crate { crate_root, .. } = version_scope {
let preferred = crate_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(project_root: &Path, invocation_dir: &Path) -> VersionScope {
if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
return crate_scope;
}
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!("{}-", package_name),
package_name,
})
}
fn resolve_release_openapi_spec(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
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: BTreeSet<PathBuf> = BTreeSet::new();
for root in roots {
if !seen.insert(root.clone()) {
continue;
}
for file_name in [
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
] {
let path = root.join(file_name);
if path.is_file() {
return Some(path);
}
}
}
None
}
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 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,
})
}
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)
}
}
fn normalized_relative_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
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 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) -> 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 mut changed = false;
if let Some(entry) = registry.entries.get(&repo_key).cloned() {
if should_clear_version_change_guard(&entry, &state) {
registry.entries.remove(&repo_key);
changed = true;
}
}
if changed {
save_version_change_guard_registry(&state_path, ®istry)?;
}
if state.is_dirty {
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) -> 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);
if state.is_dirty {
registry.entries.insert(
repo_key,
VersionChangeGuardEntry {
pending_version_change_count: 1,
head_commit: state.head_commit,
},
);
} else {
registry.entries.remove(&repo_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,
) -> Result<String, String> {
let branch_name = naming_template
.replace("${GITHUB_VERSION}", &release_version.to_string())
.replace("${GITHUB_TAG}", tag_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 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_remote_branch_commit(
project_root: &Path,
remote: &str,
branch_name: &str,
) -> Result<Option<String>, String> {
let output = run_git_command(
project_root,
&[
"ls-remote",
"--heads",
remote,
&format!("refs/heads/{}", branch_name),
],
)?;
let commit = output
.split_whitespace()
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok(commit)
}
fn ensure_release_branch(
project_root: &Path,
branch_config: &GitHubReleaseBranchSettings,
release_version: &Version,
tag_name: &str,
target_commitish: &str,
) -> Result<String, String> {
let branch_name =
render_release_branch_name(&branch_config.naming_template, release_version, tag_name)?;
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 `{}` already exists locally at {}, expected {}.",
branch_name, existing_local_commit, target_commitish
));
}
} else {
run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
}
if let Some(existing_remote_commit) =
git_remote_branch_commit(project_root, "origin", &branch_name)?
{
if existing_remote_commit != target_commitish {
return Err(format!(
"Configured release branch `{}` already exists on origin at {}, expected {}.",
branch_name, existing_remote_commit, target_commitish
));
}
} else {
run_git_command(
project_root,
&[
"push",
"origin",
&format!("{}:refs/heads/{}", target_commitish, branch_name),
],
)?;
}
Ok(branch_name)
}
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(),
}
}
fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
match version_scope {
VersionScope::Repository => format!("v{}", version),
VersionScope::Crate { tag_prefix, .. } => format!("{}{}", tag_prefix, version),
}
}
fn scoped_release_tag_name(
version_scope: &VersionScope,
version: &Version,
parsed_tag_name: &str,
) -> String {
match version_scope {
VersionScope::Repository => parsed_tag_name.to_string(),
VersionScope::Crate { .. } => {
if release_tag_family(parsed_tag_name) == "v" {
default_release_tag_name(version_scope, version)
} else {
parsed_tag_name.to_string()
}
}
}
}
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()),
}
}
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::{
append_release_label_footer, bump_version, cargo_package_name, default_release_tag_name,
default_release_title, highest_version_observation, 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, read_cargo_lock_version, read_cargo_lock_version_for_package,
read_cargo_toml_version, 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_linear_release_placeholders,
resolve_release_openapi_spec, resolve_version_scope,
rewrite_toml_package_assignment_versions, 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_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
VersionObservation, VersionScope,
};
use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
use crate::config::PackageNameLookup;
use semver::Version;
use std::collections::BTreeMap;
use std::fs;
use std::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");
dir
}
#[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 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",
)
.expect("branch name");
assert_eq!(branch, "releases/10.27.0/v10.27.0");
}
#[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 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);
assert_eq!(
fs::read_to_string(&path).expect("read Cargo.toml"),
"[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n"
);
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, 1);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read"),
Some("1.1.0".to_string())
);
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 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 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_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-");
}
VersionScope::Repository => panic!("expected crate scope"),
}
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 resolved =
resolve_release_openapi_spec(&dir, &nested_dir).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 resolved = resolve_release_openapi_spec(&dir, &crate_dir).expect("repo root openapi");
assert_eq!(resolved, root_openapi);
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)),
"alpha-crate-1.2.3"
);
}
#[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")
)
);
}
}