use super::remote::{parse_github_remote, parse_remote_owner_repo, parse_remote_web_base};
use super::semver::{compare_prerelease, parse_semver, parse_semver_tag};
use super::tags::{
create_tag_local_only, find_latest_tag_matching, find_latest_tag_matching_with_prefix,
find_previous_tag, find_previous_tag_with_prefix, get_all_semver_tags,
list_remote_tag_names_in, strip_monorepo_prefix, tag_is_signed,
};
use crate::redact::redact_url_credentials;
use crate::test_helpers::CwdGuard;
#[test]
fn test_parse_semver() {
let v = parse_semver("v1.2.3").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 2);
assert_eq!(v.patch, 3);
assert_eq!(v.prerelease, None);
assert_eq!(v.build_metadata, None);
}
#[test]
fn test_parse_semver_prerelease() {
let v = parse_semver("v1.0.0-rc.1").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.prerelease, Some("rc.1".to_string()));
assert_eq!(v.build_metadata, None);
}
#[test]
fn test_parse_semver_build_metadata() {
let v = parse_semver("v1.0.0+build.42").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 0);
assert_eq!(v.patch, 0);
assert_eq!(v.prerelease, None);
assert_eq!(v.build_metadata, Some("build.42".to_string()));
}
#[test]
fn test_parse_semver_prerelease_and_build_metadata() {
let v = parse_semver("v1.0.0-rc.1+build.42").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.prerelease, Some("rc.1".to_string()));
assert_eq!(v.build_metadata, Some("build.42".to_string()));
}
#[test]
fn test_parse_semver_rejects_prefix() {
assert!(parse_semver("cfgd-core-v2.1.0").is_err());
assert!(parse_semver("release-notes-v1.2.3").is_err());
}
#[test]
fn test_parse_semver_tag_with_prefix() {
let v = parse_semver_tag("cfgd-core-v2.1.0").unwrap();
assert_eq!(v.major, 2);
assert_eq!(v.minor, 1);
assert_eq!(v.patch, 0);
}
#[test]
fn test_parse_semver_tag_plain() {
let v = parse_semver_tag("v1.2.3").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 2);
assert_eq!(v.patch, 3);
}
#[test]
fn test_parse_semver_tag_with_prerelease_prefix() {
let v = parse_semver_tag("my-project-v1.0.0-rc.1").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.prerelease, Some("rc.1".to_string()));
}
#[test]
fn test_version_from_tag_extracts_across_tag_families() {
use super::semver::version_from_tag;
assert_eq!(version_from_tag("v1.2.3"), Some("1.2.3".to_string()));
assert_eq!(version_from_tag("1.2.3"), Some("1.2.3".to_string()));
assert_eq!(version_from_tag("crd-v0.5.0"), Some("0.5.0".to_string()));
assert_eq!(
version_from_tag("sub/v1.2.3-rc.1"),
Some("1.2.3-rc.1".to_string())
);
assert_eq!(version_from_tag("core-v2.0.1"), Some("2.0.1".to_string()));
assert_eq!(
version_from_tag("v0.4.0-beta.1"),
Some("0.4.0-beta.1".to_string())
);
assert_eq!(version_from_tag(""), None);
assert_eq!(version_from_tag("nightly"), None);
assert_eq!(version_from_tag("not-a-version"), None);
}
#[test]
fn test_split_tag_family_prefix_and_version() {
use super::semver::split_tag_family;
let (prefix, sv) = split_tag_family("v1.2.3").unwrap();
assert_eq!(prefix, "v");
assert_eq!(sv.version_string(), "1.2.3");
let (prefix, sv) = split_tag_family("crd-v0.5.0").unwrap();
assert_eq!(prefix, "crd-v");
assert_eq!(sv.version_string(), "0.5.0");
let (prefix, sv) = split_tag_family("sub/v1.2.3-rc.1").unwrap();
assert_eq!(prefix, "sub/v");
assert_eq!(sv.version_string(), "1.2.3-rc.1");
let (prefix, _) = split_tag_family("1.2.3").unwrap();
assert_eq!(prefix, "");
assert!(split_tag_family("").is_none());
assert!(split_tag_family("nightly").is_none());
}
#[test]
fn test_is_prerelease() {
assert!(parse_semver("v1.0.0-rc.1").unwrap().is_prerelease());
assert!(!parse_semver("v1.0.0").unwrap().is_prerelease());
assert!(!parse_semver("v1.0.0+build.42").unwrap().is_prerelease());
}
#[test]
fn test_parse_github_remote_https() {
let result = parse_github_remote("https://github.com/tj-smith47/anodizer.git");
assert_eq!(
result,
Some(("tj-smith47".to_string(), "anodizer".to_string()))
);
}
#[test]
fn test_parse_github_remote_https_no_dotgit() {
let result = parse_github_remote("https://github.com/owner/repo");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_github_remote_ssh() {
let result = parse_github_remote("git@github.com:owner/repo.git");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_github_remote_ssh_no_dotgit() {
let result = parse_github_remote("git@github.com:owner/repo");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_github_remote_invalid() {
let result = parse_github_remote("https://gitlab.com/foo/bar.git");
assert_eq!(result, None);
}
#[test]
fn test_parse_github_remote_empty() {
let result = parse_github_remote("");
assert_eq!(result, None);
}
#[test]
fn test_parse_remote_github_https() {
let result = parse_remote_owner_repo("https://github.com/owner/repo.git");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_remote_gitlab_https() {
let result = parse_remote_owner_repo("https://gitlab.com/owner/repo.git");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_remote_gitea_https() {
let result = parse_remote_owner_repo("https://gitea.example.com/myorg/myapp.git");
assert_eq!(result, Some(("myorg".to_string(), "myapp".to_string())));
}
#[test]
fn test_parse_remote_gitlab_nested_group() {
let result = parse_remote_owner_repo("https://gitlab.com/group/subgroup/repo.git");
assert_eq!(
result,
Some(("group/subgroup".to_string(), "repo".to_string()))
);
}
#[test]
fn test_parse_remote_ssh_gitlab() {
let result = parse_remote_owner_repo("git@gitlab.com:owner/repo.git");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_remote_ssh_gitea() {
let result = parse_remote_owner_repo("git@gitea.example.com:org/app.git");
assert_eq!(result, Some(("org".to_string(), "app".to_string())));
}
#[test]
fn test_parse_remote_ssh_nested_group() {
let result = parse_remote_owner_repo("git@gitlab.com:group/subgroup/repo.git");
assert_eq!(
result,
Some(("group/subgroup".to_string(), "repo".to_string()))
);
}
#[test]
fn test_parse_remote_no_dotgit() {
let result = parse_remote_owner_repo("https://gitlab.com/owner/repo");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_remote_empty() {
assert_eq!(parse_remote_owner_repo(""), None);
}
#[test]
fn test_parse_remote_http() {
let result = parse_remote_owner_repo("http://gitlab.local/team/project.git");
assert_eq!(result, Some(("team".to_string(), "project".to_string())));
}
#[test]
fn test_web_base_github_https() {
assert_eq!(
parse_remote_web_base("https://github.com/owner/repo.git").as_deref(),
Some("https://github.com/owner/repo")
);
}
#[test]
fn test_web_base_github_ssh() {
assert_eq!(
parse_remote_web_base("git@github.com:owner/repo.git").as_deref(),
Some("https://github.com/owner/repo")
);
}
#[test]
fn test_web_base_gitlab_ssh_self_hosted() {
assert_eq!(
parse_remote_web_base("git@gitlab.example.com:team/widget.git").as_deref(),
Some("https://gitlab.example.com/team/widget")
);
}
#[test]
fn test_web_base_gitea_https_nested_group() {
assert_eq!(
parse_remote_web_base("https://gitea.example.com/group/subgroup/repo.git").as_deref(),
Some("https://gitea.example.com/group/subgroup/repo")
);
}
#[test]
fn test_web_base_http_normalized_to_https() {
assert_eq!(
parse_remote_web_base("http://gitlab.local/team/project.git").as_deref(),
Some("https://gitlab.local/team/project")
);
}
#[test]
fn test_web_base_https_strips_userinfo() {
assert_eq!(
parse_remote_web_base("https://user:token@gitlab.com/owner/repo.git").as_deref(),
Some("https://gitlab.com/owner/repo")
);
}
#[test]
fn test_web_base_empty() {
assert_eq!(parse_remote_web_base(""), None);
}
#[test]
fn test_strip_url_credentials_with_userinfo() {
assert_eq!(
redact_url_credentials("https://user:token@github.com/owner/repo.git"),
"https://<redacted>@github.com/owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_no_userinfo() {
assert_eq!(
redact_url_credentials("https://github.com/owner/repo.git"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_ssh_unchanged() {
assert_eq!(
redact_url_credentials("git@github.com:owner/repo.git"),
"git@github.com:owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_user_only() {
assert_eq!(
redact_url_credentials("https://user@github.com/owner/repo.git"),
"https://<redacted>@github.com/owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_token_with_at_sign_does_not_leak() {
let leaky = "https://user:t@k@n@github.com/owner/repo.git";
let scrubbed = redact_url_credentials(leaky);
assert!(!scrubbed.contains("t@k@n"));
assert_eq!(scrubbed, "https://<redacted>@github.com/owner/repo.git");
}
#[test]
fn test_compare_prerelease_numeric() {
assert_eq!(
compare_prerelease("rc.9", "rc.10"),
std::cmp::Ordering::Less
);
assert_eq!(
compare_prerelease("rc.10", "rc.9"),
std::cmp::Ordering::Greater
);
}
#[test]
fn test_compare_prerelease_numeric_less_than_alpha() {
assert_eq!(compare_prerelease("1", "alpha"), std::cmp::Ordering::Less);
assert_eq!(
compare_prerelease("alpha", "1"),
std::cmp::Ordering::Greater
);
}
#[test]
fn test_compare_prerelease_alpha_lexicographic() {
assert_eq!(
compare_prerelease("alpha", "beta"),
std::cmp::Ordering::Less
);
}
#[test]
fn test_compare_prerelease_shorter_lower_precedence() {
assert_eq!(
compare_prerelease("alpha", "alpha.1"),
std::cmp::Ordering::Less
);
}
#[test]
fn test_compare_prerelease_equal() {
assert_eq!(
compare_prerelease("rc.1", "rc.1"),
std::cmp::Ordering::Equal
);
}
#[test]
fn test_semver_ord_prerelease_less_than_release() {
let pre = parse_semver("v1.0.0-rc.1").unwrap();
let rel = parse_semver("v1.0.0").unwrap();
assert!(pre < rel);
}
#[test]
fn test_semver_ord_prerelease_numeric_sorting() {
let rc9 = parse_semver("v1.0.0-rc.9").unwrap();
let rc10 = parse_semver("v1.0.0-rc.10").unwrap();
assert!(rc9 < rc10);
}
#[test]
fn test_semver_build_metadata_ignored_in_ord_and_eq() {
let a = parse_semver("v1.2.3+abc").unwrap();
let b = parse_semver("v1.2.3+def").unwrap();
assert_eq!(a.build_metadata.as_deref(), Some("abc"));
assert_eq!(b.build_metadata.as_deref(), Some("def"));
assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
assert_eq!(a, b);
assert_eq!(b.cmp(&a), std::cmp::Ordering::Equal);
let plain = parse_semver("v1.2.3").unwrap();
assert_eq!(plain.cmp(&a), std::cmp::Ordering::Equal);
assert_eq!(plain, a);
let pre_a = parse_semver("v1.0.0-rc.1+build.42").unwrap();
let pre_b = parse_semver("v1.0.0-rc.1+build.99").unwrap();
assert_eq!(pre_a.cmp(&pre_b), std::cmp::Ordering::Equal);
assert_eq!(pre_a, pre_b);
}
use serial_test::serial;
fn init_repo_with_tags(dir: &std::path::Path, tags: &[&str]) {
use std::process::Command;
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com");
cmd
},
"git",
);
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "test"]);
std::fs::write(dir.join("README"), "init").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
for tag in tags {
run(&["tag", tag]);
}
}
#[test]
#[serial]
fn test_find_latest_tag_none_config_unchanged_behavior() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
}
#[test]
#[serial]
fn test_get_all_semver_tags_ignore_tags() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v3.0.0".to_string()]),
..Default::default()
};
let tags = get_all_semver_tags("v", Some(&gc), None).unwrap();
assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
}
#[test]
#[serial]
fn test_get_all_semver_tags_ignore_tag_prefixes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "nightly-v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
..Default::default()
};
let tags = get_all_semver_tags("", Some(&gc), None).unwrap();
assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
}
#[test]
#[serial]
fn test_find_latest_tag_empty_ignore_prefix_excludes_all() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tag_prefixes: Some(vec![String::new()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(
result, None,
"empty ignore_tag_prefixes must exclude every tag in find_latest"
);
}
#[test]
#[serial]
fn test_smartsemver_empty_ignore_prefix_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
ignore_tag_prefixes: Some(vec![String::new()]),
..Default::default()
};
let result = find_previous_tag_with_prefix("v3.0.0", Some(&gc), None, None).unwrap();
assert_eq!(
result,
Some("v2.0.0".to_string()),
"empty ignore_tag_prefixes must be skipped on the smartsemver path"
);
}
#[test]
#[serial]
fn test_get_all_semver_tags_no_config_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let tags = get_all_semver_tags("v", None, None).unwrap();
assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
}
#[test]
#[serial]
fn test_find_latest_tag_ignore_tags_exact_match() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v3.0.0".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_ignore_tags_multiple() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v3.0.0".to_string(), "v2.0.0".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.0.0".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_ignore_tag_prefixes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(
dir,
&["v1.0.0", "v2.0.0", "nightly-v3.0.0", "nightly-v4.0.0"],
);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
let result_nightly = find_latest_tag_matching("nightly-v{{ .Version }}", None, None).unwrap();
assert_eq!(result_nightly, Some("nightly-v4.0.0".to_string()));
let result_filtered =
find_latest_tag_matching("nightly-v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result_filtered, None);
}
#[test]
#[serial]
fn test_find_latest_tag_ignore_all_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v1.0.0".to_string(), "v2.0.0".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, None);
}
#[test]
#[serial]
fn test_find_latest_tag_ignore_tags_and_prefixes_combined() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0-beta.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v2.0.0".to_string()]),
ignore_tag_prefixes: Some(vec!["v3".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.0.0".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_with_prefixed_template() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(
dir,
&[
"myapp-v1.0.0",
"myapp-v2.0.0",
"myapp-v3.0.0",
"other-v9.0.0",
],
);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["myapp-v3.0.0".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("myapp-v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("myapp-v2.0.0".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_default_git_config_same_as_none() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig::default();
let with_default = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
let with_none = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(with_default, with_none);
assert_eq!(with_default, Some("v2.0.0".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_prerelease_suffix_with_default_sort() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v1.1.1-rc.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result_no_suffix = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(
result_no_suffix,
Some("v1.1.1-rc.1".to_string()),
"without prerelease_suffix, SemVer sort puts v1.1.1-rc.1 highest"
);
let gc = crate::config::GitConfig {
prerelease_suffix: Some("-rc".to_string()),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(
result,
Some("v1.1.1-rc.1".to_string()),
"prerelease_suffix activates git-delegated sort; v1.1.1-rc.1 still highest"
);
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = std::process::Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com");
cmd
},
"git",
);
assert!(out.status.success());
};
run(&["tag", "v1.1.1"]);
let result_both = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert!(
result_both.is_some(),
"should find a tag with both release and rc present"
);
}
#[test]
#[serial]
fn test_find_latest_tag_ignore_tags_template_rendered() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let mut vars = crate::template::TemplateVars::new();
vars.set_env("IGNORE_TAG", "v3.0.0");
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["{{ .Env.IGNORE_TAG }}".to_string()]),
..Default::default()
};
let result_raw = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result_raw, Some("v3.0.0".to_string()));
let result_rendered =
find_latest_tag_matching("v{{ .Version }}", Some(&gc), Some(&vars)).unwrap();
assert_eq!(result_rendered, Some("v2.0.0".to_string()));
}
fn init_repo_with_tagged_commits(dir: &std::path::Path, tags: &[&str]) {
use std::process::Command;
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com");
cmd
},
"git",
);
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "test"]);
for (i, tag) in tags.iter().enumerate() {
let filename = format!("file_{}", i);
std::fs::write(dir.join(&filename), format!("content {}", i)).unwrap();
run(&["add", "."]);
run(&["commit", "-m", &format!("commit for {}", tag)]);
run(&["tag", tag]);
}
}
#[test]
#[serial]
fn test_find_previous_tag_with_ignore_tags() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_previous_tag("v3.0.0", None, None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v2.0.0".to_string()]),
..Default::default()
};
let result_filtered = find_previous_tag("v3.0.0", Some(&gc), None).unwrap();
assert_eq!(result_filtered, Some("v1.0.0".to_string()));
}
#[test]
#[serial]
fn test_find_previous_tag_with_ignore_tag_prefixes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0", "nightly-v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_previous_tag("v3.0.0", None, None).unwrap();
assert_eq!(result, Some("nightly-v2.0.0".to_string()));
let gc = crate::config::GitConfig {
ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
..Default::default()
};
let result_filtered = find_previous_tag("v3.0.0", Some(&gc), None).unwrap();
assert_eq!(result_filtered, Some("v1.0.0".to_string()));
}
#[test]
#[serial]
fn test_find_previous_tag_no_config_unchanged_behavior() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_previous_tag("v2.0.0", None, None).unwrap();
assert_eq!(result, Some("v1.0.0".to_string()));
}
#[test]
fn test_strip_monorepo_prefix_with_match() {
assert_eq!(
strip_monorepo_prefix("subproject1/v1.2.3", "subproject1/"),
"v1.2.3"
);
}
#[test]
fn test_strip_monorepo_prefix_no_match() {
assert_eq!(strip_monorepo_prefix("v1.2.3", "subproject1/"), "v1.2.3");
}
#[test]
fn test_strip_monorepo_prefix_empty_prefix() {
assert_eq!(strip_monorepo_prefix("v1.2.3", ""), "v1.2.3");
}
#[test]
fn test_strip_monorepo_prefix_partial_match() {
assert_eq!(
strip_monorepo_prefix("subproject1/v1.2.3", "sub"),
"project1/v1.2.3"
);
}
#[test]
#[serial]
fn test_find_latest_tag_with_monorepo_prefix_filters_and_returns_full_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(
dir,
&[
"v1.0.0",
"subproject1/v1.0.0",
"subproject1/v2.0.0",
"subproject2/v3.0.0",
],
);
let _cwd = CwdGuard::new(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("subproject1/"))
.unwrap();
assert_eq!(
result,
Some("subproject1/v2.0.0".to_string()),
"should return the full tag with prefix"
);
}
#[test]
#[serial]
fn test_find_latest_tag_with_monorepo_prefix_semver_comparison_uses_stripped_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["myapp/v1.0.0", "myapp/v2.0.0", "myapp/v1.5.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("myapp/"))
.unwrap();
assert_eq!(
result,
Some("myapp/v2.0.0".to_string()),
"should pick the highest version based on stripped semver"
);
}
#[test]
#[serial]
fn test_find_latest_tag_with_monorepo_prefix_no_matching_tags() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("myapp/"))
.unwrap();
assert_eq!(result, None);
}
#[test]
#[serial]
fn test_find_latest_tag_with_monorepo_prefix_none_behaves_like_original() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result_with_prefix =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, None).unwrap();
let result_original = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(result_with_prefix, result_original);
assert_eq!(result_with_prefix, Some("v2.0.0".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_with_monorepo_prefix_and_prerelease() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["svc/v1.0.0", "svc/v1.1.0-rc.1", "svc/v1.1.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("svc/")).unwrap();
assert_eq!(
result,
Some("svc/v1.1.0".to_string()),
"release v1.1.0 should win over v1.1.0-rc.1"
);
}
use super::commits::{add_path_in, commit_in};
#[test]
#[serial(token_env)]
fn test_add_path_in_bail_redacts_token_in_stderr() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &[]);
let secret = "ghp_addpathintestSentinel_123456789";
let prev = std::env::var("GITHUB_TOKEN").ok();
unsafe {
std::env::set_var("GITHUB_TOKEN", secret);
}
let nonexistent = dir.join(format!("missing-{secret}.txt"));
let rel = nonexistent.strip_prefix(dir).unwrap();
let err = add_path_in(dir, rel).expect_err("git add must fail on a non-existent path");
let msg = format!("{err:#}");
unsafe {
if let Some(prev) = prev {
std::env::set_var("GITHUB_TOKEN", prev);
} else {
std::env::remove_var("GITHUB_TOKEN");
}
}
assert!(
!msg.contains(secret),
"add_path_in bail leaked GITHUB_TOKEN: {msg}"
);
assert!(
msg.contains("$GITHUB_TOKEN"),
"redaction must substitute $GITHUB_TOKEN: {msg}"
);
}
#[test]
#[serial(token_env)]
fn test_commit_in_bail_redacts_token_in_stderr() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &[]);
let secret = "ghp_commitintestSentinel_987654321";
let prev = std::env::var("GITHUB_TOKEN").ok();
unsafe {
std::env::set_var("GITHUB_TOKEN", secret);
}
let msg_with_secret = format!("release {secret}");
let err = commit_in(dir, &msg_with_secret, false)
.expect_err("commit must fail when nothing is staged");
let msg = format!("{err:#}");
unsafe {
if let Some(prev) = prev {
std::env::set_var("GITHUB_TOKEN", prev);
} else {
std::env::remove_var("GITHUB_TOKEN");
}
}
assert!(
!msg.contains(secret),
"commit_in bail leaked GITHUB_TOKEN: {msg}"
);
}
#[test]
fn test_detect_github_repo_error_strips_url_credentials() {
let leaky = "https://ghp_leakytoken@gitlab.example.com/grp/proj.git";
let scrubbed = redact_url_credentials(leaky);
assert!(!scrubbed.contains("ghp_leakytoken"));
assert_eq!(
scrubbed,
"https://<redacted>@gitlab.example.com/grp/proj.git"
);
}
#[test]
fn short_commit_str_truncates_to_seven_chars_to_match_git_short() {
use super::commits::{SHORT_COMMIT_LEN, short_commit_str};
assert_eq!(SHORT_COMMIT_LEN, 7);
let full = "deadbeef1234567890abcdef";
let short = short_commit_str(full);
assert_eq!(short.len(), 7);
assert_eq!(short, "deadbee");
}
#[test]
fn short_commit_str_passes_short_inputs_through_unchanged() {
use super::commits::short_commit_str;
assert_eq!(short_commit_str("abc"), "abc");
assert_eq!(short_commit_str("abc1234"), "abc1234");
assert_eq!(short_commit_str(""), "");
}
#[test]
fn head_is_at_tag_returns_true_when_head_has_tag() {
use super::tags::head_is_at_tag;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0"]);
assert!(
head_is_at_tag(dir).unwrap(),
"HEAD has tag v1.0.0 attached; head_is_at_tag should return true"
);
}
#[test]
fn head_is_at_tag_returns_false_when_head_has_no_tag() {
use super::tags::head_is_at_tag;
use std::process::Command;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0"]);
std::fs::write(dir.join("untagged.txt"), "no tag here").unwrap();
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.args(["add", "."]);
cmd
},
"git",
);
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.args(["commit", "-m", "post-tag commit"]);
cmd
},
"git",
);
assert!(
!head_is_at_tag(dir).unwrap(),
"HEAD is one commit past v1.0.0; head_is_at_tag should return false"
);
}
#[test]
#[serial]
fn test_find_latest_tag_semver_mode_orders_by_semver() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0-rc.1", "v1.1.0", "v1.2.0-beta.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("semver".to_string()),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.2.0-beta.1".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_semver_mode_ignores_prerelease_suffix_setting() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0-rc.1", "v1.1.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("semver".to_string()),
prerelease_suffix: Some("-rc".to_string()),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.1.0".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_smartsemver_returns_semver_highest() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0-rc.1", "v1.1.0", "v1.2.0-beta.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.2.0-beta.1".to_string()));
}
#[test]
#[serial]
fn test_find_latest_tag_smartsemver_keeps_prereleases_for_prerelease_target() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0-rc.1", "v1.1.0", "v1.2.0-beta.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let mut vars = crate::template::TemplateVars::new();
vars.set("Version", "v1.2.0-beta.2");
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), Some(&vars)).unwrap();
assert_eq!(
result,
Some("v1.2.0-beta.1".to_string()),
"smartsemver with prerelease target keeps all candidates"
);
}
#[test]
#[serial]
fn test_find_previous_tag_smartsemver_rc1_classified_as_prerelease() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0", "v1.1.0-rc1", "v1.1.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v1.1.0", Some(&gc), None).unwrap();
assert_eq!(
result,
Some("v1.0.0".to_string()),
"v1.1.0-rc1 must be classified as prerelease via SemVer parsing alone"
);
let sv = parse_semver_tag("v1.1.0-rc1").unwrap();
assert!(sv.is_prerelease());
}
#[test]
#[serial]
fn test_find_previous_tag_smartsemver_skips_prerelease_predecessor() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v0.1.0", "v0.2.0-beta.3", "v0.2.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v0.2.0", Some(&gc), None).unwrap();
assert_eq!(result, Some("v0.1.0".to_string()));
}
#[test]
#[serial]
fn test_find_previous_tag_smartsemver_keeps_prerelease_when_current_is_prerelease() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v0.1.0", "v0.2.0-beta.1", "v0.2.0-beta.2"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v0.2.0-beta.2", Some(&gc), None).unwrap();
assert_eq!(result, Some("v0.2.0-beta.1".to_string()));
}
#[test]
#[serial]
fn test_find_previous_tag_smartsemver_release_tag_filters_prereleases() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v0.1.0", "v0.2.0-beta.3", "v0.2.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v0.2.0", Some(&gc), None).unwrap();
assert_eq!(
result,
Some("v0.1.0".to_string()),
"smartsemver must skip v0.2.0-beta.3 with current_tag v0.2.0"
);
}
#[test]
#[serial]
fn test_find_previous_tag_smartsemver_monorepo_prefix() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["svc/v0.1.0", "svc/v0.2.0-beta.3", "svc/v0.2.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result =
find_previous_tag_with_prefix("svc/v0.2.0", Some(&gc), None, Some("svc/")).unwrap();
assert_eq!(
result,
Some("svc/v0.1.0".to_string()),
"smartsemver must skip svc/v0.2.0-beta.3 in monorepo mode"
);
}
#[test]
#[serial]
fn test_find_previous_tag_smartsemver_early_dev_no_panic() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v0.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v0.0.0", Some(&gc), None).unwrap();
assert_eq!(result, None);
}
#[test]
#[serial]
fn list_remote_tag_names_dedupes_peeled_annotated_entries() {
use std::process::Command;
let work = tempfile::tempdir().unwrap();
init_repo_with_tags(work.path(), &["v1.0.0"]);
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(work.path())
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com");
cmd
},
"git",
);
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
run(&["tag", "-a", "v1.1.0", "-m", "release v1.1.0"]);
let bare = tempfile::tempdir().unwrap();
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["init", "--bare", "-q"]).arg(bare.path());
cmd
},
"git",
);
assert!(out.status.success(), "git init --bare failed");
run(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
run(&["push", "origin", "v1.0.0", "v1.1.0"]);
let mut names = list_remote_tag_names_in(work.path(), "origin").unwrap();
names.sort();
assert_eq!(names, vec!["v1.0.0".to_string(), "v1.1.0".to_string()]);
run(&[
"remote",
"set-url",
"origin",
"/nonexistent/never-a-repo.git",
]);
assert!(list_remote_tag_names_in(work.path(), "origin").is_err());
}
fn init_repo_with_ssh_signing(dir: &std::path::Path) -> tempfile::TempDir {
use std::process::Command;
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com");
cmd
},
"git",
);
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "test"]);
std::fs::write(dir.join("README"), "init").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
let keydir = tempfile::tempdir().unwrap();
let key_path = keydir.path().join("sign_key");
let keygen = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("ssh-keygen");
cmd.args(["-t", "ed25519", "-N", "", "-C", "anodizer-test", "-f"])
.arg(&key_path);
cmd
},
"ssh-keygen",
);
assert!(
keygen.status.success(),
"ssh-keygen failed: {}",
String::from_utf8_lossy(&keygen.stderr)
);
let pub_path = format!("{}.pub", key_path.display());
run(&["config", "gpg.format", "ssh"]);
run(&["config", "user.signingkey", &pub_path]);
keydir
}
#[test]
#[serial]
fn create_tag_local_only_upgrades_unsigned_reuse_to_signed() {
let tmp = tempfile::tempdir().unwrap();
let _keydir = init_repo_with_ssh_signing(tmp.path());
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let seeded = create_tag_local_only(tmp.path(), "v1.0.0", "seed", false, false, &log);
assert!(seeded.is_ok(), "seeding unsigned tag failed: {seeded:?}");
assert!(
!tag_is_signed(tmp.path(), "v1.0.0").unwrap(),
"precondition: seeded tag must be unsigned"
);
let re = create_tag_local_only(tmp.path(), "v1.0.0", "seed", false, true, &log);
assert!(re.is_ok(), "signed re-run failed: {re:?}");
assert!(
tag_is_signed(tmp.path(), "v1.0.0").unwrap(),
"reuse path must UPGRADE the unsigned tag to a signed one"
);
}
#[test]
#[serial]
fn create_tag_local_only_reuses_already_signed_tag() {
let tmp = tempfile::tempdir().unwrap();
let _keydir = init_repo_with_ssh_signing(tmp.path());
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let first = create_tag_local_only(tmp.path(), "v1.0.0", "seed", false, true, &log);
assert!(first.is_ok(), "first signed create failed: {first:?}");
assert!(tag_is_signed(tmp.path(), "v1.0.0").unwrap());
let again = create_tag_local_only(tmp.path(), "v1.0.0", "seed", false, true, &log);
assert!(again.is_ok(), "signed reuse failed: {again:?}");
assert!(
tag_is_signed(tmp.path(), "v1.0.0").unwrap(),
"already-signed tag must remain signed on reuse"
);
}