use super::*;
fn push_opts(push: bool, no_push: bool) -> TagOpts {
TagOpts {
dry_run: false,
custom_tag: None,
version_override: None,
default_bump: None,
crate_name: None,
push,
no_push,
push_tags_only: false,
sign: false,
no_sign: false,
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)] = &[
(false, true, Some(true), false),
(true, true, Some(true), false), (false, true, None, false),
(true, false, None, true),
(false, false, Some(true), true),
(false, false, Some(false), false),
(false, false, None, false),
];
for &(push, no_push, config_push, expected) in cases {
let opts = push_opts(push, no_push);
assert_eq!(
resolve_effective_push(&opts, config_push),
expected,
"push={push} no_push={no_push} config_push={config_push:?}"
);
}
}
#[test]
fn resolve_effective_sign_matrix() {
let cases: &[(bool, bool, Option<bool>, bool)] = &[
(true, true, Some(true), false),
(false, true, Some(true), false),
(false, true, None, false),
(true, false, None, true),
(false, false, Some(true), true),
(false, false, Some(false), false),
(false, false, None, false),
];
for &(sign, no_sign, config_sign, expected) in cases {
let mut opts = push_opts(false, false);
opts.sign = sign;
opts.no_sign = no_sign;
assert_eq!(
resolve_effective_sign(&opts, config_sign),
expected,
"sign={sign} no_sign={no_sign} config_sign={config_sign:?}"
);
}
}
#[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_config(
&Config {
tag: Some(tag_cfg),
..Default::default()
},
&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_config(
&Config {
tag: Some(tag_cfg),
..Default::default()
},
&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,
version_override: None,
default_bump: None,
crate_name: None,
push: false,
no_push: false,
push_tags_only: false,
sign: false,
no_sign: 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_config(
&Config {
tag: Some(cfg),
..Default::default()
},
&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()),
version_override: None,
default_bump: Some("major".to_string()),
crate_name: None,
push: false,
no_push: false,
push_tags_only: false,
sign: false,
no_sign: 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_config(
&Config {
tag: Some(cfg),
..Default::default()
},
&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),
sign: None,
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,
version_override: None,
default_bump: None,
crate_name: None,
push: false,
no_push: false,
push_tags_only: false,
sign: false,
no_sign: 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_config(
&Config {
tag: Some(cfg),
..Default::default()
},
&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: Some(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 shared_root_aggregate_name_lockstep_is_project_name() {
let config = anodizer_core::config::Config {
project_name: "ws".to_string(),
crates: vec![
crate_cfg("a", "crates/a", "v{{ .Version }}"),
crate_cfg("b", "crates/b", "v{{ .Version }}"),
],
..Default::default()
};
let ws = WorkspaceInfo {
workspace_package_version: Some("0.1.0".to_string()),
members: vec![],
};
let root = empty_root();
assert_eq!(
shared_root_aggregate_name(root.path(), &config, Some(&ws)),
Some("ws")
);
}
#[test]
fn shared_root_aggregate_name_flat_aggregate_is_project_name() {
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 }}"),
],
..Default::default()
};
let root = empty_root();
assert_eq!(
shared_root_aggregate_name(root.path(), &config, None),
Some("agg")
);
}
#[test]
fn shared_root_aggregate_name_empty_universe_single_is_project_name() {
let config = anodizer_core::config::Config {
project_name: "solo".to_string(),
..Default::default()
};
let root = empty_root();
assert_eq!(
shared_root_aggregate_name(root.path(), &config, None),
Some("solo")
);
}
#[test]
fn shared_root_aggregate_name_none_on_per_crate_and_named_single() {
let per_crate = 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 root = empty_root();
assert_eq!(
shared_root_aggregate_name(root.path(), &per_crate, None),
None
);
let single = anodizer_core::config::Config {
project_name: "proj".to_string(),
crates: vec![crate_cfg("app", ".", "v{{ .Version }}")],
..Default::default()
};
assert_eq!(shared_root_aggregate_name(root.path(), &single, None), None);
}
#[test]
fn detect_repo_shape_mixed_config_keeps_top_level_crates_as_tracks() {
let config = anodizer_core::config::Config {
project_name: "ws".to_string(),
crates: vec![
crate_cfg("root", ".", "root-v{{ .Version }}"),
crate_cfg("member", "crates/member", "member-v{{ .Version }}"),
],
workspaces: Some(vec![anodizer_core::config::WorkspaceConfig {
name: "grp".to_string(),
crates: vec![
crate_cfg("member", "crates/member", "member-v{{ .Version }}"),
crate_cfg("sibling", "crates/sibling", "sibling-v{{ .Version }}"),
],
..Default::default()
}]),
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
match shape {
RepoShape::PerCrate(groups) => {
let names: Vec<Vec<&str>> = groups
.iter()
.map(|g| g.iter().map(|c| c.name.as_str()).collect())
.collect();
assert_eq!(names, vec![vec!["member", "sibling"], vec!["root"]]);
}
other => panic!(
"expected PerCrate, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[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}"
);
}
fn independent_crates_with_repo_prefix_fixture(
core_ver: &str,
cli_ver: &str,
) -> (tempfile::TempDir, anodizer_core::config::Config) {
let (tmp, mut config) = flat_aggregate_versions_fixture(core_ver, cli_ver);
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\n",
)
.unwrap();
for c in &mut config.crates {
c.tag_template = None;
}
config.tag = Some(anodizer_core::config::TagConfig {
tag_prefix: Some("v".to_string()),
..Default::default()
});
config.populate_derived_tag_templates(tmp.path());
(tmp, config)
}
#[test]
fn independent_crates_with_a_repo_tag_prefix_bail_with_both_remedies() {
let (tmp, config) = independent_crates_with_repo_prefix_fixture("0.5.0", "0.1.0");
assert!(
matches!(
detect_repo_shape(tmp.path(), Some(&config), Some(&ws_no_lockstep())),
RepoShape::FlatAggregate(ref crates) if crates.len() == 2
),
"one derived family makes one aggregate"
);
let err = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), tmp.path())
.unwrap_err()
.to_string();
assert!(err.contains("prefix 'v'"), "names the shared prefix: {err}");
assert!(
err.contains("distinct tag_template prefix"),
"first way out — a template per crate: {err}"
);
assert!(
err.contains("remove `tag.tag_prefix: \"v\"`"),
"second way out — drop the repo prefix so `<name>-v` applies: {err}"
);
}
#[test]
fn independent_crates_with_a_repo_tag_prefix_and_one_version_cut_one_tag() {
let (tmp, config) = independent_crates_with_repo_prefix_fixture("0.2.0", "0.2.0");
let shape = detect_repo_shape(tmp.path(), Some(&config), Some(&ws_no_lockstep()));
let RepoShape::FlatAggregate(crates) = shape else {
panic!(
"one shared family must aggregate, got {:?}",
std::mem::discriminant(&shape)
);
};
assert_eq!(crates.len(), 2);
assert_eq!(
shared_tag_prefix(&crates).as_deref(),
Some("v"),
"the aggregate cuts one `v<version>` tag"
);
let res = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), tmp.path());
assert!(res.is_ok(), "agreeing versions pass: {res:?}");
}
#[test]
fn detect_repo_shape_mixed_shared_prefix_leftovers_join_one_group() {
let config = anodizer_core::config::Config {
project_name: "mixed".to_string(),
crates: vec![
crate_cfg("alpha", "crates/alpha", "v{{ .Version }}"),
crate_cfg("beta", "crates/beta", "v{{ .Version }}"),
],
workspaces: Some(vec![anodizer_core::config::WorkspaceConfig {
name: "grp".to_string(),
crates: vec![crate_cfg("gamma", "tools/gamma", "gamma-v{{ .Version }}")],
..Default::default()
}]),
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
match shape {
RepoShape::PerCrate(groups) => {
let names: Vec<Vec<&str>> = groups
.iter()
.map(|g| g.iter().map(|c| c.name.as_str()).collect())
.collect();
assert_eq!(
names,
vec![vec!["gamma"], vec!["alpha", "beta"]],
"shared-prefix leftovers must join as one aggregate group"
);
}
other => panic!(
"expected PerCrate, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn detect_repo_shape_mixed_distinct_prefix_leftovers_stay_singletons() {
let config = anodizer_core::config::Config {
project_name: "mixed".to_string(),
crates: vec![
crate_cfg("alpha", "crates/alpha", "alpha-v{{ .Version }}"),
crate_cfg("beta", "crates/beta", "beta-v{{ .Version }}"),
],
workspaces: Some(vec![anodizer_core::config::WorkspaceConfig {
name: "grp".to_string(),
crates: vec![crate_cfg("gamma", "tools/gamma", "gamma-v{{ .Version }}")],
..Default::default()
}]),
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
match shape {
RepoShape::PerCrate(groups) => {
let names: Vec<Vec<&str>> = groups
.iter()
.map(|g| g.iter().map(|c| c.name.as_str()).collect())
.collect();
assert_eq!(names, vec![vec!["gamma"], vec!["alpha"], vec!["beta"]]);
}
other => panic!(
"expected PerCrate, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn coherence_guard_rejects_divergent_mixed_leftovers() {
let (tmp, mut config) = flat_aggregate_versions_fixture("0.5.0", "0.1.0");
config.workspaces = Some(vec![anodizer_core::config::WorkspaceConfig {
name: "grp".to_string(),
crates: vec![crate_cfg("gamma", "tools/gamma", "gamma-v{{ .Version }}")],
..Default::default()
}]);
let err = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), tmp.path())
.unwrap_err()
.to_string();
assert!(err.contains("core") && err.contains("cli"), "{err}");
assert!(err.contains("0.5.0") && err.contains("0.1.0"), "{err}");
}
#[test]
fn coherence_guard_passes_agreeing_mixed_leftovers() {
let (tmp, mut config) = flat_aggregate_versions_fixture("0.2.0", "0.2.0");
config.workspaces = Some(vec![anodizer_core::config::WorkspaceConfig {
name: "grp".to_string(),
crates: vec![crate_cfg("gamma", "tools/gamma", "gamma-v{{ .Version }}")],
..Default::default()
}]);
let res = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), tmp.path());
assert!(res.is_ok(), "agreeing mixed leftovers must pass: {res:?}");
}
#[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 detect_repo_shape_flat_prefix_subset_groups_by_prefix() {
let config = anodizer_core::config::Config {
project_name: "p".to_string(),
crates: vec![
crate_cfg("alpha", "crates/alpha", "v{{ .Version }}"),
crate_cfg("beta", "crates/beta", "v{{ .Version }}"),
crate_cfg("gamma", "crates/gamma", "gamma-v{{ .Version }}"),
],
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
match shape {
RepoShape::PerCrate(groups) => {
let names: Vec<Vec<&str>> = groups
.iter()
.map(|g| g.iter().map(|c| c.name.as_str()).collect())
.collect();
assert_eq!(
names,
vec![vec!["alpha", "beta"], vec!["gamma"]],
"shared-prefix subset must aggregate; unique prefix stays singleton"
);
}
other => panic!(
"expected PerCrate, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn detect_repo_shape_mixed_leftover_prefix_subset_groups_by_prefix() {
let config = anodizer_core::config::Config {
project_name: "mixed".to_string(),
crates: vec![
crate_cfg("alpha", "crates/alpha", "v{{ .Version }}"),
crate_cfg("beta", "crates/beta", "v{{ .Version }}"),
crate_cfg("delta", "crates/delta", "delta-v{{ .Version }}"),
],
workspaces: Some(vec![anodizer_core::config::WorkspaceConfig {
name: "grp".to_string(),
crates: vec![crate_cfg("gamma", "tools/gamma", "gamma-v{{ .Version }}")],
..Default::default()
}]),
..Default::default()
};
let root = empty_root();
let shape = detect_repo_shape(root.path(), Some(&config), None);
match shape {
RepoShape::PerCrate(groups) => {
let names: Vec<Vec<&str>> = groups
.iter()
.map(|g| g.iter().map(|c| c.name.as_str()).collect())
.collect();
assert_eq!(
names,
vec![vec!["gamma"], vec!["alpha", "beta"], vec!["delta"]],
"leftover shared-prefix subset must aggregate; unique prefix stays singleton"
);
}
other => panic!(
"expected PerCrate, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn coherence_guard_rejects_divergent_flat_prefix_subset() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, ver) in [("alpha", "0.5.0"), ("beta", "0.1.0"), ("gamma", "0.9.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("alpha", "crates/alpha", "v{{ .Version }}"),
crate_cfg("beta", "crates/beta", "v{{ .Version }}"),
crate_cfg("gamma", "crates/gamma", "gamma-v{{ .Version }}"),
],
..Default::default()
};
let err = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), root)
.unwrap_err()
.to_string();
assert!(err.contains("alpha") && err.contains("beta"), "{err}");
assert!(err.contains("0.5.0") && err.contains("0.1.0"), "{err}");
assert!(
!err.contains("gamma"),
"gamma has its own namespace and must not be blamed: {err}"
);
}
#[test]
fn coherence_guard_passes_agreeing_flat_subset_with_divergent_singleton() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, ver) in [("alpha", "0.2.0"), ("beta", "0.2.0"), ("gamma", "0.9.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("alpha", "crates/alpha", "v{{ .Version }}"),
crate_cfg("beta", "crates/beta", "v{{ .Version }}"),
crate_cfg("gamma", "crates/gamma", "gamma-v{{ .Version }}"),
],
..Default::default()
};
let res = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), root);
assert!(res.is_ok(), "independent singleton must not trip: {res:?}");
}
#[test]
fn coherence_guard_rejects_divergent_mixed_leftover_subset() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, ver) in [("alpha", "0.5.0"), ("beta", "0.1.0"), ("delta", "0.9.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: "mixed".to_string(),
crates: vec![
crate_cfg("alpha", "crates/alpha", "v{{ .Version }}"),
crate_cfg("beta", "crates/beta", "v{{ .Version }}"),
crate_cfg("delta", "crates/delta", "delta-v{{ .Version }}"),
],
workspaces: Some(vec![anodizer_core::config::WorkspaceConfig {
name: "grp".to_string(),
crates: vec![crate_cfg("gamma", "tools/gamma", "gamma-v{{ .Version }}")],
..Default::default()
}]),
..Default::default()
};
let err = guard_flat_aggregate_coherence(Some(&config), Some(&ws_no_lockstep()), root)
.unwrap_err()
.to_string();
assert!(err.contains("alpha") && err.contains("beta"), "{err}");
assert!(
!err.contains("delta"),
"delta has its own namespace and must not be blamed: {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 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));
}
#[test]
fn conventional_classification_is_lockstep_between_tag_and_bump() {
use crate::commands::bump::inference;
use crate::commands::bump::plan::BumpLevel;
let corpus: &[(&[&str], Option<BumpKind>)] = &[
(&["revert: undo broken feature"], Some(BumpKind::Patch)),
(
&["feat: x\n\nBREAKING CHANGE removed the old endpoint"],
Some(BumpKind::Major),
),
(
&["fix: y\n\nBREAKING-CHANGE: dropped the flag"],
Some(BumpKind::Major),
),
(&["feat!: drop legacy auth"], Some(BumpKind::Major)),
(&["feat(core)!: rewrite pipeline"], Some(BumpKind::Major)),
(&["refactor!: drop the shim"], Some(BumpKind::Major)),
(&["feat: new stage"], Some(BumpKind::Minor)),
(&["feat(build): add cache key"], Some(BumpKind::Minor)),
(&["fix: race"], Some(BumpKind::Patch)),
(&["perf: faster loop"], Some(BumpKind::Patch)),
(&["feat(broken: unclosed scope"], Some(BumpKind::Minor)),
(&["chore: deps", "docs: tweak"], None),
(&["random subject"], None),
(&["fix: a", "feat: b", "chore: c"], Some(BumpKind::Minor)),
(&["fix: a", "feat!: b"], Some(BumpKind::Major)),
];
for (msgs, expected_tag) in corpus {
let msgs: Vec<String> = msgs.iter().map(|s| s.to_string()).collect();
assert_eq!(
detect_conventional_bump(&msgs),
*expected_tag,
"tag-side classification for {msgs:?}"
);
let expected_bump = match expected_tag {
Some(BumpKind::Major) => BumpLevel::Major,
Some(BumpKind::Minor) => BumpLevel::Minor,
Some(BumpKind::Patch) => BumpLevel::Patch,
Some(BumpKind::None) | None => BumpLevel::Skip,
};
let (level, _) = inference::classify(&msgs);
assert_eq!(
level, expected_bump,
"bump-side classification for {msgs:?}"
);
}
}
fn vf(path: &str) -> anodizer_core::config::VersionFileEntry {
anodizer_core::config::VersionFileEntry::Path(path.to_string())
}
fn vf_at(path: &str, anchor: &str) -> anodizer_core::config::VersionFileEntry {
anodizer_core::config::VersionFileEntry::Anchored(anodizer_core::config::AnchoredVersionFile {
path: path.to_string(),
match_pattern: anchor.to_string(),
})
}
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<anodizer_core::config::VersionFileEntry>>,
) -> 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![vf("README.md")], vec![vf("README.md")]],
)];
let plan = plan_version_files_rewrites(&groups).unwrap();
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].path, "README.md");
assert_eq!(plan[0].old, "0.1.0");
assert_eq!(plan[0].new, "0.2.0");
}
#[test]
fn plan_version_files_rewrites_distinct_olds_both_rewrite() {
let groups = vec![
group_result(
&["a"],
&[("a-v0.10.0", "m")],
&[("crates/a", "0.10.0")],
Some("0.9.0"),
Some("a-v0.9.0"),
vec![vec![vf("shared.txt")]],
),
group_result(
&["b"],
&[("b-v0.8.0", "m")],
&[("crates/b", "0.8.0")],
Some("0.7.0"),
Some("b-v0.7.0"),
vec![vec![vf("shared.txt")]],
),
];
let plan = plan_version_files_rewrites(&groups).unwrap();
assert_eq!(plan.len(), 2, "plan: {plan:?}");
assert_eq!(
(plan[0].old.as_str(), plan[0].new.as_str()),
("0.9.0", "0.10.0")
);
assert_eq!(
(plan[1].old.as_str(), plan[1].new.as_str()),
("0.7.0", "0.8.0")
);
}
#[test]
fn plan_version_files_rewrites_same_old_different_new_bails() {
let groups = vec![
group_result(
&["a"],
&[("a-v0.8.0", "m")],
&[("crates/a", "0.8.0")],
Some("0.7.0"),
Some("a-v0.7.0"),
vec![vec![vf("shared.txt")]],
),
group_result(
&["b"],
&[("b-v0.7.1", "m")],
&[("crates/b", "0.7.1")],
Some("0.7.0"),
Some("b-v0.7.0"),
vec![vec![vf("shared.txt")]],
),
];
let err = plan_version_files_rewrites(&groups)
.unwrap_err()
.to_string();
assert!(
err.contains("bumping FROM the same version") && err.contains("shared.txt"),
"conflict must name the hazard and the file, got: {err}"
);
}
#[test]
fn plan_version_files_rewrites_chain_bails() {
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![vf("shared.txt")]],
),
group_result(
&["b"],
&[("b-v0.3.0", "m")],
&[("crates/b", "0.3.0")],
Some("0.2.0"),
Some("b-v0.2.0"),
vec![vec![vf("shared.txt")]],
),
];
let err = plan_version_files_rewrites(&groups)
.unwrap_err()
.to_string();
assert!(
err.contains("chain") && err.contains("shared.txt"),
"conflict must name the chain and the file, got: {err}"
);
}
#[test]
fn plan_version_files_rewrites_prefix_chain_bails() {
let groups = vec![
group_result(
&["a"],
&[("a-v0.1.0-rc2", "m")],
&[("crates/a", "0.1.0-rc2")],
Some("0.1.0-rc1"),
Some("a-v0.1.0-rc1"),
vec![vec![vf("shared.txt")]],
),
group_result(
&["b"],
&[("b-v0.2.0", "m")],
&[("crates/b", "0.2.0")],
Some("0.1.0"),
Some("b-v0.1.0"),
vec![vec![vf("shared.txt")]],
),
];
let err = plan_version_files_rewrites(&groups)
.unwrap_err()
.to_string();
assert!(
err.contains("chain") && err.contains("shared.txt"),
"prefix chain must be refused, got: {err}"
);
}
#[test]
fn plan_version_files_rewrites_distinct_anchors_do_not_conflict() {
let groups = vec![
group_result(
&["operator"],
&[("operator-v0.8.0", "m")],
&[("crates/operator", "0.8.0")],
Some("0.7.0"),
Some("operator-v0.7.0"),
vec![vec![vf_at(
"values.yaml",
r"operator:\s+image:.*:v{version}",
)]],
),
group_result(
&["csi"],
&[("csi-v0.7.1", "m")],
&[("crates/csi", "0.7.1")],
Some("0.7.0"),
Some("csi-v0.7.0"),
vec![vec![vf_at("values.yaml", r"csi:\s+image:.*:v{version}")]],
),
];
let plan = plan_version_files_rewrites(&groups).unwrap();
assert_eq!(plan.len(), 2, "plan: {plan:?}");
assert_eq!(
plan[0].anchor.as_deref(),
Some(r"operator:\s+image:.*:v{version}")
);
assert_eq!(
plan[1].anchor.as_deref(),
Some(r"csi:\s+image:.*:v{version}")
);
}
#[test]
fn plan_version_files_rewrites_bare_beside_anchored_plans_both() {
let groups = vec![
group_result(
&["cfgd"],
&[("cfgd-v0.11.0", "m")],
&[("crates/cfgd", "0.11.0")],
Some("0.10.0"),
Some("cfgd-v0.10.0"),
vec![vec![vf("docs/installation.md")]],
),
group_result(
&["operator"],
&[("operator-v0.10.1", "m")],
&[("crates/operator", "0.10.1")],
Some("0.10.0"),
Some("operator-v0.10.0"),
vec![vec![vf_at("docs/installation.md", r"Operator\s+{version}")]],
),
];
let plan = plan_version_files_rewrites(&groups).unwrap();
let got: Vec<(Option<&str>, &str, &str)> = plan
.iter()
.map(|r| (r.anchor.as_deref(), r.old.as_str(), r.new.as_str()))
.collect();
assert_eq!(
got,
vec![
(None, "0.10.0", "0.11.0"),
(Some(r"Operator\s+{version}"), "0.10.0", "0.10.1"),
],
"plan: {plan:?}"
);
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("docs")).unwrap();
let file = dir.path().join("docs/installation.md");
std::fs::write(
&file,
"cfgd 0.10.0\nOperator 0.10.0\ndownload/v0.10.0/cfgd\n",
)
.unwrap();
anodizer_core::version_files::rewrite_version_in_files(dir.path(), &plan, false).unwrap();
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"cfgd 0.11.0\nOperator 0.10.1\ndownload/v0.11.0/cfgd\n"
);
}
#[test]
fn plan_version_files_rewrites_two_bare_entries_still_conflict() {
let groups = vec![
group_result(
&["operator"],
&[("operator-v0.8.0", "m")],
&[("crates/operator", "0.8.0")],
Some("0.7.0"),
Some("operator-v0.7.0"),
vec![vec![vf("values.yaml")]],
),
group_result(
&["csi"],
&[("csi-v0.7.1", "m")],
&[("crates/csi", "0.7.1")],
Some("0.7.0"),
Some("csi-v0.7.0"),
vec![vec![vf("values.yaml")]],
),
];
let err = plan_version_files_rewrites(&groups)
.unwrap_err()
.to_string();
assert!(
err.contains("bumping FROM the same version to different versions")
&& err.contains("values.yaml"),
"two bare sweeps must still be refused, got: {err}"
);
}
#[test]
fn plan_version_files_rewrites_bare_beside_anchored_still_refuses_a_chain() {
let groups = vec![
group_result(
&["cfgd"],
&[("cfgd-v0.10.1", "m")],
&[("crates/cfgd", "0.10.1")],
Some("0.10.0"),
Some("cfgd-v0.10.0"),
vec![vec![vf("docs/installation.md")]],
),
group_result(
&["operator"],
&[("operator-v0.11.0", "m")],
&[("crates/operator", "0.11.0")],
Some("0.10.1"),
Some("operator-v0.10.1"),
vec![vec![vf_at("docs/installation.md", r"Operator\s+{version}")]],
),
];
let err = plan_version_files_rewrites(&groups)
.unwrap_err()
.to_string();
assert!(
err.contains("whose bumps chain") && err.contains("whole-file entry overlaps match"),
"the chain must still be refused and name the overlap, got: {err}"
);
}
#[test]
fn plan_version_files_rewrites_dedupes_identical_anchored_pair() {
let shared = r"pin: v{version}";
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![
vf_at("values.yaml", shared),
vf_at("chart.yaml", r"one: v{version}"),
],
vec![
vf_at("values.yaml", shared),
vf_at("chart.yaml", r"two: v{version}"),
],
],
)];
let plan = plan_version_files_rewrites(&groups).unwrap();
let values: Vec<&FileRewrite> = plan.iter().filter(|r| r.path == "values.yaml").collect();
assert_eq!(
values.len(),
1,
"identical anchored pair not deduped: {plan:?}"
);
assert_eq!(values[0].anchor.as_deref(), Some(shared));
let chart: Vec<Option<&str>> = plan
.iter()
.filter(|r| r.path == "chart.yaml")
.map(|r| r.anchor.as_deref())
.collect();
assert_eq!(
chart,
vec![Some(r"one: v{version}"), Some(r"two: v{version}")],
"two anchors on one file must both survive: {plan:?}"
);
}
#[test]
fn top_level_version_files_drive_a_single_crate_plan() {
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
version_files: Some(vec![
vf("README.md"),
vf_at("chart.yaml", r"app: v{version}"),
]),
..Default::default()
};
let files = top_level_version_files(&config);
assert_eq!(files.len(), 2, "top-level enrollment dropped: {files:?}");
let plan = version_files_plan(&files, "1.2.3", "1.3.0", "app").unwrap();
let got: Vec<(&str, Option<&str>)> = plan
.iter()
.map(|r| (r.path.as_str(), r.anchor.as_deref()))
.collect();
assert_eq!(
got,
vec![
("README.md", None),
("chart.yaml", Some(r"app: v{version}")),
],
"plan: {plan:?}"
);
}
#[test]
fn version_files_plan_keeps_an_identical_pair_on_one_file() {
for files in [
vec![vf("chart.yaml"), vf_at("chart.yaml", r"pin: v{version}")],
vec![vf_at("chart.yaml", r"pin: v{version}"), vf("chart.yaml")],
] {
let plan = version_files_plan(&files, "1.2.3", "1.2.3-rc1", "app").unwrap();
let mut anchors: Vec<Option<&str>> = plan.iter().map(|r| r.anchor.as_deref()).collect();
anchors.sort();
assert_eq!(
anchors,
vec![None, Some(r"pin: v{version}")],
"plan: {plan:?}"
);
}
}
#[test]
fn identical_pair_from_two_owners_is_not_a_chain() {
let groups = vec![
group_result(
&["core"],
&[("core-v1.2.3-rc1", "Release")],
&[("crates/core", "1.2.3-rc1")],
Some("1.2.3"),
Some("core-v1.2.3"),
vec![vec![vf("shared.md")]],
),
group_result(
&["cli"],
&[("cli-v1.2.3-rc1", "Release")],
&[("crates/cli", "1.2.3-rc1")],
Some("1.2.3"),
Some("cli-v1.2.3"),
vec![vec![vf_at("shared.md", r"pin: v{version}")]],
),
];
let plan = plan_version_files_rewrites(&groups).unwrap();
let mut anchors: Vec<Option<&str>> = plan.iter().map(|r| r.anchor.as_deref()).collect();
anchors.sort();
assert_eq!(
anchors,
vec![None, Some(r"pin: v{version}")],
"plan: {plan:?}"
);
}
#[test]
fn one_owner_chain_names_the_two_entries() {
let groups = vec![
group_result(
&["app"],
&[("app-v1.2.4", "Release")],
&[("crates/app", "1.2.4")],
Some("1.2.3"),
Some("app-v1.2.3"),
vec![vec![vf("chart.yaml")]],
),
group_result(
&["app"],
&[("app-v1.2.5", "Release")],
&[("crates/app", "1.2.5")],
Some("1.2.4"),
Some("app-v1.2.4"),
vec![vec![vf_at("chart.yaml", r"pin: v{version}")]],
),
];
let err = plan_version_files_rewrites(&groups)
.unwrap_err()
.to_string();
assert!(
err.contains(
"is enrolled twice by app whose bumps chain (the whole-file entry 1.2.3 → 1.2.4 \
then match pin: v{version} 1.2.4 → 1.2.5)"
),
"err: {err}"
);
assert!(!err.contains("enrolled by crates"), "err: {err}");
}
#[test]
fn two_owner_chain_names_the_crates() {
let groups = vec![
group_result(
&["core"],
&[("core-v0.2.0", "Release")],
&[("crates/core", "0.2.0")],
Some("0.1.0"),
Some("core-v0.1.0"),
vec![vec![vf("shared.md")]],
),
group_result(
&["cli"],
&[("cli-v0.3.0", "Release")],
&[("crates/cli", "0.3.0")],
Some("0.2.0"),
Some("cli-v0.2.0"),
vec![vec![vf("shared.md")]],
),
];
let err = plan_version_files_rewrites(&groups)
.unwrap_err()
.to_string();
assert!(
err.contains(
"is enrolled by crates whose bumps chain (core 0.1.0 → 0.2.0 then cli 0.2.0 → 0.3.0)"
),
"err: {err}"
);
}
#[test]
fn top_level_version_files_cover_a_single_declared_crate() {
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
crates: vec![anodizer_core::config::CrateConfig {
name: "app".to_string(),
path: "crates/app".to_string(),
..Default::default()
}],
version_files: Some(vec![vf("README.md")]),
..Default::default()
};
assert_eq!(
top_level_version_files(&config),
vec![vf("README.md")],
"a one-crate config's top-level enrollment was dropped"
);
}
#[test]
fn refresh_cargo_lock_runs_only_where_a_lockfile_is_committed() {
let log = StageLogger::new("tag", Verbosity::Normal);
let bare = tempfile::tempdir().unwrap();
std::fs::write(
bare.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::create_dir_all(bare.path().join("src")).unwrap();
std::fs::write(bare.path().join("src/lib.rs"), "").unwrap();
assert!(
!refresh_cargo_lock(bare.path(), &log),
"a repo with no lockfile has none to stage"
);
assert!(
!bare.path().join("Cargo.lock").is_file(),
"the refresh created a lockfile the repo does not commit"
);
let locked = tempfile::tempdir().unwrap();
std::fs::write(
locked.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::create_dir_all(locked.path().join("src")).unwrap();
std::fs::write(locked.path().join("src/lib.rs"), "").unwrap();
std::fs::write(
locked.path().join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"app\"\nversion = \"0.0.1\"\n",
)
.unwrap();
assert!(
refresh_cargo_lock(locked.path(), &log),
"a committed lockfile must be refreshed and staged"
);
}
#[test]
fn repo_level_bump_refuses_each_unusable_manifest_on_one_line() {
let log = StageLogger::new("tag", Verbosity::Normal);
enum Expect {
Exact(&'static str),
Halves(&'static str, &'static str),
}
let cases: Vec<(&str, Option<&str>, Expect)> = vec![
(
"missing",
None,
Expect::Exact(
"version_files: the repo-level bump must write 1.2.4 into Cargo.toml, but that \
manifest does not exist; create it, declare the crate under `crates:`, or drop \
the version_files enrollment",
),
),
(
"unparseable",
Some("[package\nname = \"app\"\n"),
Expect::Halves(
"version_files: the repo-level bump must write 1.2.4 into Cargo.toml, but that \
manifest cannot be read:",
"; fix the manifest, declare the crate under `crates:`, or drop the version_files \
enrollment",
),
),
(
"versionless",
Some("[workspace]\nmembers = [\"crates/app\"]\n"),
Expect::Exact(
"version_files: the repo-level bump must write 1.2.4 into a manifest, but \
Cargo.toml declares no [package].version; give the manifest a version, \
declare the crate under `crates:`, or drop the version_files enrollment",
),
),
];
for (shape, manifest, expect) in cases {
let tmp = tempfile::tempdir().unwrap();
if let Some(body) = manifest {
std::fs::write(tmp.path().join("Cargo.toml"), body).unwrap();
}
let err = bump_repo_level(
tmp.path(),
&RepoLevelBump {
manifest_dir: ".",
files: &[],
old_tag: "v1.2.3",
new_version: "1.2.4",
project_name: "app",
dry_run: true,
skip_ci_suffix: "",
},
&log,
)
.unwrap_err()
.to_string();
match expect {
Expect::Exact(expected) => assert_eq!(err, expected, "{shape}"),
Expect::Halves(prefix, suffix) => {
assert!(
err.starts_with(prefix),
"{shape}: expected {prefix:?}, got {err:?}"
);
assert!(
err.ends_with(suffix),
"{shape}: expected the repair sentence {suffix:?}, got {err:?}"
);
}
}
}
}
#[test]
fn repo_level_manifest_dir_is_the_root_without_crates() {
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
version_files: Some(vec![vf("README.md")]),
..Default::default()
};
assert_eq!(repo_level_manifest_dir(&config), Some(".".to_string()));
}
#[test]
fn repo_level_manifest_dir_is_a_lone_version_synced_crate() {
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
crates: vec![anodizer_core::config::CrateConfig {
name: "app".to_string(),
path: "crates/app".to_string(),
version_sync: Some(anodizer_core::config::VersionSyncConfig {
enabled: Some(true),
mode: None,
}),
..Default::default()
}],
version_files: Some(vec![vf("README.md")]),
..Default::default()
};
assert_eq!(
repo_level_manifest_dir(&config),
Some("crates/app".to_string())
);
}
#[test]
fn repo_level_manifest_dir_is_none_without_version_sync() {
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
crates: vec![anodizer_core::config::CrateConfig {
name: "app".to_string(),
path: "crates/app".to_string(),
..Default::default()
}],
version_files: Some(vec![vf("README.md")]),
..Default::default()
};
assert_eq!(repo_level_manifest_dir(&config), None);
}
#[test]
fn repo_level_manifest_dir_is_none_with_several_crates() {
let crate_cfg = |name: &str| anodizer_core::config::CrateConfig {
name: name.to_string(),
path: format!("crates/{name}"),
..Default::default()
};
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
crates: vec![crate_cfg("a"), crate_cfg("b")],
version_files: Some(vec![vf("README.md")]),
..Default::default()
};
assert_eq!(repo_level_manifest_dir(&config), None);
}
#[test]
fn top_level_version_files_yield_to_a_crates_own_list() {
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
crates: vec![anodizer_core::config::CrateConfig {
name: "app".to_string(),
path: "crates/app".to_string(),
version_files: Some(vec![vf("OWN.md")]),
..Default::default()
}],
version_files: Some(vec![vf("README.md")]),
..Default::default()
};
assert_eq!(top_level_version_files(&config), vec![vf("OWN.md")]);
}
#[test]
fn top_level_version_files_are_empty_when_several_crates_are_declared() {
let crate_cfg = |name: &str| anodizer_core::config::CrateConfig {
name: name.to_string(),
path: format!("crates/{name}"),
..Default::default()
};
let config = anodizer_core::config::Config {
project_name: "app".to_string(),
crates: vec![crate_cfg("a"), crate_cfg("b")],
version_files: Some(vec![vf("README.md")]),
..Default::default()
};
assert!(top_level_version_files(&config).is_empty());
}
#[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![vf("VERSION")]],
)];
let plan = plan_version_files_rewrites(&groups).unwrap();
assert!(plan.is_empty());
}
#[test]
fn the_lines_quoted_in_the_version_files_docs_are_what_the_commands_produce() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/site/content/docs/general/version-files.md"
);
let page = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}"));
let quoted = |label: &str| -> Vec<String> {
page.lines()
.filter_map(|line| line.trim_start().strip_prefix(label))
.map(str::to_string)
.collect::<Vec<_>>()
};
let warnings = quoted("Warning ");
let errors = quoted("Error ");
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let write = |rel: &str, body: &str| {
let at = root.join(rel);
std::fs::create_dir_all(at.parent().expect("parent")).expect("mkdir");
std::fs::write(at, body).expect("write fixture");
};
write(
"docs/install.md",
"curl -sSfL https://example.invalid/i.sh\n",
);
let (log, capture) = StageLogger::with_capture("tag", Verbosity::Normal);
version_plan::rewrite_and_stage_version_files(
root,
&[FileRewrite {
path: "docs/install.md".to_string(),
anchor: None,
old: "0.1.0".to_string(),
new: "0.2.0".to_string(),
owner: "myapp".to_string(),
}],
false,
&log,
)
.expect("a bare zero-match entry warns rather than failing");
write(
"chart/cfgd/values.yaml",
"csi:\n image: other/image:1.0.0\n",
);
let anchored = version_plan::rewrite_and_stage_version_files(
root,
&[FileRewrite {
path: "chart/cfgd/values.yaml".to_string(),
anchor: Some(r"csi:\s+image:.*:v{version}".to_string()),
old: "0.7.0".to_string(),
new: "0.8.0".to_string(),
owner: "cfgd-csi".to_string(),
}],
false,
&log,
)
.expect_err("an anchored entry matching nothing fails the tag");
write(
"Cargo.toml",
"[workspace]\nmembers = [\"crates/myapp\"]\nresolver = \"2\"\n",
);
write(
"crates/myapp/Cargo.toml",
"[package]\nname = \"myapp\"\nversion = \"0.2.0\"\nedition = \"2024\"\n",
);
write("crates/myapp/src/lib.rs", "");
write(
"charts/myapp/Chart.yaml",
"version: 0.1.0\nappVersion: v0.1.0\n",
);
let config: Config = serde_yaml_ng::from_str(
"project_name: myapp\n\
crates:\n - name: myapp\n path: crates/myapp\n \
tag_template: \"v{{ .Version }}\"\n version_files:\n \
- charts/myapp/Chart.yaml\n",
)
.expect("the loader accepts the fixture config");
let (guard_log, guard_capture) = StageLogger::with_capture("check", Verbosity::Normal);
let stale = crate::commands::check::version_files::run_guard(&config, root, &guard_log)
.expect_err("a drifted enrollment fails the guard");
let mut produced_errors: Vec<String> = guard_capture
.all_messages()
.into_iter()
.filter(|(level, _)| *level == anodizer_core::log::LogLevel::Error)
.map(|(_, message)| message)
.collect();
produced_errors.push(format!("{stale:#}"));
produced_errors.push(format!("{anchored:#}"));
assert_eq!(
warnings,
capture.warn_messages(),
"the page's Warning lines"
);
assert_eq!(errors, produced_errors, "the page's Error lines");
assert_eq!(warnings.len(), 1, "the warnings the page quotes");
assert_eq!(errors.len(), 3, "the errors the page quotes");
}
#[test]
fn the_refusal_quoted_in_the_changelog_docs_is_what_the_guard_produces() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/site/content/docs/more/changelog.md"
);
let page = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}"));
let quoted: Vec<String> = page
.lines()
.filter_map(|line| line.trim_start().strip_prefix("Error "))
.filter(|line| !line.contains('\u{2026}'))
.map(str::to_string)
.collect();
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let member = |rel: &str, name: &str, version: &str| {
let dir = root.join(rel);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n"),
)
.expect("write manifest");
};
member("crates/core", "core", "0.2.0");
member("crates/cli", "cli", "0.3.0");
let shared = |name: &str, path: &str| CrateConfig {
name: name.to_string(),
path: path.to_string(),
tag_template: Some("v{{ Version }}".to_string()),
..Default::default()
};
let err = repo_shape::check_shared_prefix_version_coherence(
&[shared("core", "crates/core"), shared("cli", "crates/cli")],
root,
None,
)
.expect_err("one shared prefix cannot carry two versions");
assert_eq!(quoted.len(), 1, "the refusals the page quotes");
assert_eq!(quoted[0], format!("{err:#}"));
}