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,
};
use super::release_ledger::ReleasePublishTarget;
use super::release_notes::{
build_fallback_sections, build_release_install_snippets, collect_linear_issue_identifiers,
deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
LinearIssueInfo, ReleaseCommitEntry, ReleaseInstallMethod, ReleaseInstallSnippet,
ReleaseNotesRenderInput,
};
use super::{
analyze_dirty_worktree, append_release_label_footer, apply_release_flag_to_version,
bump_version, cargo_package_name, collect_version_report, current_scope_ledger_path,
default_release_tag_name, default_release_title, git_current_branch, git_head_commitish,
release_tag_name_for_project,
parse_canonical_scoped_release_tag_version, parse_release_family_version,
highest_version_observation, is_safe_dirty_path, normalized_relative_path,
parse_git_status_path, parse_local_git_tag_output, parse_local_git_tag_output_for_scope,
parse_package_version_target, parse_release_version_target, parse_remote_git_tag_output,
parse_version, prepare_release_branch_checkout, prepare_release_openapi_assets,
read_cargo_lock_version, read_cargo_lock_version_for_package, read_cargo_toml_version,
read_highest_version_from_targets, read_json_openapi_version, read_json_root_version,
read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
read_version_from_path, read_yaml_root_version, release_openapi_generation_enabled,
render_release_branch_name, resolve_change_baseline, resolve_configured_version_target_paths,
resolve_project_root_from, resolve_registry_paths, resolve_release_openapi_specs,
resolve_release_publish_target_filter, resolve_release_version_from_report,
previous_release_tag, resolve_version_scope, rewrite_toml_package_assignment_versions,
scoped_release_tag_name, select_changed_version_targets, should_clear_version_change_guard,
stale_version_observations, sync_version_to_configured_files_with_paths,
write_cargo_lock_version, write_cargo_toml_version, write_chart_version,
write_json_openapi_version, write_json_root_version, write_openapi_version,
write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
write_regex_version, write_toml_root_version, write_version_to_configured_files,
write_version_to_configured_files_with_paths, write_version_to_selected_paths,
write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
VersionObservation, VersionScope,
};
use crate::utils::{parse_github_repo_from_remote_url, redact_remote_url_credentials};
#[cfg(feature = "linear")]
use super::resolve_linear_release_placeholders;
#[cfg(feature = "linear")]
use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
use crate::config::PackageNameLookup;
use crate::strategies::deployment_config::GitHubReleaseBranchSettings;
use semver::Version;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos: u128 = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
fs::create_dir_all(&dir).expect("create temp dir");
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("create temp xbp dir");
fs::write(xbp_dir.join("xbp.yaml"), "version_targets: []\n")
.expect("write temp xbp config");
dir
}
fn git(dir: &Path, args: &[&str]) {
let git_home = dir.join("_git_home");
let _ = fs::create_dir_all(&git_home);
let mut last_err = None;
for attempt in 0..5 {
match std::process::Command::new("git")
.current_dir(dir)
.env("HOME", &git_home)
.env("XDG_CONFIG_HOME", git_home.join(".config"))
.env("GIT_CONFIG_GLOBAL", git_home.join(".gitconfig"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.args(args)
.status()
{
Ok(status) => {
assert!(
status.success(),
"git {:?} failed in {} (status={status:?})",
args,
dir.display()
);
return;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound && attempt < 4 => {
last_err = Some(err);
std::thread::sleep(std::time::Duration::from_millis(25 * (attempt as u64 + 1)));
}
Err(err) => panic!(
"failed to spawn `git {:?}` in {}: {err}",
args,
dir.display()
),
}
}
panic!(
"failed to spawn `git {:?}` in {} after retries: {}",
args,
dir.display(),
last_err
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown".into())
);
}
fn init_git_repo_with_tags(dir: &Path, tags: &[&str]) {
git(dir, &["init"]);
git(dir, &["config", "user.email", "test@example.com"]);
git(dir, &["config", "user.name", "Test"]);
fs::write(dir.join("README.md"), "demo\n").expect("write readme");
git(dir, &["add", "."]);
git(dir, &["commit", "-m", "init"]);
for tag in tags {
git(dir, &["tag", tag]);
}
}
#[test]
fn release_openapi_generation_reads_the_project_master_switch() {
let dir = temp_dir("openapi-release");
fs::write(
dir.join(".xbp/xbp.yaml"),
"project_name: demo\nversion: 1.2.3\nport: 3000\nbuild_dir: .\nopenapi:\n enabled: true\n generate_on_release: true\n",
)
.expect("write OpenAPI release config");
assert!(release_openapi_generation_enabled(&dir).unwrap());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn parse_git_status_path_handles_renames() {
assert_eq!(
parse_git_status_path("R old-name -> new-name/Cargo.toml"),
Some("new-name/Cargo.toml".to_string())
);
assert_eq!(
parse_git_status_path(" M .xbp/xbp.yaml"),
Some(".xbp/xbp.yaml".to_string())
);
}
#[test]
fn is_safe_dirty_path_recognizes_generated_paths() {
assert!(is_safe_dirty_path(".xbp/xbp.yaml"));
assert!(is_safe_dirty_path("crates/cli/target/debug/xbp"));
assert!(is_safe_dirty_path(
"scripts/__pycache__/publish.cpython-312.pyc"
));
assert!(!is_safe_dirty_path("crates/cli/src/main.rs"));
assert!(!is_safe_dirty_path("Cargo.toml"));
}
#[test]
fn analyze_dirty_worktree_splits_safe_and_risky_entries() {
let analysis = analyze_dirty_worktree(&[
" M .xbp/xbp.yaml".to_string(),
" M crates/cli/src/main.rs".to_string(),
]);
assert_eq!(analysis.safe_entries, vec![".xbp/xbp.yaml".to_string()]);
assert_eq!(
analysis.risky_entries,
vec!["crates/cli/src/main.rs".to_string()]
);
}
fn write_multi_service_config(dir: &Path) {
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: xbp
version: 10.30.0
port: 3000
build_dir: ./
services:
- name: cli
target: rust
branch: main
port: 8080
root_directory: ./
version_targets:
- crates/cli/Cargo.toml
- name: web
target: nextjs
branch: main
port: 3001
root_directory: apps/web
version_targets:
- apps/web/package.json
publish:
npm:
enabled: true
manifest_path: apps/web/package.json
crates:
enabled: true
manifest_path: crates/cli/Cargo.toml
version_targets:
- crates/cli/Cargo.toml
- apps/web/package.json
"#,
)
.expect("write xbp config");
}
#[test]
fn parses_prefixed_semver() {
assert_eq!(
parse_version("v1.2.3").expect("version"),
Version::new(1, 2, 3)
);
}
#[test]
fn rejects_invalid_semver() {
let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
assert!(error.contains("Invalid semantic version"));
}
#[test]
fn release_target_parser_supports_plain_semver() {
let (version, tag_name) =
parse_release_version_target("1.2.3-alpha.1").expect("release target");
assert_eq!(version.major, 1);
assert_eq!(version.minor, 2);
assert_eq!(version.patch, 3);
assert_eq!(version.pre.as_str(), "alpha.1");
assert_eq!(tag_name, "v1.2.3-alpha.1");
}
#[test]
fn release_target_parser_supports_prefixed_semver() {
let (version, tag_name) =
parse_release_version_target("studio-0.3.2-alpha").expect("release target");
assert_eq!(version.major, 0);
assert_eq!(version.minor, 3);
assert_eq!(version.patch, 2);
assert_eq!(version.pre.as_str(), "alpha");
assert_eq!(tag_name, "studio-0.3.2-alpha");
}
#[test]
fn release_flag_adds_build_metadata() {
let version = apply_release_flag_to_version(Version::new(1, 17, 0), Some("nightly"))
.expect("flagged version");
assert_eq!(version.to_string(), "1.17.0+nightly");
}
#[test]
fn release_flag_rejects_conflicting_build_metadata() {
let version = Version::parse("1.17.0+nightly").expect("version");
let error =
apply_release_flag_to_version(version, Some("beta")).expect_err("conflicting flag");
assert!(error.contains("already has build metadata"));
}
#[test]
fn release_flag_updates_explicit_prefixed_tag_version() {
let version = apply_release_flag_to_version(Version::new(1, 17, 0), Some("nightly"))
.expect("flagged version");
let scope = VersionScope::Service {
service_root: PathBuf::from("/tmp/packages/heroui"),
service_relative_root: "packages/heroui".to_string(),
service_name: "athena-auth-ui".to_string(),
tag_prefix: "athena-auth-ui-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["packages/heroui".to_string()],
};
let tag = scoped_release_tag_name(&scope, &version, "athena-auth-ui-1.17.0", "athena")
.expect("tag name");
assert_eq!(tag, "athena-auth-ui-1.17.0-nightly");
}
#[test]
fn bumps_versions_correctly() {
let base: Version = Version::new(0, 1, 0);
assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
}
#[test]
fn version_change_guard_clears_when_worktree_is_clean() {
let entry = VersionChangeGuardEntry {
pending_version_change_count: 1,
head_commit: Some("abc123".to_string()),
};
let state = GitWorktreeState {
is_dirty: false,
head_commit: Some("abc123".to_string()),
};
assert!(should_clear_version_change_guard(&entry, &state));
}
#[test]
fn version_change_guard_clears_when_head_changes() {
let entry = VersionChangeGuardEntry {
pending_version_change_count: 1,
head_commit: Some("abc123".to_string()),
};
let state = GitWorktreeState {
is_dirty: true,
head_commit: Some("def456".to_string()),
};
assert!(should_clear_version_change_guard(&entry, &state));
}
#[test]
fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
let entry = VersionChangeGuardEntry {
pending_version_change_count: 1,
head_commit: Some("abc123".to_string()),
};
let state = GitWorktreeState {
is_dirty: true,
head_commit: Some("abc123".to_string()),
};
assert!(!should_clear_version_change_guard(&entry, &state));
}
#[test]
fn render_release_branch_name_replaces_supported_tokens() {
let branch = render_release_branch_name(
"releases/${GITHUB_VERSION}/${GITHUB_TAG}",
&Version::new(10, 27, 0),
"v10.27.0",
"xbp",
)
.expect("branch name");
assert_eq!(branch, "releases/10.27.0/v10.27.0");
}
#[test]
fn render_release_branch_name_supports_service_name_template() {
let branch = render_release_branch_name(
"releases/{VERSION}-{SERVICE_NAME}",
&Version::new(10, 27, 0),
"v10.27.0",
"athena/api",
)
.expect("branch name");
assert_eq!(branch, "releases/10.27.0-athena-api");
}
#[test]
fn prepare_release_branch_checkout_switches_commits_off_current_branch() {
let dir: PathBuf = temp_dir("release-branch-checkout");
fs::write(dir.join("README.md"), "# demo\n").expect("write readme");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "initial"]);
git(&dir, &["branch", "-M", "main"]);
let main_commit = git_head_commitish(&dir).expect("main commit");
let branch = prepare_release_branch_checkout(
&dir,
&GitHubReleaseBranchSettings {
naming_template: "releases/{VERSION}-{SERVICE_NAME}".to_string(),
},
&Version::new(2, 13, 0),
"v2.13.0",
"athena-js",
&main_commit,
)
.expect("prepare release branch");
assert_eq!(branch, "releases/2.13.0-athena-js");
assert_eq!(
git_current_branch(&dir).expect("current branch"),
Some(branch.clone())
);
fs::write(dir.join("README.md"), "# demo\n\nrelease\n").expect("update readme");
git(&dir, &["add", "README.md"]);
git(&dir, &["commit", "-m", "release commit"]);
let release_commit = git_head_commitish(&dir).expect("release commit");
assert_ne!(release_commit, main_commit);
git(&dir, &["checkout", "main"]);
assert_eq!(git_head_commitish(&dir).expect("main head"), main_commit);
git(&dir, &["checkout", &branch]);
assert_eq!(
git_head_commitish(&dir).expect("release head"),
release_commit
);
let _ = fs::remove_dir_all(dir);
}
#[cfg(feature = "linear")]
#[test]
fn resolve_linear_release_placeholders_reads_env_files() {
let temp_dir = std::env::temp_dir().join(format!(
"xbp-linear-release-placeholders-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&temp_dir).expect("temp dir");
fs::write(
temp_dir.join(".env.local"),
"LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
)
.expect("env file");
let resolved = resolve_linear_release_placeholders(
&temp_dir,
ResolvedLinearReleaseConfig {
initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
health: "on_track".to_string(),
},
);
assert_eq!(
resolved.initiative_ids,
vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
);
assert_eq!(
resolved.organization_name.as_deref(),
Some("suits-formations")
);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn version_change_guard_clears_when_pending_count_is_zero() {
let entry = VersionChangeGuardEntry {
pending_version_change_count: 0,
head_commit: Some("abc123".to_string()),
};
let state = GitWorktreeState {
is_dirty: true,
head_commit: Some("abc123".to_string()),
};
assert!(should_clear_version_change_guard(&entry, &state));
}
#[test]
fn parse_package_version_target_supports_assignment_syntax() {
let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
.expect("parse")
.expect("target");
assert_eq!(parsed.0, "demo_pkg".to_string());
assert_eq!(parsed.1, Version::new(1, 2, 3));
}
#[test]
fn parse_package_version_target_rejects_invalid_package_names() {
let error: String = parse_package_version_target("bad package=1.2.3")
.expect_err("invalid package target should fail");
assert!(error.contains("Invalid package target"));
}
#[test]
fn parse_package_version_target_returns_none_without_assignment() {
assert!(parse_package_version_target("1.2.3")
.expect("parse")
.is_none());
}
#[test]
fn parse_package_version_target_returns_none_for_empty_package_name() {
assert!(parse_package_version_target(" =1.2.3")
.expect("parse")
.is_none());
}
#[test]
fn bumping_clears_prerelease_and_build_metadata() {
let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
}
#[test]
fn cargo_toml_adapter_reads_and_writes() {
let dir: PathBuf = temp_dir("cargo");
let path: PathBuf = dir.join("Cargo.toml");
fs::write(
&path,
r#"[package]
name = "xbp"
version = "1.0.0"
"#,
)
.expect("write Cargo.toml");
assert_eq!(
read_cargo_toml_version(&path).expect("read"),
Some("1.0.0".to_string())
);
write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_workspace_package_version_reads_and_writes_from_virtual_root() {
let dir: PathBuf = temp_dir("workspace-root-version");
let crate_dir = dir.join("crates").join("demo");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::write(
dir.join("Cargo.toml"),
r#"[workspace]
members = ["crates/demo"]
resolver = "2"
[workspace.package]
version = "0.1.0"
"#,
)
.expect("write workspace root");
fs::write(
crate_dir.join("Cargo.toml"),
r#"[package]
name = "demo"
version = { workspace = true }
"#,
)
.expect("write crate manifest");
assert_eq!(
read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read root"),
Some("0.1.0".to_string())
);
assert_eq!(
read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
Some("0.1.0".to_string())
);
write_cargo_toml_version(&dir.join("Cargo.toml"), &Version::new(0, 2, 0)).expect("write");
assert_eq!(
read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
Some("0.2.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn json_root_adapter_reads_and_writes() {
let dir: PathBuf = temp_dir("json");
let path: PathBuf = dir.join("package.json");
fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
assert_eq!(
read_json_root_version(&path).expect("read"),
Some("1.4.0".to_string())
);
write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("1.5.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn yaml_root_adapter_reads_and_writes() {
let dir: PathBuf = temp_dir("yaml");
let path: PathBuf = dir.join("xbp.yaml");
fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
assert_eq!(
read_yaml_root_version(&path, "version").expect("read"),
Some("0.2.0".to_string())
);
write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("0.3.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn toml_root_adapter_reads_and_writes() {
let dir: PathBuf = temp_dir("toml");
let path: PathBuf = dir.join("config.toml");
fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
assert_eq!(
read_toml_root_version(&path).expect("read"),
Some("3.1.4".to_string())
);
write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
assert_eq!(
read_toml_root_version(&path).expect("read"),
Some("3.2.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn openapi_adapter_reads_and_writes_nested_version() {
let dir: PathBuf = temp_dir("openapi");
let path: PathBuf = dir.join("openapi.yaml");
fs::write(
&path,
"openapi: 3.0.3\ninfo:\n title: Test\n version: 1.2.3\n",
)
.expect("write openapi");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("1.2.3".to_string())
);
write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("2.0.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn openapi_writer_creates_missing_info_mapping() {
let dir: PathBuf = temp_dir("openapi-missing-info");
let path: PathBuf = dir.join("openapi.yaml");
fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("4.0.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn json_openapi_adapter_reads_and_writes_nested_version() {
let dir: PathBuf = temp_dir("openapi-json");
let path: PathBuf = dir.join("openapi.json");
fs::write(
&path,
r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
)
.expect("write openapi json");
assert_eq!(
read_json_openapi_version(&path).expect("read"),
Some("1.2.3".to_string())
);
write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
assert_eq!(
read_json_openapi_version(&path).expect("read"),
Some("2.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn json_openapi_writer_creates_missing_info_object() {
let dir: PathBuf = temp_dir("openapi-json-missing-info");
let path: PathBuf = dir.join("openapi.json");
fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
assert_eq!(
read_json_openapi_version(&path).expect("read"),
Some("4.0.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_reader_prefers_project_version() {
let dir: PathBuf = temp_dir("pyproject-project");
let path: PathBuf = dir.join("pyproject.toml");
fs::write(
&path,
"[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
)
.expect("write pyproject");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("0.8.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_reader_falls_back_to_poetry_version() {
let dir: PathBuf = temp_dir("pyproject-poetry");
let path: PathBuf = dir.join("pyproject.toml");
fs::write(
&path,
"[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
)
.expect("write pyproject");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("1.9.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_writer_updates_project_table() {
let dir: PathBuf = temp_dir("pyproject-write-project");
let path: PathBuf = dir.join("pyproject.toml");
fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
.expect("write pyproject");
write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_writer_updates_poetry_table() {
let dir: PathBuf = temp_dir("pyproject-write-poetry");
let path: PathBuf = dir.join("pyproject.toml");
fs::write(
&path,
"[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
)
.expect("write pyproject");
write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("2.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_lock_reader_and_writer_follow_package_name() {
let dir: PathBuf = temp_dir("cargo-lock");
let cargo_toml: PathBuf = dir.join("Cargo.toml");
let cargo_lock: PathBuf = dir.join("Cargo.lock");
fs::write(
&cargo_toml,
r#"[package]
name = "xbp"
version = "1.0.0"
"#,
)
.expect("write Cargo.toml");
fs::write(
&cargo_lock,
r#"version = 4
[[package]]
name = "xbp"
version = "1.0.0"
[[package]]
name = "other"
version = "9.9.9"
"#,
)
.expect("write Cargo.lock");
assert_eq!(
read_cargo_lock_version(&cargo_lock).expect("read"),
Some("1.0.0".to_string())
);
write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
assert_eq!(
read_cargo_lock_version(&cargo_lock).expect("read"),
Some("1.0.1".to_string())
);
let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_lock_writer_errors_when_package_missing() {
let dir: PathBuf = temp_dir("cargo-lock-missing");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
)
.expect("write Cargo.toml");
let cargo_lock: PathBuf = dir.join("Cargo.lock");
fs::write(
&cargo_lock,
"version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
)
.expect("write Cargo.lock");
let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
.expect_err("missing package should fail");
assert!(error.contains("Could not find package `xbp`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_package_name_reads_package_section() {
let dir: PathBuf = temp_dir("cargo-package-name");
let cargo_lock: PathBuf = dir.join("Cargo.lock");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
)
.expect("write Cargo.toml");
fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
assert_eq!(
cargo_package_name(&cargo_lock).expect("name"),
Some("xbp-cli".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_toml_writer_skips_workspace_manifest_without_package() {
let dir: PathBuf = temp_dir("cargo-workspace-manifest");
let path: PathBuf = dir.join("Cargo.toml");
fs::write(
&path,
"[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
)
.expect("write Cargo.toml");
let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
assert!(changed);
let content = fs::read_to_string(&path).expect("read Cargo.toml");
assert!(content.contains("[workspace.package]"));
assert!(content.contains("version = \"2.0.0\""));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
let dir: PathBuf = temp_dir("workspace-cargo-skip");
fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
)
.expect("write Cargo.toml");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
)
.expect("write Cargo.lock");
fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
.expect("write README");
let updated = write_version_to_configured_files(
&dir,
&dir,
&[
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
],
&VersionScope::Repository,
&Version::new(1, 1, 0),
)
.expect("write versions");
assert_eq!(updated, 2);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read"),
Some("1.1.0".to_string())
);
let cargo_content = fs::read_to_string(dir.join("Cargo.toml")).expect("read Cargo.toml");
assert!(cargo_content.contains("version = \"1.1.0\""));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn repository_scope_prefers_workspace_default_member_manifest() {
let dir: PathBuf = temp_dir("workspace-default-member-path");
let crate_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::write(
dir.join("Cargo.toml"),
"[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
)
.expect("write workspace cargo");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
)
.expect("write crate cargo");
let resolved = super::resolve_registry_relative_path(
&dir,
&dir,
&VersionScope::Repository,
"Cargo.toml",
);
assert_eq!(resolved, "crates/cli/Cargo.toml");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn root_discovered_service_does_not_hijack_repository_scope() {
let dir: PathBuf = temp_dir("root-discovered-service-scope");
let crate_dir: PathBuf = dir.join("crates").join("cli");
let xbp_dir: PathBuf = dir.join(".xbp");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
dir.join("Cargo.toml"),
"[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
)
.expect("write workspace cargo");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
)
.expect("write crate cargo");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
)
.expect("write cargo lock");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: xbp
version: 10.35.0
port: 3000
build_dir: ./
services:
- name: xbp-2
target: python
branch: main
port: 8001
root_directory: ./
version_targets:
- Cargo.toml
- name: crates-cli
target: rust
branch: main
port: 8002
root_directory: crates/cli
version_targets:
- crates/cli/Cargo.toml
publish:
crates:
enabled: true
package_name: xbp
working_directory: crates/cli
manifest_path: crates/cli/Cargo.toml
version_targets:
- Cargo.toml
- crates/cli/Cargo.toml
"#,
)
.expect("write xbp config");
assert!(matches!(
resolve_version_scope(&dir, &dir),
VersionScope::Repository
));
let resolved = resolve_registry_paths(
&dir,
&dir,
&["Cargo.toml".to_string(), "Cargo.lock".to_string()],
&VersionScope::Repository,
);
assert!(resolved
.iter()
.any(|path| path.relative == "crates/cli/Cargo.toml"));
assert_eq!(
read_highest_version_from_targets(&resolved).expect("read selected versions"),
Some(Version::new(10, 35, 0))
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
let dir: PathBuf = temp_dir("workspace-default-member-writer");
let crate_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::write(
dir.join("Cargo.toml"),
"[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
)
.expect("write workspace cargo");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
)
.expect("write crate cargo");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
)
.expect("write cargo lock");
fs::write(
dir.join("README.md"),
"# XBP\n\ncurrent version: `10.21.0`\n",
)
.expect("write readme");
let updated = write_version_to_configured_files(
&dir,
&dir,
&[
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
],
&VersionScope::Repository,
&Version::new(10, 22, 0),
)
.expect("write versions");
assert_eq!(updated, 3);
assert_eq!(
read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
Some("10.22.0".to_string())
);
assert_eq!(
read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
Some("10.22.0".to_string())
);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read readme"),
Some("10.22.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
let dir: PathBuf = temp_dir("publish-manifest-version-target");
let package_dir = dir.join("packages").join("heroui");
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&package_dir).expect("create package dir");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: athena-auth-ui
version: 0.3.1
port: 4000
build_dir: ./
publish:
npm:
enabled: true
working_directory: packages/heroui
manifest_path: packages/heroui/package.json
"#,
)
.expect("write xbp config");
fs::write(
dir.join("package.json"),
r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
)
.expect("write root package");
fs::write(
package_dir.join("package.json"),
r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
)
.expect("write package manifest");
let updated = write_version_to_configured_files(
&dir,
&dir,
&["package.json".to_string()],
&VersionScope::Repository,
&Version::new(0, 3, 1),
)
.expect("write versions");
assert_eq!(updated, 2);
assert_eq!(
read_json_root_version(&dir.join("package.json")).expect("read root package"),
Some("0.3.1".to_string())
);
assert_eq!(
read_json_root_version(&package_dir.join("package.json")).expect("read package"),
Some("0.3.1".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_writer_updates_version_targets_from_xbp_config() {
let dir: PathBuf = temp_dir("explicit-version-targets");
let app_dir = dir.join("apps").join("web");
let cli_dir = dir.join("crates").join("cli");
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&app_dir).expect("create app dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: xbp
version: 10.30.0
port: 3000
build_dir: ./
version_targets:
- crates/cli/Cargo.toml
- apps/web/package.json
"#,
)
.expect("write xbp config");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cli Cargo.toml");
fs::write(
app_dir.join("package.json"),
r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
)
.expect("write app package");
let updated = write_version_to_configured_files(
&dir,
&dir,
&[".xbp/xbp.yaml".to_string()],
&VersionScope::Repository,
&Version::new(10, 30, 0),
)
.expect("write versions");
assert_eq!(updated, 3);
assert_eq!(
read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
Some("10.30.0".to_string())
);
assert_eq!(
read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
Some("10.30.0".to_string())
);
assert_eq!(
read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
Some("10.30.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
let package_dir = dir.join("packages").join("heroui");
let xbp_dir = dir.join(".xbp");
fs::create_dir_all(&package_dir).expect("create package dir");
fs::create_dir_all(&xbp_dir).expect("create xbp dir");
fs::write(
xbp_dir.join("xbp.yaml"),
r#"project_name: athena-auth-ui
version: 0.3.0
port: 4000
build_dir: ./
publish:
npm:
enabled: true
working_directory: packages/heroui
manifest_path: packages/heroui/package.json
"#,
)
.expect("write xbp config");
fs::write(
dir.join("package.json"),
r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
)
.expect("write root package");
fs::write(
package_dir.join("package.json"),
r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
)
.expect("write package manifest");
let _updated_paths = sync_version_to_configured_files_with_paths(
&dir,
&dir,
&["package.json".to_string()],
&VersionScope::Repository,
&Version::new(0, 3, 0),
)
.expect("sync versions");
assert_eq!(
read_json_root_version(&dir.join("package.json")).expect("read root package"),
Some("0.3.0".to_string())
);
assert_eq!(
read_json_root_version(&package_dir.join("package.json")).expect("read package"),
Some("0.3.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn readme_adapter_updates_current_version_marker() {
let dir: PathBuf = temp_dir("readme");
let path: PathBuf = dir.join("README.md");
fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
assert_eq!(
read_readme_version(&path).expect("read"),
Some("1.2.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn readme_writer_inserts_marker_when_missing() {
let dir: PathBuf = temp_dir("readme-insert");
let path: PathBuf = dir.join("README.md");
fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
let content: String = fs::read_to_string(&path).expect("read readme");
assert!(content.contains("current version: `3.0.0`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn regex_adapter_reads_and_writes_versions() {
let dir: PathBuf = temp_dir("regex");
let path: PathBuf = dir.join("build.gradle");
fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
assert_eq!(
read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
Some("5.4.3".to_string())
);
write_regex_version(
&path,
r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
&Version::new(5, 5, 0),
)
.expect("write");
assert_eq!(
read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
Some("5.5.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn regex_writer_errors_without_matching_pattern() {
let dir: PathBuf = temp_dir("regex-miss");
let path: PathBuf = dir.join("build.gradle");
fs::write(&path, "group = 'demo'\n").expect("write gradle");
let error: String = write_regex_version(
&path,
r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
&Version::new(1, 0, 0),
)
.expect_err("missing version should fail");
assert!(error.contains("No version pattern found"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
let original: &str = r#"[dependencies]
serde = "1.0.219"
tokio = { version = "1.44.1", features = ["full"] }
"#;
let (updated, changed) =
rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
.expect("rewrite");
assert!(changed);
assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
let (updated, changed) =
rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
.expect("rewrite");
assert!(changed);
assert!(updated.contains(r#"serde = "1.1.0""#));
}
#[test]
fn package_version_writer_updates_registry_toml_targets() {
let dir: PathBuf = temp_dir("package-version-registry");
let cargo_toml: PathBuf = dir.join("Cargo.toml");
fs::write(
&cargo_toml,
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
serde = "1.0.219"
tokio = { version = "1.44.1", features = ["full"] }
"#,
)
.expect("write Cargo.toml");
let updated: usize = write_package_version_to_configured_files(
&dir,
&dir,
&["Cargo.toml".to_string()],
&VersionScope::Repository,
"tokio",
&Version::new(1, 45, 1),
)
.expect("update package assignment");
assert_eq!(updated, 1);
let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn package_version_writer_errors_when_package_assignment_not_found() {
let dir: PathBuf = temp_dir("package-version-missing");
let cargo_toml: PathBuf = dir.join("Cargo.toml");
fs::write(
&cargo_toml,
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
serde = "1.0.219"
"#,
)
.expect("write Cargo.toml");
let error: String = write_package_version_to_configured_files(
&dir,
&dir,
&["Cargo.toml".to_string()],
&VersionScope::Repository,
"tokio",
&Version::new(1, 45, 1),
)
.expect_err("missing package assignment should fail");
assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn chart_writer_updates_app_version_when_present() {
let dir: PathBuf = temp_dir("chart");
let path: PathBuf = dir.join("Chart.yaml");
fs::write(
&path,
"apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
)
.expect("write chart");
write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
let content: String = fs::read_to_string(&path).expect("read chart");
assert!(content.contains("version: 0.2.0"));
assert!(content.contains("appVersion: 0.2.0"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_deduplicates_registry_entries() {
let dir: PathBuf = temp_dir("dedupe");
let readme: PathBuf = dir.join("README.md");
fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
let updated: usize = write_version_to_configured_files(
&dir,
&dir,
&[
"README.md".to_string(),
"README.md".to_string(),
"missing.md".to_string(),
],
&VersionScope::Repository,
&Version::new(1, 1, 0),
)
.expect("write versions");
assert_eq!(updated, 1);
assert_eq!(
read_readme_version(&readme).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_prefers_invocation_directory_targets() {
let dir: PathBuf = temp_dir("invocation-precedence");
let app_dir: PathBuf = dir.join("apps").join("web");
fs::create_dir_all(&app_dir).expect("create app dir");
let root_package: PathBuf = dir.join("package.json");
let app_package: PathBuf = app_dir.join("package.json");
fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
.expect("write root package");
fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
.expect("write app package");
let updated: usize = write_version_to_configured_files(
&dir,
&app_dir,
&["package.json".to_string()],
&VersionScope::Repository,
&Version::new(2, 14, 0),
)
.expect("write versions");
assert_eq!(updated, 1);
assert_eq!(
read_json_root_version(&root_package).expect("read root"),
Some("9.9.9".to_string())
);
assert_eq!(
read_json_root_version(&app_package).expect("read app"),
Some("2.14.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn resolve_project_root_prefers_nearest_xbp_config() {
let dir: PathBuf = temp_dir("nested-xbp-project-root");
let service_root: PathBuf = dir.join("services").join("athena-auth");
let nested_dir: PathBuf = service_root.join("src");
let service_xbp_dir: PathBuf = service_root.join(".xbp");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
fs::write(
service_xbp_dir.join("xbp.yaml"),
"project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
)
.expect("write service xbp config");
assert_eq!(resolve_project_root_from(&nested_dir), service_root);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn nested_xbp_repository_scope_does_not_touch_outer_root_version_files() {
let dir: PathBuf = temp_dir("nested-xbp-version-scope");
let outer_xbp_dir = dir.join(".xbp");
let service_root: PathBuf = dir.join("services").join("athena-auth");
let nested_dir: PathBuf = service_root.join("src");
let service_xbp_dir: PathBuf = service_root.join(".xbp");
fs::create_dir_all(&outer_xbp_dir).expect("create outer xbp dir");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
fs::write(
outer_xbp_dir.join("xbp.yaml"),
"project_name: root\nversion: 9.9.9\nport: 3000\nbuild_dir: ./\n",
)
.expect("write outer xbp config");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"root\"\nversion = \"9.9.9\"\n",
)
.expect("write outer cargo");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"root\"\nversion = \"9.9.9\"\n",
)
.expect("write outer cargo lock");
fs::write(
dir.join("README.md"),
"# root\n\ncurrent version: `9.9.9`\n",
)
.expect("write outer readme");
fs::write(
dir.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: Root\n version: 9.9.9\n",
)
.expect("write outer openapi");
fs::write(
dir.join("package.json"),
r#"{ "name": "root", "version": "9.9.9" }"#,
)
.expect("write outer package");
fs::write(
service_xbp_dir.join("xbp.yaml"),
"project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
)
.expect("write service xbp config");
fs::write(
service_root.join("Cargo.toml"),
"[package]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
)
.expect("write service cargo");
fs::write(
service_root.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
)
.expect("write service cargo lock");
fs::write(
service_root.join("README.md"),
"# athena-auth\n\ncurrent version: `3.22.0`\n",
)
.expect("write service readme");
fs::write(
service_root.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: Athena Auth\n version: 3.22.0\n",
)
.expect("write service openapi");
fs::write(
service_root.join("package.json"),
r#"{ "name": "athena-auth", "version": "3.22.0" }"#,
)
.expect("write service package");
let project_root = resolve_project_root_from(&nested_dir);
let updated_paths = write_version_to_configured_files_with_paths(
&project_root,
&nested_dir,
&[
".xbp/xbp.yaml".to_string(),
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
"openapi.yaml".to_string(),
"package.json".to_string(),
],
&VersionScope::Repository,
&Version::new(3, 22, 1),
)
.expect("write nested project versions");
let updated_relative = updated_paths
.iter()
.map(|path| path.strip_prefix(&project_root).expect("strip prefix"))
.map(normalized_relative_path)
.collect::<Vec<_>>();
assert_eq!(
updated_relative,
vec![
".xbp/xbp.yaml".to_string(),
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
"openapi.yaml".to_string(),
"package.json".to_string(),
]
);
assert_eq!(
read_yaml_root_version(&outer_xbp_dir.join("xbp.yaml"), "version")
.expect("read outer xbp"),
Some("9.9.9".to_string())
);
assert_eq!(
read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read outer cargo"),
Some("9.9.9".to_string())
);
assert_eq!(
read_cargo_lock_version(&dir.join("Cargo.lock")).expect("read outer cargo lock"),
Some("9.9.9".to_string())
);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read outer readme"),
Some("9.9.9".to_string())
);
assert_eq!(
read_openapi_version(&dir.join("openapi.yaml")).expect("read outer openapi"),
Some("9.9.9".to_string())
);
assert_eq!(
read_json_root_version(&dir.join("package.json")).expect("read outer package"),
Some("9.9.9".to_string())
);
assert_eq!(
read_yaml_root_version(&service_xbp_dir.join("xbp.yaml"), "version")
.expect("read service xbp"),
Some("3.22.1".to_string())
);
assert_eq!(
read_cargo_toml_version(&service_root.join("Cargo.toml")).expect("read service cargo"),
Some("3.22.1".to_string())
);
assert_eq!(
read_cargo_lock_version(&service_root.join("Cargo.lock"))
.expect("read service cargo lock"),
Some("3.22.1".to_string())
);
assert_eq!(
read_readme_version(&service_root.join("README.md")).expect("read service readme"),
Some("3.22.1".to_string())
);
assert_eq!(
read_openapi_version(&service_root.join("openapi.yaml")).expect("read service openapi"),
Some("3.22.1".to_string())
);
assert_eq!(
read_json_root_version(&service_root.join("package.json"))
.expect("read service package"),
Some("3.22.1".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn nested_repository_scope_limits_targets_and_release_version_to_local_project() {
let dir: PathBuf = temp_dir("nested-repo-local-release");
let package_root: PathBuf = dir.join("packages").join("athena-js");
let package_xbp_dir = package_root.join(".xbp");
fs::create_dir_all(&package_xbp_dir).expect("create package xbp dir");
fs::write(
package_xbp_dir.join("xbp.yaml"),
r#"project_name: athena-js
version: 2.13.0
publish:
npm:
enabled: true
manifest_path: package.json
"#,
)
.expect("write package xbp config");
fs::write(
package_root.join("package.json"),
r#"{ "name": "@xylex-group/athena", "version": "2.13.0" }"#,
)
.expect("write package manifest");
fs::write(
package_root.join("README.md"),
"# athena-js\n\ncurrent version: `2.13.0`\n",
)
.expect("write package readme");
fs::write(
dir.join("README.md"),
"# athena\n\ncurrent version: `3.27.0`\n",
)
.expect("write outer readme");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "initial"]);
git(&dir, &["tag", "v3.27.0"]);
let scope = resolve_version_scope(&package_root, &package_root);
assert!(matches!(scope, VersionScope::Repository));
let registry = vec![
".xbp/xbp.yaml".to_string(),
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
"package.json".to_string(),
];
let resolved = resolve_registry_paths(&package_root, &package_root, ®istry, &scope);
assert_eq!(
resolved
.iter()
.map(|entry| entry.relative.clone())
.collect::<Vec<_>>(),
vec![
".xbp/xbp.yaml".to_string(),
"README.md".to_string(),
"package.json".to_string(),
]
);
let report = collect_version_report(&package_root, &package_root, ®istry, &scope);
assert_eq!(report.highest_available(), Version::new(3, 27, 0));
assert_eq!(
resolve_release_version_from_report(&package_root, &scope, &report),
Version::new(2, 13, 0)
);
let baseline =
resolve_change_baseline(&package_root, &scope, "athena-js").expect("baseline");
assert_eq!(baseline.0, "initial-release".to_string());
assert_eq!(baseline.1, None);
assert_eq!(baseline.2, None);
assert_eq!(
release_tag_name_for_project(
&package_root,
&scope,
&Version::new(3, 1, 0),
"athena-js",
),
"athena-js-3.1.0"
);
assert_eq!(
default_release_title(&scope, &Version::new(3, 1, 0), "athena-js"),
"3.1.0-athena-js"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn nested_package_with_root_service_resolves_to_service_scope_tags() {
let dir: PathBuf = temp_dir("nested-package-service-scope");
let package_root: PathBuf = dir.join("packages").join("athena-js");
let package_xbp_dir = package_root.join(".xbp");
fs::create_dir_all(&package_xbp_dir).expect("create package xbp dir");
fs::write(
package_xbp_dir.join("xbp.yaml"),
r#"project_name: athena-js
version: 3.1.0
port: 3000
build_dir: ./
services:
- name: athena-js
target: docker
branch: main
port: 80
root_directory: ./
version_targets:
- package.json
- name: test-sdk
target: expressjs
branch: main
port: 4001
root_directory: test-sdk
version_targets:
- test-sdk/package.json
"#,
)
.expect("write package xbp config");
fs::create_dir_all(package_root.join("test-sdk")).expect("create test-sdk");
fs::write(
package_root.join("package.json"),
r#"{ "name": "@xylex-group/athena", "version": "3.1.0" }"#,
)
.expect("write package manifest");
fs::write(
package_root.join("test-sdk").join("package.json"),
r#"{ "name": "test-sdk", "version": "0.1.0" }"#,
)
.expect("write test-sdk manifest");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "initial"]);
git(&dir, &["tag", "athena-js-3.0.0"]);
git(&dir, &["tag", "v3.0.2"]);
let scope = resolve_version_scope(&package_root, &package_root);
match &scope {
VersionScope::Service {
service_name,
tag_prefix,
service_relative_root,
..
} => {
assert_eq!(service_name, "athena-js");
assert_eq!(tag_prefix, "athena-js-");
assert_eq!(service_relative_root, ".");
}
other => panic!("expected service scope for nested package, got {other:?}"),
}
assert_eq!(
release_tag_name_for_project(&package_root, &scope, &Version::new(3, 1, 0), "athena-js"),
"athena-js-3.1.0"
);
assert_eq!(
default_release_title(&scope, &Version::new(3, 1, 0), "athena"),
"3.1.0-athena-js"
);
let previous =
previous_release_tag(&package_root, "athena-js-3.1.0", &scope).expect("previous tag");
assert_eq!(previous.as_deref(), Some("athena-js-3.0.0"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn resolve_version_scope_detects_crate_scoped_invocation() {
let dir: PathBuf = temp_dir("crate-scope");
let crate_dir: PathBuf = dir.join("crates").join("alpha");
let nested_dir: PathBuf = crate_dir.join("src");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
)
.expect("write Cargo.toml");
let scope = resolve_version_scope(&dir, &nested_dir);
match scope {
VersionScope::Crate {
package_name,
crate_relative_root,
tag_prefix,
..
} => {
assert_eq!(package_name, "alpha-crate");
assert_eq!(crate_relative_root, "crates/alpha");
assert_eq!(tag_prefix, "alpha-crate-");
}
_ => panic!("expected crate scope"),
}
let _ = fs::remove_dir_all(dir);
}
#[test]
fn resolve_version_scope_detects_service_scoped_invocation() {
let dir: PathBuf = temp_dir("service-scope");
let service_dir: PathBuf = dir.join("apps").join("web");
let nested_dir: PathBuf = service_dir.join("src");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &nested_dir);
match scope {
VersionScope::Service {
service_name,
service_relative_root,
tag_prefix,
..
} => {
assert_eq!(service_name, "web");
assert_eq!(service_relative_root, "apps/web");
assert_eq!(tag_prefix, "web-");
}
_ => panic!("expected service scope"),
}
let _ = fs::remove_dir_all(dir);
}
#[test]
fn service_scoped_configured_targets_only_include_selected_service() {
let dir: PathBuf = temp_dir("service-targets");
let service_dir: PathBuf = dir.join("apps").join("web");
let nested_dir: PathBuf = service_dir.join("src");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &nested_dir);
let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].relative, "apps/web/package.json");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn service_scoped_release_publish_filter_selects_matching_target() {
let dir: PathBuf = temp_dir("service-publish-filter");
let service_dir: PathBuf = dir.join("apps").join("web");
let nested_dir: PathBuf = service_dir.join("src");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &nested_dir);
let publish_target =
resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
assert_eq!(publish_target.as_deref(), Some("npm"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn current_scope_ledger_path_uses_repo_local_scope_directory() {
let dir: PathBuf = temp_dir("ledger-path");
let scope = resolve_version_scope(&dir, &dir);
let path = current_scope_ledger_path(&dir, &scope, "xbp", &Version::new(10, 30, 0));
assert_eq!(
path,
dir.join(".xbp")
.join("releases")
.join("repository-xbp")
.join("10.30.0.yaml")
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn repository_change_selection_matches_only_changed_target_manifest_root() {
let dir: PathBuf = temp_dir("selection-direct");
let service_dir: PathBuf = dir.join("apps").join("web");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(service_dir.join("src")).expect("create service dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &dir);
let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
let selected = select_changed_version_targets(
&dir,
&scope,
&targets,
&["apps/web/src/app.tsx".into()],
);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].relative, "apps/web/package.json");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn repository_change_selection_fans_out_on_shared_root_change() {
let dir: PathBuf = temp_dir("selection-shared");
let service_dir: PathBuf = dir.join("apps").join("web");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(service_dir.join("src")).expect("create service dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &dir);
let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
let selected =
select_changed_version_targets(&dir, &scope, &targets, &["README.md".into()]);
assert_eq!(selected.len(), 2);
assert_eq!(
selected
.iter()
.map(|target| target.relative.as_str())
.collect::<Vec<_>>(),
vec!["crates/cli/Cargo.toml", "apps/web/package.json"]
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn selected_version_writer_updates_only_selected_targets() {
let dir: PathBuf = temp_dir("selection-write");
let service_dir: PathBuf = dir.join("apps").join("web");
let cli_dir: PathBuf = dir.join("crates").join("cli");
fs::create_dir_all(service_dir.join("src")).expect("create service dir");
fs::create_dir_all(&cli_dir).expect("create cli dir");
write_multi_service_config(&dir);
fs::write(
service_dir.join("package.json"),
r#"{"name":"@xbp/web","version":"10.29.0"}"#,
)
.expect("write package");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
)
.expect("write cargo");
let scope = resolve_version_scope(&dir, &dir);
let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
let selected = select_changed_version_targets(
&dir,
&scope,
&targets,
&["apps/web/src/app.tsx".into()],
);
let updated = write_version_to_selected_paths(&selected, &Version::new(10, 30, 0), false)
.expect("write selected targets");
assert_eq!(updated.len(), 1);
assert_eq!(
read_json_root_version(&service_dir.join("package.json")).expect("read web package"),
Some("10.30.0".to_string())
);
assert_eq!(
read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
Some("10.29.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
let dir: PathBuf = temp_dir("crate-writer");
let crate_dir: PathBuf = dir.join("crates").join("alpha");
fs::create_dir_all(&crate_dir).expect("create crate dir");
fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
)
.expect("write crate Cargo.toml");
fs::write(
dir.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
)
.expect("write Cargo.lock");
fs::write(
dir.join("README.md"),
"# root\n\ncurrent version: `9.9.9`\n",
)
.expect("write root readme");
let scope = resolve_version_scope(&dir, &crate_dir);
let updated = write_version_to_configured_files(
&dir,
&crate_dir,
&[
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"README.md".to_string(),
],
&scope,
&Version::new(1, 3, 0),
)
.expect("write versions");
assert_eq!(updated, 2);
assert_eq!(
read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
Some("1.3.0".to_string())
);
assert_eq!(
read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
.expect("read cargo lock"),
Some("1.3.0".to_string())
);
assert_eq!(
read_readme_version(&dir.join("README.md")).expect("read readme"),
Some("9.9.9".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_resolution_prefers_crate_scope() {
let dir: PathBuf = temp_dir("release-openapi-crate");
let crate_dir: PathBuf = dir.join("crates").join("monitor");
let nested_dir: PathBuf = crate_dir.join("src");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
let crate_openapi: PathBuf = crate_dir.join("openapi.json");
fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
let scope = resolve_version_scope(&dir, &nested_dir);
let resolved = resolve_release_openapi_specs(&dir, &nested_dir, &scope)
.expect("crate-scoped openapi")
.into_iter()
.next()
.expect("crate-scoped openapi");
assert_eq!(resolved, crate_openapi);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_resolution_falls_back_to_repo_root() {
let dir: PathBuf = temp_dir("release-openapi-root");
let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
fs::create_dir_all(&crate_dir).expect("create crate dir");
let root_openapi: PathBuf = dir.join("openapi.json");
fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
let scope = resolve_version_scope(&dir, &dir);
let resolved = resolve_release_openapi_specs(&dir, &crate_dir, &scope)
.expect("repo root openapi")
.into_iter()
.next()
.expect("repo root openapi");
assert_eq!(resolved, root_openapi);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_assets_use_canonical_service_version_names() {
let dir: PathBuf = temp_dir("release-openapi-assets");
let rs_dir: PathBuf = dir.join("crates").join("athena-rs");
let js_dir: PathBuf = dir.join("packages").join("athena-js");
fs::create_dir_all(&rs_dir).expect("create rs dir");
fs::create_dir_all(&js_dir).expect("create js dir");
fs::write(
rs_dir.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: Athena RS\n version: 3.15.2\n",
)
.expect("write http openapi");
fs::write(
rs_dir.join("openapi-wss.yaml"),
"openapi: 3.0.3\ninfo:\n title: Athena WSS\n version: 3.15.2\n",
)
.expect("write wss openapi");
fs::write(
js_dir.join("openapi.json"),
r#"{"openapi":"3.0.3","info":{"title":"Athena JS","version":"1.3.0"}}"#,
)
.expect("write js openapi");
let config_yaml = r#"
project_name: athena
version: 3.15.2
port: 3000
build_dir: ./
openapi:
enabled: true
generate_on_release: true
auto_detect_services: false
services:
- name: athena-rs
target: rust
branch: main
port: 8080
root_directory: crates/athena-rs
versioning: true
release: true
openapi:
enabled: true
- name: athena-js
target: node
branch: main
port: 3000
root_directory: packages/athena-js
versioning: true
release: true
openapi:
enabled: true
"#;
fs::create_dir_all(dir.join(".xbp")).expect("create xbp dir");
fs::write(dir.join(".xbp/xbp.yaml"), config_yaml).expect("write config");
let config: crate::strategies::XbpConfig =
serde_yaml::from_str(config_yaml).expect("parse config");
let scope = VersionScope::Service {
service_root: rs_dir.clone(),
service_relative_root: "crates/athena-rs".to_string(),
service_name: "athena-rs".to_string(),
tag_prefix: "athena-rs-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["crates/athena-rs".to_string()],
};
let assets = prepare_release_openapi_assets(
&dir,
&config,
&scope,
&Version::new(3, 15, 2),
)
.expect("prepare release assets");
assert_eq!(
assets
.iter()
.map(|asset| asset.asset_name.as_str())
.collect::<Vec<_>>(),
vec![
"openapi-athena-js-1.3.0.json",
"openapi-athena-rs-3.15.2-wss.yaml",
"openapi-athena-rs-3.15.2.yaml"
]
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_assets_reject_http_version_mismatch() {
let dir: PathBuf = temp_dir("release-openapi-assets-mismatch");
let rs_dir: PathBuf = dir.join("crates").join("athena-rs");
fs::create_dir_all(&rs_dir).expect("create rs dir");
fs::write(
rs_dir.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: Athena RS\n version: 3.15.1\n",
)
.expect("write http openapi");
let config_yaml = r#"
project_name: athena
version: 3.15.2
port: 3000
build_dir: ./
openapi:
enabled: true
generate_on_release: true
auto_detect_services: false
services:
- name: athena-rs
target: rust
branch: main
port: 8080
root_directory: crates/athena-rs
versioning: true
release: true
openapi:
enabled: true
"#;
let config: crate::strategies::XbpConfig =
serde_yaml::from_str(config_yaml).expect("parse config");
let scope = VersionScope::Service {
service_root: rs_dir.clone(),
service_relative_root: "crates/athena-rs".to_string(),
service_name: "athena-rs".to_string(),
tag_prefix: "athena-rs-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["crates/athena-rs".to_string()],
};
let error = prepare_release_openapi_assets(
&dir,
&config,
&scope,
&Version::new(3, 15, 2),
)
.expect_err("mismatched OpenAPI version should fail");
assert!(error.contains("does not match release version"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn release_openapi_assets_stage_root_specs_like_python_prepare() {
use super::root_release_openapi_asset_name;
let dir: PathBuf = temp_dir("release-openapi-root-python-parity");
fs::write(
dir.join("openapi.yaml"),
"openapi: 3.0.3\ninfo:\n title: HTTP\n version: 3.15.2\n",
)
.expect("write openapi.yaml");
fs::write(
dir.join("openapi-wss.yaml"),
"openapi: 3.0.3\ninfo:\n title: WSS\n version: 3.15.2\n",
)
.expect("write openapi-wss.yaml");
let config: crate::strategies::XbpConfig = serde_yaml::from_str(
"project_name: athena\nversion: 3.15.2\nport: 3000\nbuild_dir: ./\n",
)
.expect("parse config");
let version = Version::new(3, 15, 2);
let assets = prepare_release_openapi_assets(&dir, &config, &VersionScope::Repository, &version)
.expect("prepare root openapi assets");
assert_eq!(
assets
.iter()
.map(|a| a.asset_name.as_str())
.collect::<Vec<_>>(),
vec!["openapi-3.15.2.yaml", "openapi-wss-3.15.2.yaml"]
);
assert_eq!(
root_release_openapi_asset_name("openapi.yaml", &version).unwrap(),
"openapi-3.15.2.yaml"
);
for asset in &assets {
assert!(
asset.source_path.is_file(),
"staged file missing: {}",
asset.source_path.display()
);
assert!(
asset
.source_path
.to_string_lossy()
.replace('\\', "/")
.contains("/.xbp/releases/"),
"expected staged under .xbp/releases, got {}",
asset.source_path.display()
);
}
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), "demo").expect("default tag"),
"alpha-crate-1.2.3"
);
}
#[test]
fn scoped_release_tags_strip_npm_scope_by_default() {
let scope = VersionScope::Service {
service_root: PathBuf::from("/tmp/packages/heroui"),
service_relative_root: "packages/heroui".to_string(),
service_name: "@xylex-group/athena-auth-ui".to_string(),
tag_prefix: "athena-auth-ui-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["packages/heroui".to_string()],
};
let version = Version::parse("1.17.0+nightly").expect("version");
assert_eq!(
default_release_tag_name(&scope, &version, "athena").expect("default tag"),
"athena-auth-ui-1.17.0-nightly"
);
}
#[test]
fn blob_reader_handles_head_readme_versions() {
assert_eq!(
read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
.expect("read"),
Some("0.4.0".to_string())
);
}
#[test]
fn blob_reader_handles_head_cargo_lock_versions() {
let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
let cargo_lock: &str =
"version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
assert_eq!(
read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
Some("0.2.0".to_string())
);
}
#[test]
fn package_name_lookup_reads_json_name_for_npm() {
let lookup: PackageNameLookup = PackageNameLookup {
file: "package.json".to_string(),
format: "json".to_string(),
key: "name".to_string(),
registry: "npm".to_string(),
};
assert_eq!(
read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
.expect("read"),
Some("@xylex/athena-mcp".to_string())
);
}
#[test]
fn package_name_lookup_reads_toml_nested_package_name() {
let lookup: PackageNameLookup = PackageNameLookup {
file: "Cargo.toml".to_string(),
format: "toml".to_string(),
key: "package.name".to_string(),
registry: "crates.io".to_string(),
};
assert_eq!(
read_package_name_from_lookup(
&lookup,
"[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
)
.expect("read"),
Some("athena-mcp".to_string())
);
}
#[test]
fn package_name_lookup_errors_on_unknown_format() {
let lookup: PackageNameLookup = PackageNameLookup {
file: "meta.txt".to_string(),
format: "ini".to_string(),
key: "name".to_string(),
registry: "npm".to_string(),
};
let error = read_package_name_from_lookup(&lookup, "name=demo")
.expect_err("unsupported format should fail");
assert!(error.contains("Unsupported lookup format"));
}
#[test]
fn highest_version_observation_returns_max_version() {
let entries: Vec<VersionObservation> = vec![
VersionObservation {
location: "README.md".to_string(),
version: Version::new(1, 0, 0),
},
VersionObservation {
location: "Cargo.toml".to_string(),
version: Version::new(1, 2, 0),
},
];
assert_eq!(
highest_version_observation(&entries).expect("max version"),
Version::new(1, 2, 0)
);
}
#[test]
fn stale_version_observations_only_returns_outdated_entries() {
let entries: Vec<VersionObservation> = vec![
VersionObservation {
location: "README.md".to_string(),
version: Version::new(1, 1, 0),
},
VersionObservation {
location: "Cargo.toml".to_string(),
version: Version::new(1, 2, 0),
},
VersionObservation {
location: "openapi.yaml".to_string(),
version: Version::new(1, 0, 5),
},
];
let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
assert_eq!(stale.len(), 2);
assert!(stale.iter().any(|entry| entry.location == "README.md"));
assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
}
#[test]
fn parses_github_remote_urls() {
assert_eq!(
parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url(
"https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
),
Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url(
"https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
),
Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
None
);
}
#[test]
fn redacts_credentials_in_remote_urls() {
let redacted = redact_remote_url_credentials(
"https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
);
assert!(redacted.contains("REDACTED"));
assert!(!redacted.contains("ghp_secretvalue"));
let username_only = redact_remote_url_credentials(
"https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
);
assert!(username_only.contains("REDACTED@github.com"));
assert!(!username_only.contains("floris-xlx@github.com"));
let ssh_remote =
redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
}
#[test]
fn builds_github_release_urls_with_encoded_tag_segments() {
let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
assert_eq!(
create_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
);
let lookup_url =
github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
.expect("url");
assert_eq!(
lookup_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
);
let update_url =
github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
assert_eq!(
update_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
);
let lookup_with_special_tag = github_release_by_tag_endpoint(
"SuitsBooks",
"suits-invoicing",
"release candidate/v0.0.1+build",
)
.expect("url");
assert_eq!(
lookup_with_special_tag.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
);
}
#[test]
fn builds_github_release_asset_urls() {
let list_url =
github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
assert_eq!(
list_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
);
let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
.expect("url");
assert_eq!(
delete_url.as_str(),
"https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
);
let upload_url = github_release_asset_upload_endpoint(
"SuitsBooks",
"suits-invoicing",
42,
"openapi spec.json",
)
.expect("url");
assert_eq!(
upload_url.as_str(),
"https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
);
}
#[test]
fn maps_release_latest_policy_to_github_api_values() {
assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
}
#[test]
fn release_channel_from_semver_prerelease_labels() {
let stable = Version::parse("3.6.2").expect("version");
let nightly = Version::parse("3.6.2-nightly.1").expect("version");
let experimental = Version::parse("0.1.1-alpha.1").expect("version");
assert_eq!(release_channel(&stable), "stable");
assert_eq!(release_channel(&nightly), "nightly");
assert_eq!(release_channel(&experimental), "experimental");
}
#[test]
fn renders_release_docs_from_entries() {
use super::release_docs::classify_release_tag;
let entries = vec![
classify_release_tag("v3.6.2", "2026-04-27").expect("v tag"),
classify_release_tag("docs-0.1.1-alpha.1", "2026-04-20").expect("docs tag"),
];
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"));
assert!(changelog.contains("Service: `repository`"));
assert!(changelog.contains("Service: `docs`"));
let security = render_security_policy(&entries);
assert!(security.contains("### repository"));
assert!(security.contains("### docs"));
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 install_snippets = vec![ReleaseInstallSnippet {
kind: "crates".to_string(),
package_name: "xbp".to_string(),
version: "1.7.0".to_string(),
methods: vec![ReleaseInstallMethod {
title: "Install".to_string(),
commands: vec!["cargo install xbp@1.7.0".to_string()],
language: "bash".to_string(),
}],
registry_url: Some("https://crates.io/crates/xbp".to_string()),
}];
let rendered = render_release_notes(&ReleaseNotesRenderInput {
release_title: "1.7.0-athena-auth",
current_tag_name: "athena-auth-1.7.0",
owner: "xylex-group",
repo: "athena-auth",
previous_tag: Some("athena-auth-1.6.0"),
sections: §ions,
commit_entries: &commits,
pull_request_infos: &pull_request_infos,
linear_issue_infos: &issue_infos,
install_snippets: &install_snippets,
});
assert!(rendered.starts_with(
"# [1.7.0-athena-auth](https://github.com/xylex-group/athena-auth/releases/tag/athena-auth-1.7.0)"
));
assert!(rendered.contains("Comparing changes since [athena-auth-1.6.0]"));
assert!(rendered.contains(
"Tag: [athena-auth-1.7.0](https://github.com/xylex-group/athena-auth/releases/tag/athena-auth-1.7.0)"
));
assert!(rendered.contains(
"Release: [1.7.0-athena-auth](https://github.com/xylex-group/athena-auth/releases/tag/athena-auth-1.7.0)"
));
}
fn make_release_install_temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| value.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"xbp-release-install-{label}-{}-{}",
std::process::id(),
nanos
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn build_release_install_snippets_for_crates_bin_and_npm() {
let project_root = make_release_install_temp_dir("bin-npm");
let crate_dir = project_root.join("crates/cli");
fs::create_dir_all(crate_dir.join("src")).expect("crate dir");
fs::write(
crate_dir.join("Cargo.toml"),
r#"[package]
name = "xbp"
version = "10.38.1"
edition = "2021"
[[bin]]
name = "xbp"
path = "src/main.rs"
"#,
)
.expect("write cargo");
fs::write(crate_dir.join("src/main.rs"), "fn main() {}\n").expect("write main");
let npm_dir = project_root.join("packages/athena");
fs::create_dir_all(&npm_dir).expect("npm dir");
fs::write(
npm_dir.join("package.json"),
r#"{
"name": "@xylex-group/athena",
"version": "3.1.0"
}
"#,
)
.expect("write package.json");
let snippets = build_release_install_snippets(
&project_root,
&[
ReleasePublishTarget {
kind: "crates".to_string(),
manifest_path: "crates/cli/Cargo.toml".to_string(),
package_name: Some("xbp".to_string()),
},
ReleasePublishTarget {
kind: "npm".to_string(),
manifest_path: "packages/athena/package.json".to_string(),
package_name: Some("@xylex-group/athena".to_string()),
},
],
"10.38.1",
);
assert_eq!(snippets.len(), 2);
assert_eq!(snippets[0].package_name, "xbp");
assert_eq!(snippets[0].methods.len(), 1);
assert_eq!(
snippets[0].methods[0].commands,
vec!["cargo install xbp@10.38.1".to_string()]
);
assert_eq!(
snippets[0].registry_url.as_deref(),
Some("https://crates.io/crates/xbp")
);
assert_eq!(snippets[1].package_name, "@xylex-group/athena");
assert_eq!(snippets[1].methods.len(), 1);
assert_eq!(
snippets[1].methods[0].commands,
vec!["npm install @xylex-group/athena@10.38.1".to_string()]
);
let _ = fs::remove_dir_all(&project_root);
}
#[test]
fn build_release_install_snippets_uses_pnpm_when_lockfile_present() {
let project_root = make_release_install_temp_dir("pnpm-lock");
let npm_dir = project_root.join("packages/athena");
fs::create_dir_all(&npm_dir).expect("npm dir");
fs::write(
npm_dir.join("package.json"),
r#"{ "name": "@xylex-group/athena", "version": "3.1.0" }"#,
)
.expect("package.json");
fs::write(npm_dir.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n").expect("lock");
let snippets = build_release_install_snippets(
&project_root,
&[ReleasePublishTarget {
kind: "npm".to_string(),
manifest_path: "packages/athena/package.json".to_string(),
package_name: Some("@xylex-group/athena".to_string()),
}],
"3.1.0",
);
assert_eq!(snippets.len(), 1);
assert_eq!(
snippets[0].methods[0].commands,
vec!["pnpm add @xylex-group/athena@3.1.0".to_string()]
);
let _ = fs::remove_dir_all(&project_root);
}
#[test]
fn build_release_install_snippets_uses_cargo_add_for_lib_only_crates() {
let project_root = make_release_install_temp_dir("lib-only");
let crate_dir = project_root.join("crates/core");
fs::create_dir_all(crate_dir.join("src")).expect("crate dir");
fs::write(
crate_dir.join("Cargo.toml"),
r#"[package]
name = "xbp-core"
version = "10.38.1"
edition = "2021"
"#,
)
.expect("write cargo");
fs::write(crate_dir.join("src/lib.rs"), "pub fn x() {}\n").expect("write lib");
let snippets = build_release_install_snippets(
&project_root,
&[ReleasePublishTarget {
kind: "crates".to_string(),
manifest_path: "crates/core/Cargo.toml".to_string(),
package_name: None,
}],
"10.38.1",
);
assert_eq!(snippets.len(), 1);
assert_eq!(snippets[0].package_name, "xbp-core");
assert_eq!(snippets[0].methods.len(), 1);
assert_eq!(
snippets[0].methods[0].commands,
vec!["cargo add xbp-core@10.38.1".to_string()]
);
let _ = fs::remove_dir_all(&project_root);
}
#[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 service_tag_prefix_uses_xbp_service_name_not_cargo_package() {
let dir: PathBuf = temp_dir("service-tag-prefix");
let service_dir: PathBuf = dir.join("crates").join("athena-rs");
fs::create_dir_all(&service_dir).expect("create service dir");
fs::write(
service_dir.join("Cargo.toml"),
"[package]\nname = \"athena\"\nversion = \"4.0.0\"\n",
)
.expect("write cargo");
let config_yaml = r#"
project_name: athena
version: 4.0.0
port: 3000
build_dir: ./
services:
- name: athena-rs
target: rust
branch: main
port: 8080
root_directory: crates/athena-rs
version_targets:
- crates/athena-rs/Cargo.toml
"#;
fs::create_dir_all(dir.join(".xbp")).expect("create xbp dir");
fs::write(dir.join(".xbp/xbp.yaml"), config_yaml).expect("write config");
let config: crate::strategies::XbpConfig =
serde_yaml::from_str(config_yaml).expect("parse config");
let service = config.services.as_ref().unwrap()[0].clone();
let scope = super::build_service_scope(&dir, service).expect("service scope");
match scope {
VersionScope::Service { tag_prefix, .. } => assert_eq!(tag_prefix, "athena-rs-"),
_ => panic!("expected service scope"),
}
let _ = fs::remove_dir_all(dir);
}
#[test]
fn change_baseline_uses_successful_ledger_tag_name_not_nested_baseline() {
let dir: PathBuf = temp_dir("baseline-uses-tag-name");
let xbp_dir = dir.join(".xbp").join("releases").join("service-athena-js");
fs::create_dir_all(&xbp_dir).expect("create ledger dir");
fs::write(
xbp_dir.join("3.0.0.yaml"),
r#"scope_kind: service
scope_label: athena-js
scope_slug: service-athena-js
version: 3.0.0
tag_name: athena-js-3.0.0
branch_name: null
status: Completed
baseline:
source: release-ledger
reference: v3.27.0
tag: v3.27.0
selection:
changed_files: []
version_targets: []
publish_targets: []
steps: {}
"#,
)
.expect("write ledger");
let scope = VersionScope::Service {
service_root: dir.join("packages").join("athena-js"),
service_relative_root: "packages/athena-js".to_string(),
service_name: "athena-js".to_string(),
tag_prefix: "athena-js-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["packages/athena-js".to_string()],
};
let baseline = resolve_change_baseline(&dir, &scope, "athena").expect("baseline");
assert_eq!(baseline.0, "release-ledger");
assert_eq!(baseline.1.as_deref(), Some("athena-js-3.0.0"));
assert_eq!(baseline.2.as_deref(), Some("athena-js-3.0.0"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn previous_release_tag_ignores_bare_monorepo_v_tags_for_service_scope() {
let dir: PathBuf = temp_dir("previous-ignores-v-tags");
fs::create_dir_all(dir.join(".xbp")).expect("xbp");
fs::write(dir.join(".xbp/xbp.yaml"), "version_targets: []\n").expect("config");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test"]);
fs::write(dir.join("README.md"), "x\n").expect("readme");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "init"]);
git(&dir, &["tag", "v3.27.0"]);
git(&dir, &["tag", "v3.0.2"]);
git(&dir, &["tag", "athena-js-3.0.0"]);
git(&dir, &["tag", "athena-js-3.1.0"]);
let scope = VersionScope::Service {
service_root: dir.join("packages").join("athena-js"),
service_relative_root: "packages/athena-js".to_string(),
service_name: "athena-js".to_string(),
tag_prefix: "athena-js-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["packages/athena-js".to_string()],
};
let previous =
previous_release_tag(&dir, "athena-js-3.1.0", &scope).expect("previous tag");
assert_eq!(
previous.as_deref(),
Some("athena-js-3.0.0"),
"must not pick monorepo v3.0.2/v3.27.0 as previous for athena-js"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn previous_release_tag_is_scoped_per_service() {
let dir: PathBuf = temp_dir("previous-release-tag");
init_git_repo_with_tags(
&dir,
&[
"athena-js-3.0.0",
"athena-rs-3.0.0",
"athena-rs-4.0.0",
],
);
let scope = VersionScope::Service {
service_root: dir.join("crates/athena-rs"),
service_relative_root: "crates/athena-rs".to_string(),
service_name: "athena-rs".to_string(),
tag_prefix: "athena-rs-".to_string(),
cargo_package_name: Some("athena".to_string()),
version_targets: Vec::new(),
watch_paths: vec!["crates/athena-rs".to_string()],
};
let previous = previous_release_tag(&dir, "athena-rs-4.0.0", &scope).expect("previous tag");
assert_eq!(previous.as_deref(), Some("athena-rs-3.0.0"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn previous_release_tag_ignores_other_service_tags_with_same_version() {
let dir: PathBuf = temp_dir("previous-release-tag-cross-service");
init_git_repo_with_tags(
&dir,
&["athena-js-3.0.0", "athena-rs-3.0.0", "athena-rs-4.0.0"],
);
let scope = VersionScope::Service {
service_root: dir.join("crates/athena-rs"),
service_relative_root: "crates/athena-rs".to_string(),
service_name: "athena-rs".to_string(),
tag_prefix: "athena-rs-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["crates/athena-rs".to_string()],
};
let previous = previous_release_tag(&dir, "athena-rs-4.0.0", &scope).expect("previous tag");
assert_eq!(previous.as_deref(), Some("athena-rs-3.0.0"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn canonical_scoped_release_tag_version_parses_optional_flag_suffix() {
let family = "athena-rs-";
let version = parse_canonical_scoped_release_tag_version("athena-rs-4.0.0-nightly", family)
.expect("flagged tag");
assert_eq!(version.major, 4);
assert_eq!(version.minor, 0);
assert_eq!(version.patch, 0);
assert_eq!(version.build.as_str(), "nightly");
assert_eq!(
parse_release_family_version("athena-rs-4.0.0", family)
.expect("plain tag")
.to_string(),
"4.0.0"
);
}
#[test]
fn release_title_defaults_to_version_and_service_slug() {
let scope = VersionScope::Service {
service_root: PathBuf::from("/tmp/apps/athena-auth"),
service_relative_root: "apps/athena-auth".to_string(),
service_name: "athena-auth".to_string(),
tag_prefix: "athena-auth-".to_string(),
cargo_package_name: None,
version_targets: Vec::new(),
watch_paths: vec!["apps/athena-auth".to_string()],
};
assert_eq!(
default_release_title(&scope, &Version::new(1, 7, 0), "athena"),
"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,
"4.0.0-athena-rs",
"athena-rs-4.0.0",
"https://github.com/xylex-group/athena/releases/tag/athena-rs-4.0.0",
);
assert!(with_label.contains("Release: [4.0.0-athena-rs]"));
assert!(with_label.contains("Tag: [athena-rs-4.0.0]"));
assert!(with_label.contains("Release label: Pre-release"));
assert!(with_label.contains(&format!("Generated by XBP {}", env!("CARGO_PKG_VERSION"))));
}