use anodizer_core::config::{GitConfig, TagConfig};
use anodizer_core::git;
use anodizer_core::hooks::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};
pub struct TagOpts {
pub dry_run: bool,
pub custom_tag: Option<String>,
pub default_bump: Option<String>,
pub crate_name: Option<String>,
pub config_override: Option<std::path::PathBuf>,
pub verbose: bool,
pub debug: bool,
pub quiet: bool,
pub strict: bool,
}
struct ResolvedConfig {
default_bump: String,
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,
}
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(|| "minor".to_string()),
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),
}
}
}
pub fn run(opts: TagOpts) -> Result<()> {
let tag_config = load_tag_config(&opts);
let git_config = load_git_config(&opts);
let mut cfg = ResolvedConfig::from_tag_config(&tag_config, &opts);
let mut crate_path: Option<String> = None;
let mut version_sync_enabled = false;
if let Some(ref crate_name) = opts.crate_name
&& let Some(info) = load_crate_tag_info(&opts, crate_name)
{
cfg.tag_prefix = info.tag_prefix;
crate_path = Some(info.path);
version_sync_enabled = info.version_sync;
}
let workspace_root_path = std::env::current_dir().ok();
let workspace_info: Option<WorkspaceInfo> = match (&opts.crate_name, &workspace_root_path) {
(None, Some(root)) => load_workspace(root)
.ok()
.filter(|ws| ws.workspace_package_version.is_some()),
_ => 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 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", dry_run, &log, Some(&tv))?;
}
if cfg.git_api_tagging {
log.verbose("using GitHub API for tagging (git_api_tagging=true)");
git::create_tag_via_github_api(tag, message, dry_run, &log, strict)?;
} else {
git::create_and_push_tag(tag, message, dry_run, &log, strict)?;
}
if !post_hooks.is_empty() {
run_hooks(&post_hooks, "tag-post", 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(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(&cfg, prev_tag.as_deref(), crate_path.as_deref())?;
log.verbose(&format!("scanned {} commit message(s)", messages.len()));
let bump = detect_bump(&messages, &cfg);
log.verbose(&format!("detected bump: {:?}", bump));
let cargo_ahead = if let Some(ws) = &workspace_info {
match (
ws.workspace_package_version
.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,
}
} else {
version_sync_enabled
&& match (crate_path.as_deref(), prev_tag.as_deref()) {
(Some(path), Some(prev)) => {
match (
anodizer_stage_build::version_sync::read_cargo_version(path)
.ok()
.and_then(|v| git::parse_semver(&v).ok()),
git::parse_semver_tag(prev).ok(),
) {
(Some(c), Some(p)) => {
(c.major, c.minor, c.patch) > (p.major, p.minor, p.patch)
}
_ => false,
}
}
_ => 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);
}
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 {
anodizer_stage_build::version_sync::read_cargo_version(path).ok()
} else {
None
};
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));
if let Some(ws) = &workspace_info {
let root = workspace_root_path
.as_deref()
.unwrap_or_else(|| Path::new("."));
apply_workspace_bump(root, ws, &new_version, opts.dry_run, &log)?;
} else if let Some(ref path) = crate_path
&& version_sync_enabled
{
anodizer_stage_build::version_sync::sync_version(path, &new_version, opts.dry_run, &log)?;
let workspace_root = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
let crate_cargo = std::path::Path::new(path).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,
name,
&new_version,
opts.dry_run,
&log,
)?
} else {
vec![]
};
if !opts.dry_run {
match anodizer_core::cargo_lock::cargo_update_workspace(None) {
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);
}
let _ = git::stage_and_commit(
&files_to_stage,
&format!("chore: bump {} to {}", path, new_version),
);
}
}
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,
)?;
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(())
}
fn apply_workspace_bump(
workspace_root: &Path,
ws: &WorkspaceInfo,
new_version: &str,
dry_run: bool,
log: &StageLogger,
) -> Result<()> {
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(());
}
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
));
return Ok(());
}
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 staged_rel: Vec<String> = staged
.iter()
.map(|p| {
p.strip_prefix(workspace_root)
.unwrap_or(p.as_path())
.to_string_lossy()
.into_owned()
})
.collect();
let staged_refs: Vec<&str> = staged_rel.iter().map(|s| s.as_str()).collect();
git::stage_and_commit(
&staged_refs,
&format!("chore(release): bump workspace → {}", new_version),
)?;
log.status(&format!("workspace version-sync: bumped → {}", new_version));
Ok(())
}
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_tag_config(opts: &TagOpts) -> TagConfig {
if let Some(path) = resolve_config_path(opts)
&& let Ok(config) = crate::pipeline::load_config(&path)
{
return config.tag.unwrap_or_default();
}
TagConfig::default()
}
fn load_git_config(opts: &TagOpts) -> Option<GitConfig> {
let path = resolve_config_path(opts)?;
let config = crate::pipeline::load_config(&path).ok()?;
config.git
}
struct CrateTagInfo {
tag_prefix: String,
path: String,
version_sync: bool,
}
fn load_crate_tag_info(opts: &TagOpts, crate_name: &str) -> Option<CrateTagInfo> {
let config_path = resolve_config_path(opts)?;
let config = crate::pipeline::load_config(&config_path).ok()?;
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);
Some(CrateTagInfo {
tag_prefix,
path: crate_cfg.path.clone(),
version_sync,
})
}
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)?,
};
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(
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(1, p),
None => git::get_last_commit_messages(1),
},
"full" | "compare" => match (prev_tag, path) {
(Some(tag), Some(p)) => git::get_commit_messages_between_path(tag, "HEAD", p),
(Some(tag), None) => git::get_commit_messages_between(tag, "HEAD"),
(None, Some(p)) => git::get_last_commit_messages_path(500, p),
(None, None) => git::get_last_commit_messages(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,
)
}
pub(crate) fn detect_bump_from_tokens(
messages: &[String],
major_token: &str,
minor_token: &str,
patch_token: &str,
none_token: &str,
default_bump: &str,
) -> BumpKind {
let message_has_token = |msg: &str, token: &str| -> bool {
msg.split(|c: char| c.is_whitespace()).any(|w| w == token)
};
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::Minor,
}
}
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
}
}
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::*;
#[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 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,
config_override: None,
verbose: false,
debug: false,
quiet: false,
strict: false,
};
let resolved = ResolvedConfig::from_tag_config(&cfg, &opts);
assert_eq!(resolved.default_bump, "minor");
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,
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()),
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),
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,
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");
}
}