pub mod rollback;
use anodizer_core::config::{CrateConfig, GitConfig, TagConfig};
use anodizer_core::git;
use anodizer_core::hooks::{HookRunContext, run_hooks};
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::template::TemplateVars;
use anyhow::{Result, bail};
use regex::Regex;
use std::path::{Path, PathBuf};
use crate::commands::bump::cargo_edit::{WorkspaceInfo, apply_plan, load_workspace};
use crate::commands::bump::plan::{BumpLevel, PlanRow};
use crate::commands::changelog_sync::{
ChangelogRouting, ChangelogTarget, render_and_stage_changelogs, resolve_changelog_enabled,
};
use crate::commands::version_files_resolve::resolve_version_files;
pub struct TagOpts {
pub dry_run: bool,
pub custom_tag: Option<String>,
pub default_bump: Option<String>,
pub crate_name: Option<String>,
pub push: bool,
pub no_push: bool,
pub push_remote: Option<String>,
pub push_dry_run: bool,
pub changelog: bool,
pub config_override: Option<std::path::PathBuf>,
pub verbose: bool,
pub debug: bool,
pub quiet: bool,
pub strict: bool,
}
fn resolve_effective_push(opts: &TagOpts, config_push: Option<bool>, path_default: bool) -> bool {
if opts.no_push {
false
} else if opts.push || config_push == Some(true) {
true
} else {
path_default
}
}
fn resolve_tag_push_branch(
opts: &TagOpts,
config_push: Option<bool>,
path_default: bool,
) -> Result<Option<String>> {
if resolve_effective_push(opts, config_push, path_default) {
Ok(Some(git::get_current_branch()?))
} else {
Ok(None)
}
}
#[derive(Clone)]
struct ResolvedConfig {
default_bump: String,
bump_minor_pre_major: bool,
bump_patch_for_minor_pre_major: bool,
tag_prefix: String,
release_branches: Vec<String>,
custom_tag: Option<String>,
tag_context: String,
branch_history: String,
initial_version: String,
prerelease: bool,
prerelease_suffix: String,
force_without_changes: bool,
force_without_changes_pre: bool,
major_string_token: String,
minor_string_token: String,
patch_string_token: String,
none_string_token: String,
git_api_tagging: bool,
skip_ci_on_bump: bool,
}
impl ResolvedConfig {
fn from_tag_config(cfg: &TagConfig, opts: &TagOpts) -> Self {
ResolvedConfig {
default_bump: opts
.default_bump
.clone()
.or_else(|| cfg.default_bump.clone())
.unwrap_or_else(|| "none".to_string()),
bump_minor_pre_major: cfg.bump_minor_pre_major.unwrap_or(false),
bump_patch_for_minor_pre_major: cfg.bump_patch_for_minor_pre_major.unwrap_or(false),
tag_prefix: cfg.tag_prefix.clone().unwrap_or_else(|| "v".to_string()),
release_branches: cfg.release_branches.clone().unwrap_or_default(),
custom_tag: opts.custom_tag.clone().or_else(|| cfg.custom_tag.clone()),
tag_context: cfg
.tag_context
.clone()
.unwrap_or_else(|| "repo".to_string()),
branch_history: cfg
.branch_history
.clone()
.unwrap_or_else(|| "compare".to_string()),
initial_version: cfg
.initial_version
.clone()
.unwrap_or_else(|| "0.0.0".to_string()),
prerelease: cfg.prerelease.unwrap_or(false),
prerelease_suffix: cfg
.prerelease_suffix
.clone()
.unwrap_or_else(|| "beta".to_string()),
force_without_changes: cfg.force_without_changes.unwrap_or(false),
force_without_changes_pre: cfg.force_without_changes_pre.unwrap_or(false),
major_string_token: cfg
.major_string_token
.clone()
.unwrap_or_else(|| "#major".to_string()),
minor_string_token: cfg
.minor_string_token
.clone()
.unwrap_or_else(|| "#minor".to_string()),
patch_string_token: cfg
.patch_string_token
.clone()
.unwrap_or_else(|| "#patch".to_string()),
none_string_token: cfg
.none_string_token
.clone()
.unwrap_or_else(|| "#none".to_string()),
git_api_tagging: cfg.git_api_tagging.unwrap_or(false),
skip_ci_on_bump: cfg.skip_ci_on_bump.unwrap_or(false),
}
}
}
fn skip_ci_suffix(skip_ci_on_bump: bool) -> &'static str {
if skip_ci_on_bump { " [skip ci]" } else { "" }
}
pub fn run(opts: TagOpts) -> Result<()> {
let workspace_root_path =
crate::commands::helpers::discover_workspace_root(resolve_config_path(&opts).as_deref())?;
let loaded_config: Option<anodizer_core::config::Config> =
load_config_at(&opts, &workspace_root_path);
let loaded_workspace: Option<WorkspaceInfo> = load_workspace(&workspace_root_path).ok();
let tag_config = loaded_config
.as_ref()
.and_then(|c| c.tag.clone())
.unwrap_or_default();
let git_config: Option<anodizer_core::config::GitConfig> =
loaded_config.as_ref().and_then(|c| c.git.clone());
let changelog_enabled = resolve_changelog_enabled(loaded_config.as_ref(), opts.changelog);
guard_flat_aggregate_coherence(
loaded_config.as_ref(),
loaded_workspace.as_ref(),
&workspace_root_path,
)?;
let mut cfg = ResolvedConfig::from_tag_config(&tag_config, &opts);
let remote = opts.push_remote.as_deref().unwrap_or("origin").to_string();
let config_push = tag_config.push;
let mut crate_path: Option<String> = None;
let mut version_sync_enabled = false;
let mut crate_version_files: Vec<String> = Vec::new();
if let Some(ref crate_name) = opts.crate_name
&& let Some(info) = load_crate_tag_info(&opts, &workspace_root_path, crate_name)
{
cfg.tag_prefix = info.tag_prefix;
crate_path = Some(info.path);
version_sync_enabled = info.version_sync;
crate_version_files = info.version_files;
}
if let Some(ref ct) = cfg.custom_tag
&& opts.crate_name.is_none()
{
if matches!(
detect_repo_shape(
&workspace_root_path,
loaded_config.as_ref(),
loaded_workspace.as_ref()
),
RepoShape::PerCrate(_)
) {
anyhow::bail!(
"--custom-tag {:?} is incompatible with per-crate workspace mode; \
pass --crate <name> to override a single crate's tag",
ct
);
}
}
if opts.crate_name.is_none() {
let mut is_flat_aggregate = false;
let groups = match detect_repo_shape(
&workspace_root_path,
loaded_config.as_ref(),
loaded_workspace.as_ref(),
) {
RepoShape::PerCrate(groups) => Some(groups),
RepoShape::FlatAggregate(crates) if cfg.custom_tag.is_none() => {
is_flat_aggregate = true;
Some(vec![crates])
}
RepoShape::Single | RepoShape::Lockstep | RepoShape::FlatAggregate(_) => None,
};
if let Some(groups) = groups {
let config_verbose = tag_config.verbose.unwrap_or(false);
let effective_verbose = opts.verbose || (config_verbose && !opts.quiet);
let log = StageLogger::new(
"tag",
Verbosity::from_flags(opts.quiet, effective_verbose, opts.debug),
);
log.status(&format!(
"running auto-tag (per-crate){}",
if opts.dry_run { " (dry-run)" } else { "" }
));
return run_per_crate_tag(
PerCrateDispatch {
groups,
is_flat_aggregate,
workspace_root: workspace_root_path.clone(),
},
&opts,
&cfg,
git_config.as_ref(),
loaded_config.as_ref(),
PushControls {
remote: &remote,
config_push,
changelog_enabled,
},
&log,
);
}
}
let workspace_info: Option<&WorkspaceInfo> = if opts.crate_name.is_none() {
loaded_workspace
.as_ref()
.filter(|ws| ws.workspace_package_version.is_some())
} else {
None
};
let config_verbose = tag_config.verbose.unwrap_or(false);
let effective_verbose = opts.verbose || (config_verbose && !opts.quiet);
let log = StageLogger::new(
"tag",
Verbosity::from_flags(opts.quiet, effective_verbose, opts.debug),
);
log.status(&format!(
"running auto-tag{}",
if opts.dry_run { " (dry-run)" } else { "" }
));
let strict = opts.strict;
let tag_prefix_for_hooks = cfg.tag_prefix.clone();
let pre_hooks = tag_config.tag_pre_hooks.clone().unwrap_or_default();
let post_hooks = tag_config.tag_post_hooks.clone().unwrap_or_default();
let push_mode = resolve_effective_push(&opts, config_push, false) || opts.push_dry_run;
let push_preview = opts.push_dry_run;
let push_branch = if push_mode {
Some(git::get_current_branch()?)
} else {
None
};
let cwd = workspace_root_path.clone();
let create_tag = |tag: &str, message: &str, dry_run: bool, prev: Option<&str>| -> Result<()> {
let mut tv = TemplateVars::new();
tv.set("Tag", tag);
tv.set("PrefixedTag", tag);
let version = tag
.strip_prefix(tag_prefix_for_hooks.as_str())
.unwrap_or(tag);
tv.set("Version", version);
if let Some(p) = prev {
tv.set("PreviousTag", p);
}
unsafe {
std::env::set_var("ANODIZER_CURRENT_TAG", tag);
if let Some(p) = prev {
std::env::set_var("ANODIZER_PREVIOUS_TAG", p);
}
}
if !pre_hooks.is_empty() {
run_hooks(
&pre_hooks,
"tag-pre",
HookRunContext::new(dry_run, &log, Some(&tv)),
)?;
}
let push_dry = dry_run || push_preview;
if cfg.git_api_tagging {
log.verbose("using GitHub API for tagging (git_api_tagging=true)");
if push_mode {
git::push_branch_and_tags_atomic_in(
&cwd,
&git::AtomicPushSpec {
remote: &remote,
branch: push_branch.as_deref(),
tags: &[],
dry_run: push_dry,
strict,
},
&log,
)?;
}
git::create_tag_via_github_api(tag, message, dry_run, &log, strict)?;
} else if push_mode {
git::create_tag_local_only(&cwd, tag, message, dry_run, &log)?;
git::push_branch_and_tags_atomic_in(
&cwd,
&git::AtomicPushSpec {
remote: &remote,
branch: push_branch.as_deref(),
tags: std::slice::from_ref(&tag.to_string()),
dry_run: push_dry,
strict,
},
&log,
)?;
} else {
git::create_and_push_tag(tag, message, dry_run, &log, strict)?;
}
if !post_hooks.is_empty() {
run_hooks(
&post_hooks,
"tag-post",
HookRunContext::new(dry_run, &log, Some(&tv)),
)?;
}
Ok(())
};
if let Some(ref custom) = cfg.custom_tag {
let new_tag = if custom.starts_with(&cfg.tag_prefix) {
custom.clone()
} else {
format!("{}{}", cfg.tag_prefix, custom)
};
log.verbose(&format!("using custom tag: {}", new_tag));
let prev_for_custom = find_previous_tag(&cfg, git_config.as_ref()).ok().flatten();
create_tag(
&new_tag,
&format!("Release {}", new_tag),
opts.dry_run,
prev_for_custom.as_deref(),
)?;
println!("new_tag={}", new_tag);
println!("old_tag=");
println!("part=custom");
return Ok(());
}
let current_branch = git::get_current_branch()?;
if !cfg.release_branches.is_empty() && !branch_matches(¤t_branch, &cfg.release_branches) {
let short_commit = git::get_short_commit()?;
let prev_tag = find_previous_tag(&cfg, git_config.as_ref())?;
let base_version = match &prev_tag {
Some(tag) => {
let sv = git::parse_semver_tag(tag)?;
format!("{}.{}.{}", sv.major, sv.minor, sv.patch)
}
None => cfg.initial_version.clone(),
};
let hash_tag = format!("{}{}-{}", cfg.tag_prefix, base_version, short_commit);
log.verbose(&format!(
"branch '{}' is not a release branch, producing hash-postfixed version: {}",
current_branch, hash_tag
));
println!("new_tag={}", hash_tag);
println!("old_tag={}", prev_tag.as_deref().unwrap_or(""));
println!("part=none");
return Ok(());
}
let prev_tag = find_previous_tag(&cfg, git_config.as_ref())?;
log.verbose(&format!(
"previous tag: {}",
prev_tag.as_deref().unwrap_or("(none)")
));
if let Some(ref tag) = prev_tag {
let has_changes = if let Some(ref path) = crate_path {
git::has_changes_since_in(&workspace_root_path, tag, path)?
} else {
git::has_commits_since_tag(tag)?
};
if !has_changes {
let force = if cfg.prerelease {
cfg.force_without_changes_pre
} else {
cfg.force_without_changes
};
if !force {
log.verbose(&format!("no changes since {} -- skipping", tag));
println!("new_tag={}", tag);
println!("old_tag={}", tag);
println!("part=none");
return Ok(());
}
log.verbose(&format!(
"no changes since {}, but force_without_changes is enabled",
tag
));
}
}
let messages = get_messages_for_bump(
&workspace_root_path,
&cfg,
prev_tag.as_deref(),
crate_path.as_deref(),
)?;
log.verbose(&format!("scanned {} commit message(s)", messages.len()));
let bump = detect_bump_demoted(&messages, &cfg, prev_tag.as_deref());
log.verbose(&format!("detected bump: {:?}", bump));
let cargo_current_ver: Option<String> = if let Some(ws) = workspace_info {
ws.workspace_package_version.clone()
} else if version_sync_enabled && let Some(ref path) = crate_path {
let abs = workspace_root_path.join(path);
anodizer_stage_build::version_sync::read_cargo_version(&abs.to_string_lossy()).ok()
} else {
None
};
let cargo_ahead = match (
cargo_current_ver
.as_deref()
.and_then(|v| git::parse_semver(v).ok()),
prev_tag
.as_deref()
.and_then(|t| git::parse_semver_tag(t).ok()),
) {
(Some(c), Some(p)) => (c.major, c.minor, c.patch) > (p.major, p.minor, p.patch),
_ => false,
};
if bump == BumpKind::None && !cargo_ahead {
log.verbose("no bump signal and Cargo.toml not ahead -- skipping tag");
println!("new_tag={}", prev_tag.as_deref().unwrap_or(""));
println!("old_tag={}", prev_tag.as_deref().unwrap_or(""));
println!("part=none");
return Ok(());
}
let (new_major, new_minor, new_patch, old_tag_str) = if let Some(ref prev) = prev_tag {
let base = git::parse_semver_tag(prev)?;
let (maj, min, pat) = apply_bump(base.major, base.minor, base.patch, &bump);
(maj, min, pat, prev.as_str())
} else {
let base = git::parse_semver_tag(&format!("{}{}", cfg.tag_prefix, cfg.initial_version))
.unwrap_or(git::SemVer {
major: 0,
minor: 1,
patch: 0,
prerelease: None,
build_metadata: None,
});
(base.major, base.minor, base.patch, "")
};
let mut new_version = format!("{}.{}.{}", new_major, new_minor, new_patch);
if cfg.prerelease {
new_version = format!("{}-{}", new_version, cfg.prerelease_suffix);
}
if let Some(cargo_ver) = cargo_current_ver
&& let Ok(cargo_sv) = git::parse_semver(&cargo_ver)
{
let tag_tuple = (new_major, new_minor, new_patch);
let cargo_tuple = (cargo_sv.major, cargo_sv.minor, cargo_sv.patch);
if cargo_tuple > tag_tuple {
log.status(&format!(
"Cargo.toml version {} > tag-derived {}, using Cargo.toml version",
cargo_ver, new_version
));
new_version = cargo_ver;
}
}
let new_tag = format!("{}{}", cfg.tag_prefix, new_version);
log.verbose(&format!("{} -> {}", old_tag_str, new_tag));
let mut bump_commit_created = false;
if let Some(ws) = workspace_info {
let root = workspace_root_path.as_path();
let ws_version_files = resolve_version_files(None, loaded_config.as_ref());
let ws_old = bare_version_from_tag(old_tag_str);
let ws_from_tag = (!old_tag_str.is_empty()).then_some(old_tag_str);
let cl_config = changelog_config_for(loaded_config.as_ref());
let cl_routing = ChangelogRouting::from_config(&cl_config);
bump_commit_created = apply_workspace_bump(
root,
ws,
&new_version,
&WorkspaceBumpEdits {
vf: VersionFilesBump {
old: ws_old.as_deref(),
files: &ws_version_files,
},
cl: ChangelogBump {
enabled: changelog_enabled,
from_tag: ws_from_tag,
full_tag: &new_tag,
routing: &cl_routing,
},
},
opts.dry_run,
cfg.skip_ci_on_bump,
&log,
)?;
} else if let Some(ref path) = crate_path
&& version_sync_enabled
{
let abs_crate_dir = workspace_root_path
.join(path)
.to_string_lossy()
.into_owned();
anodizer_stage_build::version_sync::sync_version(
&abs_crate_dir,
&new_version,
opts.dry_run,
&log,
)?;
let workspace_root = workspace_root_path.to_string_lossy().to_string();
let crate_cargo = std::path::Path::new(&abs_crate_dir).join("Cargo.toml");
let crate_name = if let Ok(content) = std::fs::read_to_string(&crate_cargo) {
content
.parse::<toml_edit::DocumentMut>()
.ok()
.and_then(|doc| {
doc.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(|s| s.to_string())
})
} else {
None
};
let dep_modified = if let Some(ref name) = crate_name {
anodizer_stage_build::version_sync::sync_workspace_deps(
&workspace_root,
&abs_crate_dir,
name,
&new_version,
opts.dry_run,
&log,
)?
} else {
vec![]
};
let vf_old = bare_version_from_tag(old_tag_str);
let vf_changed = match vf_old {
Some(ref old) => rewrite_and_stage_version_files(
&workspace_root_path,
&crate_version_files,
old,
&new_version,
opts.dry_run,
&log,
)?,
None => Vec::new(),
};
let ws_root = Path::new(&workspace_root);
let cl_changed = if changelog_enabled {
let from_tag = (!old_tag_str.is_empty()).then(|| old_tag_str.to_string());
let targets = crate_name
.as_ref()
.map(|name| {
vec![ChangelogTarget {
crate_name: name.clone(),
crate_dir: ws_root.join(path),
from_tag,
to_version: new_version.clone(),
full_tag: new_tag.clone(),
}]
})
.unwrap_or_default();
let cl_config = changelog_config_for(loaded_config.as_ref());
let mut routing = ChangelogRouting::from_config(&cl_config);
if let Some(cfg) = loaded_config.as_ref() {
routing.root_crate_names = crate::commands::changelog_sync::config_root_crate_names(
cfg,
routing.root_crates,
);
}
render_and_stage_changelogs(ws_root, &targets, &routing, opts.dry_run, &log)?
} else {
Vec::new()
};
if !opts.dry_run {
match anodizer_core::cargo_lock::cargo_update_workspace(Some(workspace_root_path.as_path())) {
Ok(true) => {}
Ok(false) => log.warn(
"version-sync: `cargo update --workspace` exited non-zero; Cargo.lock may be stale",
),
Err(e) => log.warn(&format!(
"version-sync: could not spawn `cargo update --workspace` ({e}); Cargo.lock may be stale"
)),
}
let cargo_toml = format!("{}/Cargo.toml", path);
let mut files_to_stage: Vec<&str> = vec![&cargo_toml, "Cargo.lock"];
for f in &dep_modified {
files_to_stage.push(f);
}
for f in &vf_changed {
files_to_stage.push(f);
}
for f in &cl_changed {
if !files_to_stage.contains(&f.as_str()) {
files_to_stage.push(f);
}
}
bump_commit_created = git::stage_and_commit_in(
&workspace_root_path,
&files_to_stage,
&format!(
"chore(release): bump {} → {}{}",
path,
new_version,
skip_ci_suffix(cfg.skip_ci_on_bump)
),
)?;
}
}
let prev_for_hook = if old_tag_str.is_empty() {
None
} else {
Some(old_tag_str)
};
create_tag(
&new_tag,
&format!("Release {}", new_tag),
opts.dry_run,
prev_for_hook,
)?;
if bump_commit_created
&& push_branch.is_none()
&& !opts.no_push
&& !opts.dry_run
&& !opts.push_dry_run
{
log.status(
"tagged a version-sync bump commit but left it local; \
pass --push to push the bump commit + tag atomically (or push the branch yourself)",
);
}
let part_str = match bump {
BumpKind::Major => "major",
BumpKind::Minor => "minor",
BumpKind::Patch => "patch",
BumpKind::None => "none",
};
println!("new_tag={}", new_tag);
println!("old_tag={}", old_tag_str);
println!("part={}", part_str);
Ok(())
}
struct VersionFilesBump<'a> {
old: Option<&'a str>,
files: &'a [String],
}
struct ChangelogBump<'a> {
enabled: bool,
from_tag: Option<&'a str>,
full_tag: &'a str,
routing: &'a ChangelogRouting<'a>,
}
struct WorkspaceBumpEdits<'a> {
vf: VersionFilesBump<'a>,
cl: ChangelogBump<'a>,
}
fn changelog_config_for(
config: Option<&anodizer_core::config::Config>,
) -> anodizer_core::config::ChangelogConfig {
config.and_then(|c| c.changelog.clone()).unwrap_or_default()
}
fn apply_workspace_bump(
workspace_root: &Path,
ws: &WorkspaceInfo,
new_version: &str,
edits: &WorkspaceBumpEdits<'_>,
dry_run: bool,
skip_ci_on_bump: bool,
log: &StageLogger,
) -> Result<bool> {
let WorkspaceBumpEdits { vf, cl } = edits;
let rows: Vec<PlanRow> = ws
.members
.iter()
.map(|m| {
let current = if m.inherits_workspace_version {
ws.workspace_package_version.clone().unwrap_or_default()
} else {
m.own_version.clone().unwrap_or_default()
};
let level = if current == new_version {
BumpLevel::Skip
} else {
BumpLevel::Explicit
};
PlanRow {
crate_name: m.name.clone(),
current,
next: new_version.to_string(),
level,
reason: "workspace tag".into(),
edited_files: vec![],
manifest: m.manifest_path.clone(),
inherits_workspace_version: m.inherits_workspace_version,
}
})
.collect();
if rows.iter().all(|r| r.level == BumpLevel::Skip) {
log.verbose(&format!(
"workspace already at {}, nothing to sync",
new_version
));
return Ok(false);
}
let per_crate_targets: Vec<ChangelogTarget> = if cl.enabled && cl.routing.per_crate {
ws.members
.iter()
.map(|m| ChangelogTarget {
crate_name: m.name.clone(),
crate_dir: m
.manifest_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| workspace_root.to_path_buf()),
from_tag: cl.from_tag.map(str::to_string),
to_version: new_version.to_string(),
full_tag: cl.full_tag.to_string(),
})
.collect()
} else {
Vec::new()
};
let per_crate_routing = ChangelogRouting {
root_enabled: false,
per_crate: true,
chronology: cl.routing.chronology,
root_crates: cl.routing.root_crates,
single_track: false,
multitrack: false,
root_crate_names: Vec::new(),
};
let root_aggregate_target: Vec<ChangelogTarget> = if cl.enabled && cl.routing.root_enabled {
vec![ChangelogTarget {
crate_name: ws
.members
.first()
.map(|m| m.name.clone())
.unwrap_or_default(),
crate_dir: workspace_root.to_path_buf(),
from_tag: cl.from_tag.map(str::to_string),
to_version: new_version.to_string(),
full_tag: cl.full_tag.to_string(),
}]
} else {
Vec::new()
};
let root_routing = ChangelogRouting {
root_enabled: true,
per_crate: false,
chronology: cl.routing.chronology,
root_crates: None,
single_track: true,
multitrack: false,
root_crate_names: Vec::new(),
};
if dry_run {
log.status(&format!(
"(dry-run) workspace version-sync: would bump {} crate(s) → {}",
rows.iter().filter(|r| r.level != BumpLevel::Skip).count(),
new_version
));
if let Some(old) = vf.old {
rewrite_and_stage_version_files(workspace_root, vf.files, old, new_version, true, log)?;
}
render_and_stage_changelogs(
workspace_root,
&per_crate_targets,
&per_crate_routing,
true,
log,
)?;
render_and_stage_changelogs(
workspace_root,
&root_aggregate_target,
&root_routing,
true,
log,
)?;
return Ok(false);
}
apply_plan(workspace_root, &rows, false, log)?;
match anodizer_core::cargo_lock::cargo_update_workspace(Some(workspace_root)) {
Ok(true) => {}
Ok(false) => log.warn(
"version-sync: `cargo update --workspace` exited non-zero; Cargo.lock may be stale",
),
Err(e) => log.warn(&format!(
"version-sync: could not spawn `cargo update --workspace` ({e}); Cargo.lock may be stale"
)),
}
let mut staged: Vec<PathBuf> = Vec::new();
let root_manifest = workspace_root.join("Cargo.toml");
staged.push(root_manifest.clone());
for m in &ws.members {
if m.manifest_path != root_manifest && !staged.contains(&m.manifest_path) {
staged.push(m.manifest_path.clone());
}
}
let lockfile = workspace_root.join("Cargo.lock");
if lockfile.is_file() {
staged.push(lockfile);
}
let mut staged_rel: Vec<String> = staged
.iter()
.map(|p| {
p.strip_prefix(workspace_root)
.unwrap_or(p.as_path())
.to_string_lossy()
.into_owned()
})
.collect();
if let Some(old) = vf.old {
let vf_changed = rewrite_and_stage_version_files(
workspace_root,
vf.files,
old,
new_version,
false,
log,
)?;
for f in vf_changed {
if !staged_rel.contains(&f) {
staged_rel.push(f);
}
}
}
let mut cl_changed = render_and_stage_changelogs(
workspace_root,
&per_crate_targets,
&per_crate_routing,
false,
log,
)?;
cl_changed.extend(render_and_stage_changelogs(
workspace_root,
&root_aggregate_target,
&root_routing,
false,
log,
)?);
for f in cl_changed {
if !staged_rel.contains(&f) {
staged_rel.push(f);
}
}
let staged_refs: Vec<&str> = staged_rel.iter().map(|s| s.as_str()).collect();
git::stage_and_commit_in(
workspace_root,
&staged_refs,
&format!(
"chore(release): bump workspace → {}{}",
new_version,
skip_ci_suffix(skip_ci_on_bump)
),
)?;
log.status(&format!("workspace version-sync: bumped → {}", new_version));
Ok(true)
}
fn resolve_config_path(opts: &TagOpts) -> Option<std::path::PathBuf> {
opts.config_override
.as_deref()
.filter(|p| p.exists())
.map(|p| p.to_path_buf())
.or_else(|| crate::pipeline::find_config(None).ok())
}
fn load_config_at(opts: &TagOpts, root: &Path) -> Option<anodizer_core::config::Config> {
match opts.config_override.as_deref().filter(|p| p.exists()) {
Some(p) => crate::pipeline::load_config(p).ok(),
None => crate::pipeline::load_repo_config(root).ok(),
}
}
pub(crate) enum RepoShape {
Single,
Lockstep,
FlatAggregate(Vec<CrateConfig>),
PerCrate(Vec<Vec<CrateConfig>>),
}
pub(crate) fn detect_repo_shape(
workspace_root: &Path,
preloaded_config: Option<&anodizer_core::config::Config>,
preloaded_workspace: Option<&WorkspaceInfo>,
) -> RepoShape {
if let Some(config) = preloaded_config
&& let Some(ref ws_list) = config.workspaces
&& !ws_list.is_empty()
{
let groups: Vec<Vec<CrateConfig>> = ws_list.iter().map(|ws| ws.crates.clone()).collect();
return RepoShape::PerCrate(groups);
}
let lockstep = if let Some(ws) = preloaded_workspace {
ws.workspace_package_version.is_some()
} else {
load_workspace(workspace_root)
.ok()
.is_some_and(|ws| ws.workspace_package_version.is_some())
};
if lockstep {
return RepoShape::Lockstep;
}
let config = match preloaded_config {
Some(c) => c,
None => return RepoShape::Single,
};
if config.crates.len() > 1 {
if shared_tag_prefix(&config.crates).is_some() {
return RepoShape::FlatAggregate(config.crates.clone());
}
let groups: Vec<Vec<CrateConfig>> = config.crates.iter().map(|c| vec![c.clone()]).collect();
return RepoShape::PerCrate(groups);
}
RepoShape::Single
}
fn shared_tag_prefix(crates: &[CrateConfig]) -> Option<String> {
let mut iter = crates.iter();
let first = git::extract_tag_prefix(&iter.next()?.tag_template)?;
for c in iter {
if git::extract_tag_prefix(&c.tag_template)? != first {
return None;
}
}
Some(first)
}
pub(crate) fn guard_flat_aggregate_coherence(
config: Option<&anodizer_core::config::Config>,
workspace: Option<&WorkspaceInfo>,
workspace_root: &Path,
) -> Result<()> {
let RepoShape::FlatAggregate(crates) = detect_repo_shape(workspace_root, config, workspace)
else {
return Ok(());
};
let prefix = shared_tag_prefix(&crates).unwrap_or_else(|| "v".to_string());
let mut versions: Vec<(String, String)> = Vec::new();
for c in &crates {
let crate_dir = workspace_root.join(&c.path);
if let Ok(Some(ver)) =
anodizer_stage_build::version_sync::read_cargo_version_opt(&crate_dir.to_string_lossy())
{
versions.push((c.name.clone(), ver));
}
}
let Some((_, first_ver)) = versions.first() else {
return Ok(());
};
if versions.iter().any(|(_, v)| v != first_ver) {
let listing = versions
.iter()
.map(|(name, ver)| format!("'{name}' ({ver})"))
.collect::<Vec<_>>()
.join(", ");
bail!(
"crates {listing} share tag prefix '{prefix}' but set different [package].version \
values; one tag can't carry two versions. For lockstep set \
[workspace.package].version; for independent releases give each crate a distinct \
tag_template prefix."
);
}
Ok(())
}
struct GroupTagResult {
crate_names: Vec<String>,
new_tags: Vec<(String, String)>,
version_updates: Vec<(String, String)>,
old_version: Option<String>,
prev_tag: Option<String>,
crate_version_files: Vec<Vec<String>>,
}
fn compute_per_crate_tags(
workspace_root: &Path,
groups: &[Vec<CrateConfig>],
opts: &TagOpts,
cfg: &ResolvedConfig,
git_config: Option<&GitConfig>,
preloaded_config: Option<&anodizer_core::config::Config>,
log: &StageLogger,
) -> Result<Vec<GroupTagResult>> {
use crate::commands::release::{detect_changed_crates_pub, flatten_known_crates};
let fallback: anodizer_core::config::Config;
let anodizer_config: &anodizer_core::config::Config = if let Some(c) = preloaded_config {
c
} else {
fallback = resolve_config_path(opts)
.and_then(|p| crate::pipeline::load_config(&p).ok())
.unwrap_or_default();
&fallback
};
let all_known = flatten_known_crates(anodizer_config);
let changed_names = detect_changed_crates_pub(
workspace_root,
&all_known,
anodizer_config.git.as_ref(),
anodizer_config.monorepo_tag_prefix(),
log,
)?;
if changed_names.is_empty() {
return Ok(vec![]);
}
use std::collections::HashSet;
let changed_set: HashSet<&str> = changed_names.iter().map(|s| s.as_str()).collect();
let mut results: Vec<GroupTagResult> = Vec::new();
for group in groups {
let group_selected = group.iter().any(|c| changed_set.contains(c.name.as_str()));
if !group_selected {
continue;
}
let first = &group[0];
let tag_prefix =
git::extract_tag_prefix(&first.tag_template).unwrap_or_else(|| cfg.tag_prefix.clone());
let group_cfg = ResolvedConfig {
tag_prefix: tag_prefix.clone(),
custom_tag: None,
..cfg.clone()
};
let prev_tag = find_previous_tag(&group_cfg, git_config)?;
let mut all_messages: Vec<String> = Vec::new();
for crate_cfg in group {
let msgs = get_messages_for_bump(
workspace_root,
cfg,
prev_tag.as_deref(),
Some(&crate_cfg.path),
)
.unwrap_or_default();
all_messages.extend(msgs);
}
let bump = detect_bump_demoted(&all_messages, &group_cfg, prev_tag.as_deref());
if bump == BumpKind::None {
log.verbose(&format!(
"group {:?}: no bump signal — skipping",
group.iter().map(|c| c.name.as_str()).collect::<Vec<_>>()
));
continue;
}
let (new_major, new_minor, new_patch, old_tag_str) = if let Some(ref prev) = prev_tag {
let base = git::parse_semver_tag(prev)?;
let (maj, min, pat) = apply_bump(base.major, base.minor, base.patch, &bump);
(maj, min, pat, prev.as_str())
} else {
let base = git::parse_semver_tag(&format!("{}{}", tag_prefix, cfg.initial_version))
.unwrap_or(git::SemVer {
major: 0,
minor: 1,
patch: 0,
prerelease: None,
build_metadata: None,
});
(base.major, base.minor, base.patch, "")
};
let mut new_version = format!("{}.{}.{}", new_major, new_minor, new_patch);
if cfg.prerelease {
new_version = format!("{}-{}", new_version, cfg.prerelease_suffix);
}
log.verbose(&format!(
"group {:?}: {} -> {}{}",
group.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
old_tag_str,
tag_prefix,
new_version
));
let mut new_tags: Vec<(String, String)> = Vec::new();
let mut version_updates: Vec<(String, String)> = Vec::new();
let mut crate_version_files: Vec<Vec<String>> = Vec::new();
for crate_cfg in group {
let crate_prefix = git::extract_tag_prefix(&crate_cfg.tag_template)
.unwrap_or_else(|| tag_prefix.clone());
let new_tag = format!("{}{}", crate_prefix, new_version);
let message = format!("Release {}", new_tag);
new_tags.push((new_tag, message));
version_updates.push((crate_cfg.path.clone(), new_version.clone()));
crate_version_files.push(resolve_version_files(
Some(crate_cfg),
Some(anodizer_config),
));
}
results.push(GroupTagResult {
crate_names: group.iter().map(|c| c.name.clone()).collect(),
new_tags,
version_updates,
old_version: bare_version_from_tag(old_tag_str),
prev_tag: (!old_tag_str.is_empty()).then(|| old_tag_str.to_string()),
crate_version_files,
});
}
Ok(results)
}
#[derive(Debug, Clone, Copy)]
struct PushControls<'a> {
remote: &'a str,
config_push: Option<bool>,
changelog_enabled: bool,
}
struct PerCrateDispatch {
groups: Vec<Vec<CrateConfig>>,
is_flat_aggregate: bool,
workspace_root: PathBuf,
}
fn run_per_crate_tag(
dispatch: PerCrateDispatch,
opts: &TagOpts,
cfg: &ResolvedConfig,
git_config: Option<&GitConfig>,
anodizer_config: Option<&anodizer_core::config::Config>,
controls: PushControls<'_>,
log: &StageLogger,
) -> Result<()> {
let PerCrateDispatch {
groups,
is_flat_aggregate,
workspace_root,
} = dispatch;
let cwd = workspace_root.clone();
let tag_results = compute_per_crate_tags(
&workspace_root,
&groups,
opts,
cfg,
git_config,
anodizer_config,
log,
)?;
if tag_results.is_empty() {
log.verbose("no changed crates — nothing to tag");
println!("anodizer-output crates=[]");
println!("anodizer-output versions={{}}");
return Ok(());
}
let all_tagged_crates: Vec<String> = tag_results
.iter()
.flat_map(|r| r.crate_names.iter().cloned())
.collect();
let all_version_updates: Vec<(String, String)> = tag_results
.iter()
.flat_map(|r| r.version_updates.iter().cloned())
.collect();
let mut all_new_tags: Vec<String> = Vec::new();
for r in &tag_results {
for (t, _) in &r.new_tags {
if !all_new_tags.contains(t) {
all_new_tags.push(t.clone());
}
}
}
let vf_plan = plan_version_files_rewrites(&tag_results)?;
let mut changelog_targets = if controls.changelog_enabled {
plan_changelog_targets(&cwd, &tag_results)
} else {
Vec::new()
};
let cl_config = changelog_config_for(anodizer_config);
let mut changelog_routing = ChangelogRouting::from_config(&cl_config);
if collapse_targets_to_flat_aggregate(
&mut changelog_targets,
&cwd,
anodizer_config,
is_flat_aggregate && changelog_routing.root_enabled && !changelog_routing.per_crate,
) {
changelog_routing.single_track = true;
}
changelog_routing.root_crate_names = anodizer_config
.map(|cfg| {
crate::commands::changelog_sync::config_root_crate_names(
cfg,
changelog_routing.root_crates,
)
})
.unwrap_or_default();
changelog_routing.multitrack = changelog_routing.root_enabled
&& !changelog_routing.single_track
&& changelog_routing.root_crate_names.len() > 1;
if !opts.dry_run {
for (path, new_version) in &all_version_updates {
let abs_crate_dir = workspace_root.join(path).to_string_lossy().into_owned();
anodizer_stage_build::version_sync::sync_version(
&abs_crate_dir,
new_version,
false,
log,
)?;
}
let workspace_root_str = workspace_root.to_string_lossy().into_owned();
let mut intra_ws_modified: Vec<String> = Vec::new();
for group_result in &tag_results {
for (crate_name, (crate_path, new_version)) in group_result
.crate_names
.iter()
.zip(group_result.version_updates.iter())
{
let abs_crate_dir = workspace_root
.join(crate_path)
.to_string_lossy()
.into_owned();
let modified = anodizer_stage_build::version_sync::sync_workspace_deps(
&workspace_root_str,
&abs_crate_dir,
crate_name,
new_version,
false,
log,
)?;
intra_ws_modified.extend(modified);
}
}
match anodizer_core::cargo_lock::cargo_update_workspace(Some(workspace_root.as_path())) {
Ok(_) => {}
Err(e) => log.warn(&format!(
"version-sync: could not spawn `cargo update --workspace` ({e}); Cargo.lock may be stale"
)),
}
let mut files_to_stage: Vec<String> = all_version_updates
.iter()
.map(|(path, _)| format!("{}/Cargo.toml", path))
.collect();
for abs in &intra_ws_modified {
let rel = Path::new(abs)
.strip_prefix(&workspace_root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| abs.clone());
if !files_to_stage.contains(&rel) {
files_to_stage.push(rel);
}
}
for rewrite in &vf_plan {
let vf_changed = rewrite_and_stage_version_files(
&workspace_root,
std::slice::from_ref(&rewrite.file),
&rewrite.old,
&rewrite.new,
false,
log,
)?;
for f in vf_changed {
if !files_to_stage.contains(&f) {
files_to_stage.push(f);
}
}
}
let cl_changed =
render_and_stage_changelogs(&cwd, &changelog_targets, &changelog_routing, false, log)?;
for f in cl_changed {
if !files_to_stage.contains(&f) {
files_to_stage.push(f);
}
}
files_to_stage.push("Cargo.lock".to_string());
let staged_refs: Vec<&str> = files_to_stage.iter().map(|s| s.as_str()).collect();
let version_arrows: Vec<String> = tag_results
.iter()
.flat_map(|r| r.version_updates.iter())
.map(|(path, ver)| {
let label = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path.as_str());
format!("{}→{}", label, ver)
})
.collect();
let bump_summary = if version_arrows.is_empty() {
all_tagged_crates.join(", ")
} else {
version_arrows.join(", ")
};
git::stage_and_commit_in(
&workspace_root,
&staged_refs,
&format!(
"chore(release): bump {}{}",
bump_summary,
skip_ci_suffix(cfg.skip_ci_on_bump)
),
)?;
let mut created: Vec<&str> = Vec::new();
for group_result in &tag_results {
for (tag, message) in &group_result.new_tags {
if created.contains(&tag.as_str()) {
continue;
}
git::create_tag_local_only(&cwd, tag, message, false, log)?;
created.push(tag.as_str());
}
}
} else {
for rewrite in &vf_plan {
rewrite_and_stage_version_files(
&workspace_root,
std::slice::from_ref(&rewrite.file),
&rewrite.old,
&rewrite.new,
true,
log,
)?;
}
render_and_stage_changelogs(&cwd, &changelog_targets, &changelog_routing, true, log)?;
}
let crates_json =
serde_json::to_string(&all_tagged_crates).unwrap_or_else(|_| "[]".to_string());
let versions_map: std::collections::HashMap<String, String> = tag_results
.iter()
.flat_map(|r| {
r.crate_names
.iter()
.zip(r.version_updates.iter())
.map(|(name, (_, ver))| (name.clone(), ver.clone()))
})
.collect();
let versions_json = serde_json::to_string(&versions_map).unwrap_or_else(|_| "{}".to_string());
let push_dry = opts.dry_run || opts.push_dry_run;
let push_branch = resolve_tag_push_branch(opts, controls.config_push, true)?;
git::push_branch_and_tags_atomic_in(
&cwd,
&git::AtomicPushSpec {
remote: controls.remote,
branch: push_branch.as_deref(),
tags: &all_new_tags,
dry_run: push_dry,
strict: opts.strict,
},
log,
)?;
println!("anodizer-output crates={}", crates_json);
println!("anodizer-output versions={}", versions_json);
Ok(())
}
struct CrateTagInfo {
tag_prefix: String,
path: String,
version_sync: bool,
version_files: Vec<String>,
}
fn load_crate_tag_info(opts: &TagOpts, root: &Path, crate_name: &str) -> Option<CrateTagInfo> {
let config = load_config_at(opts, root)?;
let crate_cfg = config
.crates
.iter()
.find(|c| c.name == crate_name)
.or_else(|| {
config
.workspaces
.as_deref()
.unwrap_or_default()
.iter()
.flat_map(|w| &w.crates)
.find(|c| c.name == crate_name)
})?;
let tag_prefix = git::extract_tag_prefix(&crate_cfg.tag_template)?;
let version_sync = crate_cfg
.version_sync
.as_ref()
.and_then(|vs| vs.enabled)
.unwrap_or(false);
let version_files = resolve_version_files(Some(crate_cfg), Some(&config));
Some(CrateTagInfo {
tag_prefix,
path: crate_cfg.path.clone(),
version_sync,
version_files,
})
}
fn find_previous_tag(
cfg: &ResolvedConfig,
git_config: Option<&GitConfig>,
) -> Result<Option<String>> {
let tags = match cfg.tag_context.as_str() {
"branch" => git::get_branch_semver_tags(&cfg.tag_prefix, git_config, None)?,
_ => git::get_all_semver_tags(&cfg.tag_prefix, git_config, None)?,
};
let tag_sort = git_config
.and_then(|gc| gc.tag_sort.as_deref())
.unwrap_or("-version:refname");
if tag_sort == "smartsemver" && !cfg.prerelease {
for tag in tags {
if let Ok(sv) = git::parse_semver_tag(&tag)
&& !sv.is_prerelease()
{
return Ok(Some(tag));
}
}
return Ok(None);
}
Ok(tags.into_iter().next())
}
fn branch_matches(branch: &str, patterns: &[String]) -> bool {
for pattern in patterns {
if branch == pattern {
return true;
}
if let Ok(re) = Regex::new(&format!("^{}$", pattern))
&& re.is_match(branch)
{
return true;
}
}
false
}
fn get_messages_for_bump(
workspace_root: &Path,
cfg: &ResolvedConfig,
prev_tag: Option<&str>,
path: Option<&str>,
) -> Result<Vec<String>> {
match cfg.branch_history.as_str() {
"last" => match path {
Some(p) => git::get_last_commit_messages_path_in(workspace_root, 1, p),
None => git::get_last_commit_messages_in(workspace_root, 1),
},
"full" | "compare" => match (prev_tag, path) {
(Some(tag), Some(p)) => {
git::get_commit_messages_between_path_in(workspace_root, tag, "HEAD", p)
}
(Some(tag), None) => git::get_commit_messages_between_in(workspace_root, tag, "HEAD"),
(None, Some(p)) => git::get_last_commit_messages_path_in(workspace_root, 500, p),
(None, None) => git::get_last_commit_messages_in(workspace_root, 500),
},
other => {
bail!("unknown branch_history mode: {}", other);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BumpKind {
Major,
Minor,
Patch,
None,
}
fn detect_bump(messages: &[String], cfg: &ResolvedConfig) -> BumpKind {
detect_bump_from_tokens(
messages,
&cfg.major_string_token,
&cfg.minor_string_token,
&cfg.patch_string_token,
&cfg.none_string_token,
&cfg.default_bump,
)
}
fn detect_bump_demoted(
messages: &[String],
cfg: &ResolvedConfig,
prev_tag: Option<&str>,
) -> BumpKind {
let bump = detect_bump(messages, cfg);
if has_explicit_bump_token(messages, cfg) {
return bump;
}
let base_major = prev_tag
.and_then(|t| git::parse_semver_tag(t).ok())
.map_or(0, |sv| sv.major);
demote_pre_major(
bump,
base_major,
cfg.bump_minor_pre_major,
cfg.bump_patch_for_minor_pre_major,
)
}
fn message_has_token(msg: &str, token: &str) -> bool {
msg.split(|c: char| c.is_whitespace()).any(|w| w == token)
}
fn has_explicit_bump_token(messages: &[String], cfg: &ResolvedConfig) -> bool {
let has = |token: &str| messages.iter().any(|m| message_has_token(m, token));
has(&cfg.major_string_token) || has(&cfg.minor_string_token) || has(&cfg.patch_string_token)
}
fn demote_pre_major(
bump: BumpKind,
base_major: u64,
bump_minor_pre_major: bool,
bump_patch_for_minor_pre_major: bool,
) -> BumpKind {
if base_major != 0 {
return bump;
}
match bump {
BumpKind::Major if bump_minor_pre_major => BumpKind::Minor,
BumpKind::Minor if bump_patch_for_minor_pre_major => BumpKind::Patch,
other => other,
}
}
fn detect_bump_from_tokens(
messages: &[String],
major_token: &str,
minor_token: &str,
patch_token: &str,
none_token: &str,
default_bump: &str,
) -> BumpKind {
let mut has_major = false;
let mut has_minor = false;
let mut has_patch = false;
let mut has_none = false;
for msg in messages {
if message_has_token(msg, none_token) {
has_none = true;
}
if message_has_token(msg, major_token) {
has_major = true;
}
if message_has_token(msg, minor_token) {
has_minor = true;
}
if message_has_token(msg, patch_token) {
has_patch = true;
}
}
if has_major {
return BumpKind::Major;
}
if has_minor {
return BumpKind::Minor;
}
if has_patch {
return BumpKind::Patch;
}
if let Some(bump) = detect_conventional_bump(messages) {
return bump;
}
if has_none {
return BumpKind::None;
}
match default_bump {
"major" => BumpKind::Major,
"minor" => BumpKind::Minor,
"patch" => BumpKind::Patch,
"none" | "false" => BumpKind::None,
_ => BumpKind::None,
}
}
fn detect_conventional_bump(messages: &[String]) -> Option<BumpKind> {
let mut has_breaking = false;
let mut has_feat = false;
let mut has_fix_or_perf = false;
for msg in messages {
if msg.contains("BREAKING CHANGE") || msg.contains("BREAKING-CHANGE") {
has_breaking = true;
}
let subject = msg.lines().next().unwrap_or("").trim_start();
let (ty, rest) = match subject.split_once(':') {
Some(pair) => pair,
None => continue,
};
let (head, marker) = ty.split_once('(').map_or((ty, ""), |(h, scope_rest)| {
let after_scope = scope_rest.split_once(')').map_or("", |x| x.1);
(h, after_scope)
});
let is_breaking_shorthand = marker.starts_with('!') || ty.ends_with('!');
let _ = rest;
if is_breaking_shorthand {
has_breaking = true;
}
match head.trim() {
"feat" => has_feat = true,
"fix" | "perf" | "revert" => has_fix_or_perf = true,
_ => {}
}
}
if has_breaking {
Some(BumpKind::Major)
} else if has_feat {
Some(BumpKind::Minor)
} else if has_fix_or_perf {
Some(BumpKind::Patch)
} else {
None
}
}
fn bare_version_from_tag(tag: &str) -> Option<String> {
if tag.is_empty() {
return None;
}
let sv = git::parse_semver_tag(tag).ok()?;
let mut v = format!("{}.{}.{}", sv.major, sv.minor, sv.patch);
if let Some(pre) = sv.prerelease {
v.push('-');
v.push_str(&pre);
}
Some(v)
}
fn rewrite_and_stage_version_files(
root: &Path,
files: &[String],
old: &str,
new: &str,
dry_run: bool,
log: &StageLogger,
) -> Result<Vec<String>> {
if files.is_empty() || old == new {
return Ok(Vec::new());
}
let resolved: Vec<String> = files
.iter()
.map(|f| root.join(f).to_string_lossy().into_owned())
.collect();
let outcomes =
anodizer_core::version_files::rewrite_version_in_files(&resolved, old, new, dry_run)?;
let mut changed = Vec::new();
for (outcome, rel) in outcomes.iter().zip(files.iter()) {
if outcome.replacements > 0 {
log.status(&format!(
"{}version_files: rewrote {} occurrence(s) of {} → {} in {}",
if dry_run { "(dry-run) " } else { "" },
outcome.replacements,
old,
new,
rel
));
if !dry_run {
changed.push(rel.clone());
}
} else {
log.warn(&format!(
"version_files: enrolled file {} did not contain version {} (nothing rewritten)",
rel, old
));
}
}
Ok(changed)
}
#[derive(Debug, PartialEq)]
struct VersionFileRewrite {
file: String,
old: String,
new: String,
}
fn plan_version_files_rewrites(tag_results: &[GroupTagResult]) -> Result<Vec<VersionFileRewrite>> {
use std::collections::HashMap;
let mut seen: HashMap<String, (String, String, String)> = HashMap::new();
let mut plan: Vec<VersionFileRewrite> = Vec::new();
for group_result in tag_results {
let Some(ref old) = group_result.old_version else {
continue;
};
let owner = group_result
.crate_names
.first()
.cloned()
.unwrap_or_else(|| "?".to_string());
for ((_, new_version), files) in group_result
.version_updates
.iter()
.zip(group_result.crate_version_files.iter())
{
for file in files {
match seen.get(file) {
Some((existing_old, existing_new, existing_crate))
if existing_old != old || existing_new != new_version =>
{
bail!(
"version_files conflict: {} is enrolled by crates with different \
version bumps ({} {} → {} vs {} {} → {}); a file cannot hold two \
versions in one tag run",
file,
existing_crate,
existing_old,
existing_new,
owner,
old,
new_version,
);
}
Some(_) => {}
None => {
seen.insert(
file.clone(),
(old.clone(), new_version.clone(), owner.clone()),
);
plan.push(VersionFileRewrite {
file: file.clone(),
old: old.clone(),
new: new_version.clone(),
});
}
}
}
}
}
Ok(plan)
}
fn plan_changelog_targets(
workspace_root: &Path,
tag_results: &[GroupTagResult],
) -> Vec<ChangelogTarget> {
let mut targets = Vec::new();
for group_result in tag_results {
for ((crate_name, (crate_path, new_version)), (full_tag, _msg)) in group_result
.crate_names
.iter()
.zip(group_result.version_updates.iter())
.zip(group_result.new_tags.iter())
{
targets.push(ChangelogTarget {
crate_name: crate_name.clone(),
crate_dir: workspace_root.join(crate_path),
from_tag: group_result.prev_tag.clone(),
to_version: new_version.clone(),
full_tag: full_tag.clone(),
});
}
}
targets
}
fn collapse_targets_to_flat_aggregate(
targets: &mut Vec<ChangelogTarget>,
workspace_root: &Path,
config: Option<&anodizer_core::config::Config>,
collapse: bool,
) -> bool {
if !collapse || targets.len() <= 1 {
return false;
}
let Some(config) = config else {
return false;
};
let project_name = config.project_name.clone();
let first = match targets.first() {
Some(t) => t,
None => return false,
};
let aggregate = ChangelogTarget {
crate_name: project_name,
crate_dir: workspace_root.to_path_buf(),
from_tag: first.from_tag.clone(),
to_version: first.to_version.clone(),
full_tag: first.full_tag.clone(),
};
*targets = vec![aggregate];
true
}
pub(crate) fn apply_bump(major: u64, minor: u64, patch: u64, bump: &BumpKind) -> (u64, u64, u64) {
match bump {
BumpKind::Major => (major + 1, 0, 0),
BumpKind::Minor => (major, minor + 1, 0),
BumpKind::Patch => (major, minor, patch + 1),
BumpKind::None => (major, minor, patch),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn push_opts(push: bool, no_push: bool) -> TagOpts {
TagOpts {
dry_run: false,
custom_tag: None,
default_bump: None,
crate_name: None,
push,
no_push,
push_remote: None,
push_dry_run: false,
changelog: false,
config_override: None,
verbose: false,
debug: false,
quiet: false,
strict: false,
}
}
#[test]
fn resolve_effective_push_matrix() {
let cases: &[(bool, bool, Option<bool>, bool, bool)] = &[
(false, true, Some(true), true, false),
(true, true, Some(true), true, false), (false, true, None, true, false),
(true, false, None, false, true),
(false, false, Some(true), false, true),
(false, false, Some(false), false, false),
(false, false, Some(false), true, true),
(false, false, None, false, false),
(false, false, None, true, true),
];
for &(push, no_push, config_push, path_default, expected) in cases {
let opts = push_opts(push, no_push);
assert_eq!(
resolve_effective_push(&opts, config_push, path_default),
expected,
"push={push} no_push={no_push} config_push={config_push:?} path_default={path_default}"
);
}
}
#[test]
fn test_detect_bump_major_takes_precedence() {
let messages = vec![
"fix: something #patch".to_string(),
"feat: big change #major".to_string(),
"feat: small change #minor".to_string(),
];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "minor");
assert_eq!(result, BumpKind::Major);
}
#[test]
fn test_detect_bump_minor_over_patch() {
let messages = vec![
"fix: something #patch".to_string(),
"feat: new feature #minor".to_string(),
];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "patch");
assert_eq!(result, BumpKind::Minor);
}
#[test]
fn test_detect_bump_patch_only() {
let messages = vec!["fix: a bug #patch".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "minor");
assert_eq!(result, BumpKind::Patch);
}
#[test]
fn test_detect_bump_none_token_loses_to_explicit_major() {
let messages = vec![
"chore: update deps #none".to_string(),
"feat: something #major".to_string(),
];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "minor");
assert_eq!(result, BumpKind::Major);
}
#[test]
fn test_detect_bump_none_suppresses_default_fallback() {
let messages = vec!["chore: prep #none".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "minor");
assert_eq!(result, BumpKind::None);
}
#[test]
fn test_detect_bump_none_loses_to_conventional_fix() {
let messages = vec![
"fix: deref bug".to_string(),
"chore: revert local-only churn #none".to_string(),
];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::Patch);
}
#[test]
fn test_detect_bump_default_when_no_tokens() {
let messages = vec![
"unstructured message".to_string(),
"docs: update readme".to_string(),
];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "minor");
assert_eq!(result, BumpKind::Minor);
}
#[test]
fn test_detect_bump_default_patch() {
let messages = vec!["chore: deps bump".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "patch");
assert_eq!(result, BumpKind::Patch);
}
#[test]
fn test_detect_bump_default_major() {
let messages = vec!["chore: deps bump".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "major");
assert_eq!(result, BumpKind::Major);
}
#[test]
fn test_detect_bump_default_none() {
let messages = vec!["chore: deps bump".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::None);
}
#[test]
fn test_conventional_fix_triggers_patch() {
let messages = vec!["fix: null deref in parser".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::Patch);
}
#[test]
fn test_conventional_feat_triggers_minor() {
let messages = vec!["feat(api): add pagination".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::Minor);
}
#[test]
fn test_conventional_perf_triggers_patch() {
let messages = vec!["perf: skip redundant clone".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::Patch);
}
#[test]
fn test_conventional_breaking_change_footer_triggers_major() {
let messages = vec![
"feat: rename flags\n\nBREAKING CHANGE: --dry replaced with --dry-run".to_string(),
];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::Major);
}
#[test]
fn test_conventional_breaking_shorthand_triggers_major() {
let messages = vec!["feat!: rewrite config layer".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::Major);
}
#[test]
fn test_conventional_scoped_breaking_shorthand_triggers_major() {
let messages = vec!["fix(config)!: rename layer field".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::Major);
}
#[test]
fn test_conventional_chore_only_range_noops_with_none_default() {
let messages = vec![
"chore: bump dep".to_string(),
"test: new harness".to_string(),
"refactor: cleaner helper".to_string(),
];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::None);
}
#[test]
fn test_conventional_ignored_when_explicit_token_present() {
let messages = vec!["feat: add thing\n\n#major".to_string()];
let result =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "none");
assert_eq!(result, BumpKind::Major);
}
#[test]
fn test_detect_bump_empty_messages_uses_default() {
let result = detect_bump_from_tokens(&[], "#major", "#minor", "#patch", "#none", "patch");
assert_eq!(result, BumpKind::Patch);
}
#[test]
fn test_detect_bump_custom_tokens() {
let messages = vec!["BREAKING CHANGE: rewrite".to_string()];
let result = detect_bump_from_tokens(
&messages,
"BREAKING CHANGE",
"feat:",
"fix:",
"skip:",
"patch",
);
assert_eq!(result, BumpKind::Major);
}
#[test]
fn test_apply_bump_major() {
assert_eq!(apply_bump(1, 2, 3, &BumpKind::Major), (2, 0, 0));
}
#[test]
fn test_apply_bump_minor() {
assert_eq!(apply_bump(1, 2, 3, &BumpKind::Minor), (1, 3, 0));
}
#[test]
fn test_apply_bump_patch() {
assert_eq!(apply_bump(1, 2, 3, &BumpKind::Patch), (1, 2, 4));
}
#[test]
fn test_apply_bump_none() {
assert_eq!(apply_bump(1, 2, 3, &BumpKind::None), (1, 2, 3));
}
#[test]
fn test_apply_bump_from_zero() {
assert_eq!(apply_bump(0, 0, 0, &BumpKind::Patch), (0, 0, 1));
assert_eq!(apply_bump(0, 0, 0, &BumpKind::Minor), (0, 1, 0));
assert_eq!(apply_bump(0, 0, 0, &BumpKind::Major), (1, 0, 0));
}
#[test]
fn demote_pre_major_axes() {
assert_eq!(
demote_pre_major(BumpKind::Major, 0, true, false),
BumpKind::Minor
);
assert_eq!(
demote_pre_major(BumpKind::Major, 0, false, false),
BumpKind::Major
);
assert_eq!(
demote_pre_major(BumpKind::Minor, 0, false, true),
BumpKind::Patch
);
assert_eq!(
demote_pre_major(BumpKind::Minor, 0, false, false),
BumpKind::Minor
);
assert_eq!(
demote_pre_major(BumpKind::Major, 0, true, true),
BumpKind::Minor
);
assert_eq!(
demote_pre_major(BumpKind::Minor, 0, true, true),
BumpKind::Patch
);
assert_eq!(
demote_pre_major(BumpKind::Patch, 0, true, true),
BumpKind::Patch
);
assert_eq!(
demote_pre_major(BumpKind::None, 0, true, true),
BumpKind::None
);
}
#[test]
fn demote_pre_major_inert_at_one() {
assert_eq!(
demote_pre_major(BumpKind::Major, 1, true, true),
BumpKind::Major
);
assert_eq!(
demote_pre_major(BumpKind::Minor, 1, true, true),
BumpKind::Minor
);
assert_eq!(
demote_pre_major(BumpKind::Major, 2, true, false),
BumpKind::Major
);
}
fn cfg_with_pre_major(minor_pre_major: bool, patch_for_minor: bool) -> ResolvedConfig {
let tag_cfg = TagConfig {
bump_minor_pre_major: Some(minor_pre_major),
bump_patch_for_minor_pre_major: Some(patch_for_minor),
..Default::default()
};
ResolvedConfig::from_tag_config(&tag_cfg, &push_opts(false, false))
}
#[test]
fn has_explicit_bump_token_whole_word_only() {
let cfg = cfg_with_pre_major(true, false);
assert!(has_explicit_bump_token(
&["chore: x #minor".to_string()],
&cfg
));
assert!(has_explicit_bump_token(
&["release #major".to_string()],
&cfg
));
assert!(!has_explicit_bump_token(
&["feat!: break".to_string()],
&cfg
));
assert!(!has_explicit_bump_token(
&["fix #minorbug".to_string()],
&cfg
));
}
#[test]
fn detect_bump_demoted_precedence() {
assert_eq!(
detect_bump_demoted(
&["feat!: break".to_string()],
&cfg_with_pre_major(true, false),
Some("v0.5.0")
),
BumpKind::Minor
);
assert_eq!(
detect_bump_demoted(
&["feat!: break".to_string()],
&cfg_with_pre_major(false, false),
Some("v0.5.0")
),
BumpKind::Major
);
assert_eq!(
detect_bump_demoted(
&["feat!: break".to_string(), "stabilize #major".to_string()],
&cfg_with_pre_major(true, false),
Some("v0.5.0"),
),
BumpKind::Major
);
assert_eq!(
detect_bump_demoted(
&["feat!: break".to_string()],
&cfg_with_pre_major(true, false),
Some("v1.2.0")
),
BumpKind::Major
);
assert_eq!(
detect_bump_demoted(
&["feat!: break".to_string()],
&cfg_with_pre_major(true, false),
None
),
BumpKind::Minor
);
assert_eq!(
detect_bump_demoted(
&["feat: thing".to_string()],
&cfg_with_pre_major(false, true),
Some("v0.5.0")
),
BumpKind::Patch
);
}
#[test]
fn detect_bump_demoted_none_token_does_not_block_demotion() {
assert_eq!(
detect_bump_demoted(
&["feat!: break #none".to_string()],
&cfg_with_pre_major(true, false),
Some("v0.5.0")
),
BumpKind::Minor
);
assert_eq!(
detect_bump_demoted(
&["chore: housekeeping #none".to_string()],
&cfg_with_pre_major(true, false),
Some("v0.5.0")
),
BumpKind::None
);
}
#[test]
fn detect_bump_demoted_honors_custom_tokens() {
let tag_cfg = TagConfig {
major_string_token: Some("#breaking".to_string()),
bump_minor_pre_major: Some(true),
..Default::default()
};
let cfg = ResolvedConfig::from_tag_config(&tag_cfg, &push_opts(false, false));
assert_eq!(
detect_bump_demoted(&["rework #breaking".to_string()], &cfg, Some("v0.5.0")),
BumpKind::Major
);
assert_eq!(
detect_bump_demoted(&["feat!: rework".to_string()], &cfg, Some("v0.5.0")),
BumpKind::Minor
);
}
#[test]
fn test_branch_matches_exact() {
assert!(branch_matches("main", &["main".to_string()]));
assert!(branch_matches("master", &["master".to_string()]));
}
#[test]
fn test_branch_matches_regex() {
assert!(branch_matches("release/1.0", &["release/.*".to_string()]));
}
#[test]
fn test_branch_no_match() {
assert!(!branch_matches(
"feature/foo",
&["main".to_string(), "master".to_string()]
));
}
#[test]
fn test_branch_matches_empty_patterns() {
assert!(!branch_matches("main", &[]));
}
#[test]
fn test_prerelease_suffix_application() {
let version = "1.2.0";
let suffix = "beta";
let result = format!("{}-{}", version, suffix);
assert_eq!(result, "1.2.0-beta");
}
#[test]
fn test_prerelease_suffix_custom() {
let version = "2.0.0";
let suffix = "rc.1";
let result = format!("{}-{}", version, suffix);
assert_eq!(result, "2.0.0-rc.1");
}
#[test]
fn test_custom_tag_with_prefix() {
let custom = "v5.0.0";
let prefix = "v";
let tag = if custom.starts_with(prefix) {
custom.to_string()
} else {
format!("{}{}", prefix, custom)
};
assert_eq!(tag, "v5.0.0");
}
#[test]
fn test_custom_tag_without_prefix() {
let custom = "5.0.0";
let prefix = "v";
let tag = if custom.starts_with(prefix) {
custom.to_string()
} else {
format!("{}{}", prefix, custom)
};
assert_eq!(tag, "v5.0.0");
}
#[test]
fn test_resolved_config_defaults() {
let cfg = TagConfig::default();
let opts = TagOpts {
dry_run: false,
custom_tag: None,
default_bump: None,
crate_name: None,
push: false,
no_push: false,
push_remote: None,
push_dry_run: false,
changelog: false,
config_override: None,
verbose: false,
debug: false,
quiet: false,
strict: false,
};
let resolved = ResolvedConfig::from_tag_config(&cfg, &opts);
assert_eq!(resolved.default_bump, "none");
assert_eq!(resolved.tag_prefix, "v");
assert_eq!(resolved.tag_context, "repo");
assert_eq!(resolved.branch_history, "compare");
assert_eq!(resolved.initial_version, "0.0.0");
assert!(!resolved.prerelease);
assert_eq!(resolved.prerelease_suffix, "beta");
assert!(!resolved.force_without_changes);
assert!(!resolved.force_without_changes_pre);
assert_eq!(resolved.major_string_token, "#major");
assert_eq!(resolved.minor_string_token, "#minor");
assert_eq!(resolved.patch_string_token, "#patch");
assert_eq!(resolved.none_string_token, "#none");
}
#[test]
fn test_resolved_config_cli_overrides() {
let cfg = TagConfig {
default_bump: Some("minor".to_string()),
..Default::default()
};
let opts = TagOpts {
dry_run: false,
custom_tag: Some("v9.9.9".to_string()),
default_bump: Some("major".to_string()),
crate_name: None,
push: false,
no_push: false,
push_remote: None,
push_dry_run: false,
changelog: false,
config_override: None,
verbose: false,
debug: false,
quiet: false,
strict: false,
};
let resolved = ResolvedConfig::from_tag_config(&cfg, &opts);
assert_eq!(resolved.default_bump, "major");
assert_eq!(resolved.custom_tag, Some("v9.9.9".to_string()));
}
#[test]
fn test_resolved_config_full_config() {
let cfg = TagConfig {
default_bump: Some("patch".to_string()),
bump_minor_pre_major: None,
bump_patch_for_minor_pre_major: None,
tag_prefix: Some("release-v".to_string()),
release_branches: Some(vec!["main".to_string(), "release/.*".to_string()]),
custom_tag: None,
tag_context: Some("branch".to_string()),
branch_history: Some("last".to_string()),
initial_version: Some("1.0.0".to_string()),
prerelease: Some(true),
prerelease_suffix: Some("alpha".to_string()),
force_without_changes: Some(true),
force_without_changes_pre: Some(true),
major_string_token: Some("BREAKING".to_string()),
minor_string_token: Some("feat:".to_string()),
patch_string_token: Some("fix:".to_string()),
none_string_token: Some("skip".to_string()),
git_api_tagging: Some(false),
push: None,
skip_ci_on_bump: None,
verbose: Some(false),
tag_pre_hooks: None,
tag_post_hooks: None,
};
let opts = TagOpts {
dry_run: false,
custom_tag: None,
default_bump: None,
crate_name: None,
push: false,
no_push: false,
push_remote: None,
push_dry_run: false,
changelog: false,
config_override: None,
verbose: false,
debug: false,
quiet: false,
strict: false,
};
let resolved = ResolvedConfig::from_tag_config(&cfg, &opts);
assert_eq!(resolved.default_bump, "patch");
assert_eq!(resolved.tag_prefix, "release-v");
assert_eq!(resolved.release_branches.len(), 2);
assert_eq!(resolved.tag_context, "branch");
assert_eq!(resolved.branch_history, "last");
assert_eq!(resolved.initial_version, "1.0.0");
assert!(resolved.prerelease);
assert_eq!(resolved.prerelease_suffix, "alpha");
assert!(resolved.force_without_changes);
assert!(resolved.force_without_changes_pre);
assert_eq!(resolved.major_string_token, "BREAKING");
assert_eq!(resolved.minor_string_token, "feat:");
assert_eq!(resolved.patch_string_token, "fix:");
assert_eq!(resolved.none_string_token, "skip");
}
#[test]
fn test_tag_config_from_yaml_full() {
let yaml = r##"
default_bump: patch
tag_prefix: "v"
release_branches:
- main
- "release/.*"
tag_context: branch
branch_history: last
initial_version: "1.0.0"
prerelease: true
prerelease_suffix: rc
force_without_changes: true
force_without_changes_pre: false
major_string_token: "#major"
minor_string_token: "#minor"
patch_string_token: "#patch"
none_string_token: "#none"
git_api_tagging: true
verbose: false
"##;
let cfg: TagConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.default_bump, Some("patch".to_string()));
assert_eq!(cfg.tag_prefix, Some("v".to_string()));
assert_eq!(
cfg.release_branches,
Some(vec!["main".to_string(), "release/.*".to_string()])
);
assert_eq!(cfg.tag_context, Some("branch".to_string()));
assert_eq!(cfg.branch_history, Some("last".to_string()));
assert_eq!(cfg.initial_version, Some("1.0.0".to_string()));
assert_eq!(cfg.prerelease, Some(true));
assert_eq!(cfg.prerelease_suffix, Some("rc".to_string()));
assert_eq!(cfg.force_without_changes, Some(true));
assert_eq!(cfg.force_without_changes_pre, Some(false));
assert_eq!(cfg.git_api_tagging, Some(true));
assert_eq!(cfg.verbose, Some(false));
}
#[test]
fn test_tag_config_from_yaml_minimal() {
let yaml = "{}";
let cfg: TagConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.default_bump, None);
assert_eq!(cfg.tag_prefix, None);
assert_eq!(cfg.release_branches, None);
}
#[test]
fn test_tag_config_from_yaml_defaults() {
let yaml = "default_bump: major";
let cfg: TagConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.default_bump, Some("major".to_string()));
assert_eq!(cfg.tag_prefix, None); }
#[test]
fn test_top_level_config_with_tag_section() {
let yaml = r#"
project_name: myproject
crates:
- name: myproject
path: "."
tag_template: "v{{ .Version }}"
tag:
default_bump: patch
tag_prefix: "v"
branch_history: last
"#;
let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
let tag = config.tag.unwrap();
assert_eq!(tag.default_bump, Some("patch".to_string()));
assert_eq!(tag.branch_history, Some("last".to_string()));
}
#[test]
fn test_tag_pre_post_hooks_yaml_roundtrip() {
let yaml = r#"
tag_pre_hooks:
- "cargo update --workspace"
- cmd: "scripts/pre-tag.sh {{ .Tag }}"
dir: "."
tag_post_hooks:
- "git push --follow-tags"
"#;
let cfg: TagConfig = serde_yaml_ng::from_str(yaml).unwrap();
let pre = cfg.tag_pre_hooks.as_ref().unwrap();
assert_eq!(pre.len(), 2);
assert!(matches!(
pre[0],
anodizer_core::config::HookEntry::Simple(ref s) if s == "cargo update --workspace"
));
let post = cfg.tag_post_hooks.as_ref().unwrap();
assert_eq!(post.len(), 1);
assert!(matches!(
post[0],
anodizer_core::config::HookEntry::Simple(ref s) if s == "git push --follow-tags"
));
}
#[test]
fn test_tag_hooks_default_none() {
let cfg: TagConfig = serde_yaml_ng::from_str("default_bump: minor").unwrap();
assert!(cfg.tag_pre_hooks.is_none());
assert!(cfg.tag_post_hooks.is_none());
}
#[test]
fn test_full_bump_flow_major() {
let messages = vec!["feat: breaking change #major".to_string()];
let bump =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "patch");
assert_eq!(bump, BumpKind::Major);
let (maj, min, pat) = apply_bump(1, 5, 3, &bump);
assert_eq!((maj, min, pat), (2, 0, 0));
let new_tag = format!("v{}.{}.{}", maj, min, pat);
assert_eq!(new_tag, "v2.0.0");
}
#[test]
fn test_full_bump_flow_minor_default() {
let messages = vec!["docs: update readme".to_string()];
let bump =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "minor");
assert_eq!(bump, BumpKind::Minor);
let (maj, min, pat) = apply_bump(1, 2, 3, &bump);
assert_eq!((maj, min, pat), (1, 3, 0));
}
#[test]
fn test_full_bump_flow_prerelease() {
let messages = vec!["feat: new thing #minor".to_string()];
let bump =
detect_bump_from_tokens(&messages, "#major", "#minor", "#patch", "#none", "patch");
assert_eq!(bump, BumpKind::Minor);
let (maj, min, pat) = apply_bump(1, 2, 3, &bump);
let version = format!("{}.{}.{}-beta", maj, min, pat);
assert_eq!(version, "1.3.0-beta");
}
fn crate_cfg(name: &str, path: &str, template: &str) -> CrateConfig {
CrateConfig {
name: name.to_string(),
path: path.to_string(),
tag_template: template.to_string(),
..Default::default()
}
}
fn empty_root() -> tempfile::TempDir {
tempfile::tempdir().expect("create temp workspace root")
}
#[test]
fn detect_repo_shape_no_config_no_workspace_returns_single() {
let root = empty_root();
let shape = detect_repo_shape(root.path(), None, None);
assert!(matches!(shape, RepoShape::Single));
}
#[test]
fn detect_repo_shape_single_crate_config_returns_single() {
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
crates: vec![crate_cfg("app", ".", "v{{ .Version }}")],
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
assert!(matches!(shape, RepoShape::Single));
}
#[test]
fn detect_repo_shape_lockstep_workspace_wins_over_per_crate_config() {
let config = anodizer_core::config::Config {
project_name: "ws".to_string(),
crates: vec![
crate_cfg("a", "crates/a", "a-v{{ .Version }}"),
crate_cfg("b", "crates/b", "b-v{{ .Version }}"),
],
..Default::default()
};
let ws = WorkspaceInfo {
workspace_package_version: Some("0.1.0".to_string()),
members: vec![],
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), Some(&ws));
assert!(matches!(shape, RepoShape::Lockstep));
}
#[test]
fn detect_repo_shape_flat_multi_crate_returns_per_crate() {
let config = anodizer_core::config::Config {
project_name: "ws".to_string(),
crates: vec![
crate_cfg("core", "crates/core", "core-v{{ .Version }}"),
crate_cfg("cli", "crates/cli", "v{{ .Version }}"),
],
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
match shape {
RepoShape::PerCrate(groups) => {
assert_eq!(groups.len(), 2);
assert_eq!(groups[0][0].name, "core");
assert_eq!(groups[1][0].name, "cli");
}
other => panic!(
"expected PerCrate, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn detect_repo_shape_hybrid_workspaces_returns_per_crate_groups() {
let ws1 = anodizer_core::config::WorkspaceConfig {
name: "group-a".to_string(),
crates: vec![crate_cfg("core", "crates/core", "core-v{{ .Version }}")],
..Default::default()
};
let ws2 = anodizer_core::config::WorkspaceConfig {
name: "group-b".to_string(),
crates: vec![
crate_cfg("bin-a", "crates/bin-a", "bin-a-v{{ .Version }}"),
crate_cfg("bin-b", "crates/bin-b", "bin-b-v{{ .Version }}"),
],
..Default::default()
};
let config = anodizer_core::config::Config {
project_name: "myproj".to_string(),
workspaces: Some(vec![ws1, ws2]),
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
match shape {
RepoShape::PerCrate(groups) => {
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].len(), 1);
assert_eq!(groups[0][0].name, "core");
assert_eq!(groups[1].len(), 2);
assert_eq!(groups[1][0].name, "bin-a");
assert_eq!(groups[1][1].name, "bin-b");
}
other => panic!(
"expected PerCrate, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn detect_repo_shape_workspaces_block_wins_over_workspace_package_version() {
let ws1 = anodizer_core::config::WorkspaceConfig {
name: "group".to_string(),
crates: vec![
crate_cfg("a", "crates/a", "a-v{{ .Version }}"),
crate_cfg("b", "crates/b", "b-v{{ .Version }}"),
],
..Default::default()
};
let config = anodizer_core::config::Config {
project_name: "p".to_string(),
workspaces: Some(vec![ws1]),
..Default::default()
};
let ws = WorkspaceInfo {
workspace_package_version: Some("0.2.0".to_string()),
members: vec![],
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), Some(&ws));
match shape {
RepoShape::PerCrate(groups) => {
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
}
other => panic!(
"expected PerCrate (workspaces: declaration wins over [workspace.package].version), got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn detect_repo_shape_single_flat_crate_returns_single() {
let config = anodizer_core::config::Config {
project_name: "solo".to_string(),
crates: vec![crate_cfg("solo", ".", "v{{ .Version }}")],
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
assert!(matches!(shape, RepoShape::Single));
}
fn ws_no_lockstep() -> WorkspaceInfo {
WorkspaceInfo {
workspace_package_version: None,
members: vec![],
}
}
#[test]
fn detect_repo_shape_same_prefix_flat_crates_returns_flat_aggregate() {
let config = anodizer_core::config::Config {
project_name: "ws".to_string(),
crates: vec![
crate_cfg("core", "crates/core", "v{{ .Version }}"),
crate_cfg("cli", "crates/cli", "v{{ .Version }}"),
],
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), Some(&ws_no_lockstep()));
match shape {
RepoShape::FlatAggregate(crates) => {
assert_eq!(crates.len(), 2, "carries the flat crate list");
assert_eq!(crates[0].name, "core");
assert_eq!(crates[1].name, "cli");
}
other => panic!(
"same-prefix flat crates must classify as FlatAggregate, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn detect_repo_shape_distinct_prefix_flat_crates_returns_per_crate() {
let config = anodizer_core::config::Config {
project_name: "ws".to_string(),
crates: vec![
crate_cfg("core", "crates/core", "core-v{{ .Version }}"),
crate_cfg("cli", "crates/cli", "v{{ .Version }}"),
],
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), Some(&ws_no_lockstep()));
match shape {
RepoShape::PerCrate(groups) => assert_eq!(groups.len(), 2),
other => panic!(
"expected PerCrate for distinct prefixes, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn detect_repo_shape_no_tag_template_flat_crates_returns_per_crate() {
let config = anodizer_core::config::Config {
project_name: "ws".to_string(),
crates: vec![
crate_cfg("core", "crates/core", ""),
crate_cfg("cli", "crates/cli", ""),
],
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), Some(&ws_no_lockstep()));
match shape {
RepoShape::PerCrate(groups) => assert_eq!(groups.len(), 2),
other => panic!(
"expected PerCrate when no tag_template yields a shared prefix, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn detect_repo_shape_explicit_workspaces_shared_prefix_still_per_crate() {
let ws1 = anodizer_core::config::WorkspaceConfig {
name: "group-a".to_string(),
crates: vec![crate_cfg("a", "crates/a", "v{{ .Version }}")],
..Default::default()
};
let ws2 = anodizer_core::config::WorkspaceConfig {
name: "group-b".to_string(),
crates: vec![crate_cfg("b", "crates/b", "v{{ .Version }}")],
..Default::default()
};
let config = anodizer_core::config::Config {
project_name: "p".to_string(),
workspaces: Some(vec![ws1, ws2]),
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), Some(&ws_no_lockstep()));
match shape {
RepoShape::PerCrate(groups) => assert_eq!(groups.len(), 2),
other => panic!(
"explicit workspaces: must stay PerCrate despite a shared prefix, got {:?}",
std::mem::discriminant(&other)
),
}
}
fn flat_aggregate_versions_fixture(
core_ver: &str,
cli_ver: &str,
) -> (tempfile::TempDir, anodizer_core::config::Config) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, ver) in [("core", core_ver), ("cli", cli_ver)] {
let dir = root.join(format!("crates/{name}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"{ver}\"\n"),
)
.unwrap();
}
let config = anodizer_core::config::Config {
project_name: "agg".to_string(),
crates: vec![
crate_cfg("core", "crates/core", "v{{ .Version }}"),
crate_cfg("cli", "crates/cli", "v{{ .Version }}"),
],
..Default::default()
};
(tmp, config)
}
#[test]
fn coherence_guard_passes_when_versions_agree() {
let (tmp, config) = flat_aggregate_versions_fixture("0.2.0", "0.2.0");
let res =
guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), tmp.path());
assert!(res.is_ok(), "all-agree flat aggregate must pass: {res:?}");
}
#[test]
fn coherence_guard_rejects_divergent_versions() {
let (tmp, config) = flat_aggregate_versions_fixture("0.5.0", "0.1.0");
let err =
guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), tmp.path())
.unwrap_err()
.to_string();
assert!(err.contains("core"), "names conflicting crate core: {err}");
assert!(err.contains("cli"), "names conflicting crate cli: {err}");
assert!(err.contains("0.5.0") && err.contains("0.1.0"), "{err}");
assert!(err.contains("prefix 'v'"), "names the shared prefix: {err}");
assert!(
err.contains("[workspace.package].version"),
"steers toward lockstep: {err}"
);
assert!(
err.contains("distinct tag_template prefix"),
"steers toward independent prefixes: {err}"
);
}
#[test]
fn coherence_guard_skips_missing_manifests() {
let tmp = tempfile::tempdir().unwrap();
let config = anodizer_core::config::Config {
project_name: "agg".to_string(),
crates: vec![
crate_cfg("core", "crates/core", "v{{ .Version }}"),
crate_cfg("cli", "crates/cli", "v{{ .Version }}"),
],
..Default::default()
};
let res =
guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), tmp.path());
assert!(res.is_ok(), "missing manifests must be skipped: {res:?}");
}
#[test]
fn coherence_guard_noop_for_non_flat_aggregate() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, ver) in [("core", "0.5.0"), ("cli", "0.1.0")] {
let dir = root.join(format!("crates/{name}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"{ver}\"\n"),
)
.unwrap();
}
let config = anodizer_core::config::Config {
project_name: "p".to_string(),
crates: vec![
crate_cfg("core", "crates/core", "core-v{{ .Version }}"),
crate_cfg("cli", "crates/cli", "cli-v{{ .Version }}"),
],
..Default::default()
};
let res = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), root);
assert!(
res.is_ok(),
"distinct-prefix PerCrate is not guarded: {res:?}"
);
}
#[test]
fn coherence_guard_skips_versionless_member() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/core")).unwrap();
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"0.2.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/cli")).unwrap();
std::fs::write(
root.join("crates/cli/Cargo.toml"),
"[package]\nname = \"cli\"\nversion.workspace = true\n",
)
.unwrap();
let config = anodizer_core::config::Config {
project_name: "agg".to_string(),
crates: vec![
crate_cfg("core", "crates/core", "v{{ .Version }}"),
crate_cfg("cli", "crates/cli", "v{{ .Version }}"),
],
..Default::default()
};
let res = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), root);
assert!(
res.is_ok(),
"versionless member must be skipped, not compared as 0.0.0: {res:?}"
);
}
#[test]
fn coherence_guard_lists_all_members_on_n_way_divergence() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, ver) in [("a", "0.2.0"), ("b", "0.2.0"), ("c", "0.5.0")] {
let dir = root.join(format!("crates/{name}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"{ver}\"\n"),
)
.unwrap();
}
let config = anodizer_core::config::Config {
project_name: "agg".to_string(),
crates: vec![
crate_cfg("a", "crates/a", "v{{ .Version }}"),
crate_cfg("b", "crates/b", "v{{ .Version }}"),
crate_cfg("c", "crates/c", "v{{ .Version }}"),
],
..Default::default()
};
let err = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), root)
.unwrap_err()
.to_string();
assert!(err.contains("'a' (0.2.0)"), "lists member a: {err}");
assert!(err.contains("'b' (0.2.0)"), "lists member b: {err}");
assert!(err.contains("'c' (0.5.0)"), "lists member c: {err}");
}
#[test]
fn anodizer_output_format_empty() {
let crates: Vec<String> = vec![];
let json = serde_json::to_string(&crates).unwrap();
assert_eq!(json, "[]");
let line = format!("anodizer-output crates={}", json);
assert_eq!(line, "anodizer-output crates=[]");
}
#[test]
fn anodizer_output_format_single_crate() {
let crates = vec!["myproj-core".to_string()];
let json = serde_json::to_string(&crates).unwrap();
let line = format!("anodizer-output crates={}", json);
assert_eq!(line, "anodizer-output crates=[\"myproj-core\"]");
}
#[test]
fn anodizer_output_format_multi_crate() {
let crates = vec!["core".to_string(), "bin-a".to_string(), "bin-b".to_string()];
let json = serde_json::to_string(&crates).unwrap();
let line = format!("anodizer-output crates={}", json);
assert_eq!(
line,
"anodizer-output crates=[\"core\",\"bin-a\",\"bin-b\"]"
);
}
#[test]
fn anodizer_output_versions_format_empty() {
let versions: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let json = serde_json::to_string(&versions).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn anodizer_output_versions_format_single_crate() {
let mut versions = std::collections::HashMap::new();
versions.insert("cfgd-core".to_string(), "0.4.0".to_string());
let json = serde_json::to_string(&versions).unwrap();
assert_eq!(json, "{\"cfgd-core\":\"0.4.0\"}");
}
#[test]
fn skip_ci_suffix_on_appends_marker_with_leading_space() {
assert_eq!(skip_ci_suffix(true), " [skip ci]");
}
#[test]
fn skip_ci_suffix_off_is_empty() {
assert_eq!(skip_ci_suffix(false), "");
}
#[test]
fn shared_tag_prefix_uniform_prefix_returns_it() {
let crates = vec![
crate_cfg("a", "crates/a", "v{{ .Version }}"),
crate_cfg("b", "crates/b", "v{{ .Version }}"),
];
assert_eq!(shared_tag_prefix(&crates), Some("v".to_string()));
}
#[test]
fn shared_tag_prefix_divergent_prefixes_returns_none() {
let crates = vec![
crate_cfg("a", "crates/a", "a-v{{ .Version }}"),
crate_cfg("b", "crates/b", "b-v{{ .Version }}"),
];
assert_eq!(shared_tag_prefix(&crates), None);
}
#[test]
fn shared_tag_prefix_single_crate_returns_its_prefix() {
let crates = vec![crate_cfg("core", "crates/core", "core-v{{ .Version }}")];
assert_eq!(shared_tag_prefix(&crates), Some("core-v".to_string()));
}
#[test]
fn shared_tag_prefix_empty_slice_returns_none() {
assert_eq!(shared_tag_prefix(&[]), None);
}
#[test]
fn bare_version_from_tag_strips_v_prefix() {
assert_eq!(bare_version_from_tag("v1.2.3"), Some("1.2.3".to_string()));
}
#[test]
fn bare_version_from_tag_keeps_prerelease() {
assert_eq!(
bare_version_from_tag("v0.4.0-beta.1"),
Some("0.4.0-beta.1".to_string())
);
}
#[test]
fn bare_version_from_tag_handles_monorepo_prefix() {
assert_eq!(
bare_version_from_tag("core-v2.0.1"),
Some("2.0.1".to_string())
);
}
#[test]
fn bare_version_from_tag_empty_is_none() {
assert_eq!(bare_version_from_tag(""), None);
}
#[test]
fn bare_version_from_tag_non_semver_is_none() {
assert_eq!(bare_version_from_tag("not-a-version"), None);
}
#[test]
fn message_has_token_matches_standalone_word() {
assert!(message_has_token("fix: a bug #patch", "#patch"));
}
#[test]
fn message_has_token_rejects_substring_within_word() {
assert!(!message_has_token("this is #handsome", "#hand"));
assert!(!message_has_token("#patches galore", "#patch"));
}
#[test]
fn message_has_token_matches_token_anywhere_in_whitespace_split() {
assert!(message_has_token(
"subject\nbody line #major footer",
"#major"
));
}
#[test]
fn detect_conventional_bump_feat_is_minor() {
let msgs = vec!["feat: add thing".to_string()];
assert_eq!(detect_conventional_bump(&msgs), Some(BumpKind::Minor));
}
#[test]
fn detect_conventional_bump_fix_is_patch() {
let msgs = vec!["fix(core): correct it".to_string()];
assert_eq!(detect_conventional_bump(&msgs), Some(BumpKind::Patch));
}
#[test]
fn detect_conventional_bump_breaking_shorthand_is_major() {
let msgs = vec!["feat!: drop old API".to_string()];
assert_eq!(detect_conventional_bump(&msgs), Some(BumpKind::Major));
}
#[test]
fn detect_conventional_bump_chore_only_is_none() {
let msgs = vec!["chore: bump deps".to_string(), "docs: tweak".to_string()];
assert_eq!(detect_conventional_bump(&msgs), None);
}
#[test]
fn detect_conventional_bump_major_wins_over_minor_and_patch() {
let msgs = vec![
"fix: x".to_string(),
"feat: y".to_string(),
"refactor!: z".to_string(),
];
assert_eq!(detect_conventional_bump(&msgs), Some(BumpKind::Major));
}
fn group_result(
crate_names: &[&str],
new_tags: &[(&str, &str)],
version_updates: &[(&str, &str)],
old_version: Option<&str>,
prev_tag: Option<&str>,
crate_version_files: Vec<Vec<String>>,
) -> GroupTagResult {
GroupTagResult {
crate_names: crate_names.iter().map(|s| s.to_string()).collect(),
new_tags: new_tags
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect(),
version_updates: version_updates
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect(),
old_version: old_version.map(str::to_string),
prev_tag: prev_tag.map(str::to_string),
crate_version_files,
}
}
#[test]
fn plan_changelog_targets_one_target_per_bumped_crate() {
let root = Path::new("/ws");
let groups = vec![
group_result(
&["core"],
&[("core-v0.2.0", "msg")],
&[("crates/core", "0.2.0")],
Some("0.1.0"),
Some("core-v0.1.0"),
vec![vec![]],
),
group_result(
&["cli"],
&[("cli-v1.0.0", "msg")],
&[("crates/cli", "1.0.0")],
None,
None,
vec![vec![]],
),
];
let targets = plan_changelog_targets(root, &groups);
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].crate_name, "core");
assert_eq!(targets[0].crate_dir, root.join("crates/core"));
assert_eq!(targets[0].from_tag.as_deref(), Some("core-v0.1.0"));
assert_eq!(targets[0].to_version, "0.2.0");
assert_eq!(targets[0].full_tag, "core-v0.2.0");
assert_eq!(targets[1].crate_name, "cli");
assert_eq!(targets[1].from_tag, None);
assert_eq!(targets[1].full_tag, "cli-v1.0.0");
}
#[test]
fn collapse_targets_to_flat_aggregate_collapses_lockstep_set() {
let root = Path::new("/ws");
let groups = vec![group_result(
&["a", "b"],
&[("v0.5.0", "m"), ("v0.5.0", "m")],
&[("crates/a", "0.5.0"), ("crates/b", "0.5.0")],
Some("0.4.0"),
Some("v0.4.0"),
vec![vec![], vec![]],
)];
let mut targets = plan_changelog_targets(root, &groups);
assert_eq!(targets.len(), 2, "precondition: two per-crate targets");
let config = anodizer_core::config::Config {
project_name: "myproj".to_string(),
..Default::default()
};
let collapsed = collapse_targets_to_flat_aggregate(&mut targets, root, Some(&config), true);
assert!(collapsed);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].crate_name, "myproj");
assert_eq!(targets[0].crate_dir, root.to_path_buf());
assert_eq!(targets[0].from_tag.as_deref(), Some("v0.4.0"));
assert_eq!(targets[0].to_version, "0.5.0");
}
#[test]
fn collapse_targets_to_flat_aggregate_noop_when_collapse_false() {
let root = Path::new("/ws");
let mut targets = plan_changelog_targets(
root,
&[group_result(
&["a", "b"],
&[("v1.0.0", "m"), ("v1.0.0", "m")],
&[("crates/a", "1.0.0"), ("crates/b", "1.0.0")],
Some("0.9.0"),
Some("v0.9.0"),
vec![vec![], vec![]],
)],
);
let config = anodizer_core::config::Config::default();
let collapsed =
collapse_targets_to_flat_aggregate(&mut targets, root, Some(&config), false);
assert!(!collapsed);
assert_eq!(targets.len(), 2, "targets must be left untouched");
}
#[test]
fn collapse_targets_to_flat_aggregate_noop_for_single_target() {
let root = Path::new("/ws");
let mut targets = plan_changelog_targets(
root,
&[group_result(
&["solo"],
&[("v1.0.0", "m")],
&[("crates/solo", "1.0.0")],
Some("0.9.0"),
Some("v0.9.0"),
vec![vec![]],
)],
);
let config = anodizer_core::config::Config::default();
assert!(!collapse_targets_to_flat_aggregate(
&mut targets,
root,
Some(&config),
true
));
assert_eq!(targets.len(), 1);
}
#[test]
fn plan_version_files_rewrites_dedupes_identical_lockstep_pair() {
let groups = vec![group_result(
&["a", "b"],
&[("v0.2.0", "m"), ("v0.2.0", "m")],
&[("crates/a", "0.2.0"), ("crates/b", "0.2.0")],
Some("0.1.0"),
Some("v0.1.0"),
vec![vec!["README.md".to_string()], vec!["README.md".to_string()]],
)];
let plan = plan_version_files_rewrites(&groups).unwrap();
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].file, "README.md");
assert_eq!(plan[0].old, "0.1.0");
assert_eq!(plan[0].new, "0.2.0");
}
#[test]
fn plan_version_files_rewrites_conflicting_old_versions_bail() {
let groups = vec![
group_result(
&["a"],
&[("a-v0.2.0", "m")],
&[("crates/a", "0.2.0")],
Some("0.1.0"),
Some("a-v0.1.0"),
vec![vec!["shared.txt".to_string()]],
),
group_result(
&["b"],
&[("b-v0.2.0", "m")],
&[("crates/b", "0.2.0")],
Some("0.1.5"),
Some("b-v0.1.5"),
vec![vec!["shared.txt".to_string()]],
),
];
let err = plan_version_files_rewrites(&groups)
.unwrap_err()
.to_string();
assert!(
err.contains("version_files conflict") && err.contains("shared.txt"),
"conflict must name the file, got: {err}"
);
}
#[test]
fn plan_version_files_rewrites_skips_group_with_no_old_version() {
let groups = vec![group_result(
&["new"],
&[("new-v0.1.0", "m")],
&[("crates/new", "0.1.0")],
None,
None,
vec![vec!["VERSION".to_string()]],
)];
let plan = plan_version_files_rewrites(&groups).unwrap();
assert!(plan.is_empty());
}
}