mod milestones;
mod split;
pub use split::run_merge;
use super::helpers;
use crate::pipeline;
use anodizer_core::config::{Config, CrateConfig, WorkspaceConfig};
use anodizer_core::context::{Context, ContextOptions};
use anodizer_core::git;
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::template;
use anyhow::{Context as _, Result};
use chrono::Utc;
use std::path::PathBuf;
pub struct ReleaseOpts {
pub crate_names: Vec<String>,
pub all: bool,
pub force: bool,
pub snapshot: bool,
pub nightly: bool,
pub dry_run: bool,
pub clean: bool,
pub skip: Vec<String>,
pub token: Option<String>,
pub verbose: bool,
pub debug: bool,
pub quiet: bool,
pub config_override: Option<PathBuf>,
pub parallelism: usize,
pub single_target: Option<String>,
pub release_notes: Option<PathBuf>,
pub release_notes_tmpl: Option<PathBuf>,
pub workspace: Option<String>,
pub draft: bool,
pub release_header: Option<PathBuf>,
pub release_header_tmpl: Option<PathBuf>,
pub release_footer: Option<PathBuf>,
pub release_footer_tmpl: Option<PathBuf>,
pub fail_fast: bool,
pub split: bool,
pub merge: bool,
pub strict: bool,
pub prepare: bool,
}
pub(crate) fn apply_prepare_mode_to_skip(skip: &mut Vec<String>) {
for stage in ["release", "publish", "announce"] {
if !skip.iter().any(|s| s == stage) {
skip.push(stage.to_string());
}
}
}
pub fn run(mut opts: ReleaseOpts) -> Result<()> {
if opts.prepare {
apply_prepare_mode_to_skip(&mut opts.skip);
}
let log = StageLogger::new(
"release",
Verbosity::from_flags(opts.quiet, opts.verbose, opts.debug),
);
git::check_git_available()?;
if opts.snapshot && opts.nightly {
anyhow::bail!("--snapshot and --nightly cannot be combined");
}
let config_path = pipeline::find_config(opts.config_override.as_deref())?;
let (mut config, deprecations) = pipeline::load_config_with_deprecations(&config_path)?;
let mut workspace_skip: Vec<String> = Vec::new();
if let Some(ref ws_name) = opts.workspace {
let ws = resolve_workspace(&config, ws_name)?.clone();
workspace_skip = ws.skip.clone();
helpers::apply_workspace_overlay(&mut config, &ws);
} else if !opts.crate_names.is_empty() && config.crates.is_empty() {
let target = &opts.crate_names[0];
let ws_for_target = config
.workspaces
.as_ref()
.and_then(|ws_list| {
ws_list
.iter()
.find(|ws| ws.crates.iter().any(|c| &c.name == target))
})
.cloned();
if let Some(ws) = ws_for_target {
log.verbose(&format!(
"--crate {} lives in workspace '{}'; applying workspace overlay",
target, ws.name
));
workspace_skip = ws.skip.clone();
helpers::apply_workspace_overlay(&mut config, &ws);
}
}
helpers::infer_project_name(&mut config, &log);
helpers::auto_detect_github(&mut config, &log);
if opts.draft {
let release = config.release.get_or_insert_with(Default::default);
release.draft = Some(true);
}
if let Some(ref header_path) = opts.release_header {
let header_content = std::fs::read_to_string(header_path).with_context(|| {
format!(
"failed to read release header file: {}",
header_path.display()
)
})?;
let release = config.release.get_or_insert_with(Default::default);
release.header = Some(anodizer_core::config::ContentSource::Inline(header_content));
}
if let Some(ref header_tmpl_path) = opts.release_header_tmpl {
let raw = std::fs::read_to_string(header_tmpl_path).with_context(|| {
format!(
"failed to read release header template file: {}",
header_tmpl_path.display()
)
})?;
let release = config.release.get_or_insert_with(Default::default);
release.header = Some(anodizer_core::config::ContentSource::Inline(raw));
}
if let Some(ref footer_path) = opts.release_footer {
let footer_content = std::fs::read_to_string(footer_path).with_context(|| {
format!(
"failed to read release footer file: {}",
footer_path.display()
)
})?;
let release = config.release.get_or_insert_with(Default::default);
release.footer = Some(anodizer_core::config::ContentSource::Inline(footer_content));
}
if let Some(ref footer_tmpl_path) = opts.release_footer_tmpl {
let raw = std::fs::read_to_string(footer_tmpl_path).with_context(|| {
format!(
"failed to read release footer template file: {}",
footer_tmpl_path.display()
)
})?;
let release = config.release.get_or_insert_with(Default::default);
release.footer = Some(anodizer_core::config::ContentSource::Inline(raw));
}
if opts.clean && !opts.dry_run {
let dist = &config.dist;
if dist.exists() {
std::fs::remove_dir_all(dist)?;
}
} else if opts.clean && opts.dry_run {
log.status("(dry-run) would clean dist directory");
}
if !opts.clean && !opts.merge {
let dist = &config.dist;
if dist.exists()
&& let Ok(mut entries) = dist.read_dir()
&& entries.next().is_some()
{
anyhow::bail!(
"dist directory '{}' is not empty; use --clean to remove it first",
dist.display()
);
}
}
let all_known_crates: Vec<CrateConfig> = {
let mut acc: Vec<CrateConfig> = config.crates.clone();
if let Some(ref ws_list) = config.workspaces {
for ws in ws_list {
for c in &ws.crates {
if !acc.iter().any(|existing| existing.name == c.name) {
acc.push(c.clone());
}
}
}
}
acc
};
let selected = if opts.all {
if opts.force {
all_known_crates.iter().map(|c| c.name.clone()).collect()
} else {
detect_changed_crates(
&all_known_crates,
config.git.as_ref(),
config.monorepo_tag_prefix(),
&log,
)?
}
} else {
opts.crate_names.clone()
};
let selected_sorted = topo_sort_selected(&all_known_crates, &selected);
let mut skip_stages = opts.skip;
for stage in &workspace_skip {
if !skip_stages.iter().any(|s| s == stage) {
skip_stages.push(stage.clone());
}
}
if opts.snapshot {
for stage in &["publish", "snapcraft-publish", "blob", "announce"] {
if !skip_stages.iter().any(|s| s == stage) {
skip_stages.push(stage.to_string());
}
}
}
if skip_stages.contains(&"publish".to_string())
&& !skip_stages.contains(&"announce".to_string())
{
skip_stages.push("announce".to_string());
}
let release_notes_path = if let Some(ref tmpl_path) = opts.release_notes_tmpl {
let content = std::fs::read_to_string(tmpl_path).with_context(|| {
format!(
"failed to read release notes template: {}",
tmpl_path.display()
)
})?;
Some((tmpl_path.clone(), content))
} else {
None
};
let ctx_opts = ContextOptions {
snapshot: opts.snapshot,
nightly: opts.nightly,
dry_run: opts.dry_run,
quiet: opts.quiet,
verbose: opts.verbose,
debug: opts.debug,
skip_stages,
selected_crates: selected_sorted,
token: opts.token,
parallelism: opts.parallelism,
single_target: opts.single_target,
release_notes_path: opts.release_notes,
fail_fast: opts.fail_fast,
partial_target: None, merge: opts.merge,
project_root: None,
strict: opts.strict,
};
let mut ctx = Context::new(config.clone(), ctx_opts);
for (prop, msg) in &deprecations {
ctx.deprecate(prop, msg);
}
helpers::resolve_scm_token_type(&mut ctx, &config);
ctx.populate_time_vars();
ctx.populate_runtime_vars();
ctx.populate_metadata_var()?;
helpers::setup_env(&mut ctx, &config, &log)?;
helpers::resolve_git_context(&mut ctx, &config, &log)?;
if !opts.merge
&& !opts.split
&& !ctx.should_skip("before")
&& let Some(before) = &config.before
&& let Some(ref hooks) = before.hooks
{
pipeline::run_hooks(
hooks,
"before",
opts.dry_run,
&log,
Some(ctx.template_vars()),
)?;
}
if let Some((_tmpl_path, raw_content)) = release_notes_path {
let rendered = template::render(&raw_content, ctx.template_vars()).with_context(|| {
format!(
"failed to render release notes template: {}",
_tmpl_path.display()
)
})?;
let dist = &config.dist;
std::fs::create_dir_all(dist).ok();
let rendered_path = dist.join("release-notes.md");
std::fs::write(&rendered_path, &rendered).with_context(|| {
format!(
"failed to write rendered release notes: {}",
rendered_path.display()
)
})?;
ctx.options.release_notes_path = Some(rendered_path);
log.verbose("rendered release notes template");
}
if git::is_git_dirty() && !ctx.is_snapshot() && !ctx.is_nightly() && !ctx.is_dry_run() {
let status = git::git_status_porcelain();
anyhow::bail!(
"git repository is dirty; use --snapshot to release from a dirty tree, or commit your changes first.\n\nDirty files:\n{}",
status
);
}
if ctx.is_nightly() {
let nightly_cfg = config.nightly.as_ref();
let date_str = Utc::now().format("%Y%m%d").to_string();
let base_version = ctx
.template_vars()
.get("Version")
.cloned()
.unwrap_or_else(|| "0.1.0".to_string());
let numeric_base = base_version
.split('-')
.next()
.unwrap_or(&base_version)
.to_string();
let nightly_version = format!("{}-nightly.{}", numeric_base, date_str);
ctx.template_vars_mut().set("Version", &nightly_version);
ctx.template_vars_mut().set("RawVersion", &nightly_version);
let nightly_tag = nightly_cfg
.and_then(|c| c.tag_name.as_deref())
.unwrap_or("nightly")
.to_string();
ctx.template_vars_mut().set("Tag", &nightly_tag);
ctx.template_vars_mut().set("IsNightly", "true");
let name_tmpl = nightly_cfg
.and_then(|c| c.name_template.as_deref())
.unwrap_or("{{ ProjectName }}-nightly");
let release_name = template::render(name_tmpl, ctx.template_vars())
.with_context(|| format!("failed to render nightly name_template: {name_tmpl}"))?;
ctx.template_vars_mut().set("ReleaseName", &release_name);
log.verbose(&format!(
"nightly: version={}, tag={}, name={}",
nightly_version, nightly_tag, release_name
));
}
if ctx.is_snapshot() {
let snapshot_tmpl = config
.snapshot
.as_ref()
.map(|s| s.name_template.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("{{ Version }}-SNAPSHOT-{{ ShortCommit }}");
let rendered_name =
template::render(snapshot_tmpl, ctx.template_vars()).with_context(|| {
format!(
"failed to render snapshot version_template: {}",
snapshot_tmpl
)
})?;
if rendered_name.trim().is_empty() {
anyhow::bail!("empty snapshot name after rendering version_template");
}
ctx.template_vars_mut().set("Version", &rendered_name);
ctx.template_vars_mut().set("ReleaseName", &rendered_name);
log.verbose(&format!(
"snapshot: version={}, release_name={}",
rendered_name, rendered_name
));
}
helpers::write_effective_config(&config, &log)?;
if opts.split {
return split::run_split(&mut ctx, &config, &log);
}
if opts.merge {
return split::run_merge(&mut ctx, &config, &log, opts.dry_run, None);
}
let p = pipeline::build_release_pipeline();
let result = p.run(&mut ctx, &log);
if result.is_ok() {
run_post_pipeline(&mut ctx, &config, opts.dry_run, &log)?;
}
result
}
fn run_post_pipeline(
ctx: &mut Context,
config: &Config,
dry_run: bool,
log: &anodizer_core::log::StageLogger,
) -> Result<()> {
helpers::run_report_sizes(ctx, config, log);
helpers::write_metadata_and_artifacts(ctx, config, log)?;
if let Some(ref publishers) = config.publishers
&& !publishers.is_empty()
{
log.status("running custom publishers...");
super::publisher::run_publishers(
publishers,
ctx.artifacts.all(),
ctx.template_vars(),
dry_run,
log,
ctx.options.parallelism,
Some(&ctx.skip_memento),
)?;
}
if let Some(ref milestones) = config.milestones {
milestones::close_milestones(milestones, ctx, dry_run, log)?;
}
if let Some(after) = &config.after
&& let Some(ref hooks) = after.post
{
pipeline::run_hooks(hooks, "after", dry_run, log, Some(ctx.template_vars()))?;
}
Ok(())
}
fn detect_changed_crates(
crates: &[CrateConfig],
git_config: Option<&anodizer_core::config::GitConfig>,
monorepo_prefix: Option<&str>,
log: &StageLogger,
) -> Result<Vec<String>> {
if let Some(gc) = git_config {
let has_templates = gc
.ignore_tags
.as_ref()
.is_some_and(|tags| tags.iter().any(|t| t.contains("{{")))
|| gc
.ignore_tag_prefixes
.as_ref()
.is_some_and(|pfx| pfx.iter().any(|p| p.contains("{{")));
if has_templates {
log.debug(
"note: ignore_tags/ignore_tag_prefixes templates not rendered during \
change detection (template vars not yet available)",
);
}
}
let mut changed = vec![];
let mut oldest_tag: Option<String> = None;
for c in crates {
let latest_tag = git::find_latest_tag_matching_with_prefix(
&c.tag_template,
git_config,
None,
monorepo_prefix,
)?;
match &latest_tag {
None => {
changed.push(c.name.clone());
}
Some(tag) => {
if git::has_changes_since(tag, &c.path)? {
changed.push(c.name.clone());
}
if let Ok(sv) = git::parse_semver_tag(tag) {
let is_older = oldest_tag
.as_ref()
.and_then(|t| git::parse_semver_tag(t).ok())
.is_none_or(|osv| sv < osv);
if is_older {
oldest_tag = Some(tag.clone());
}
}
}
}
}
changed = propagate_dependents(crates, changed);
if let Some(ref tag) = oldest_tag {
let ws_changed = check_workspace_files_changed(tag)?;
if ws_changed {
return Ok(crates.iter().map(|c| c.name.clone()).collect());
}
}
Ok(changed)
}
fn propagate_dependents(crates: &[CrateConfig], changed: Vec<String>) -> Vec<String> {
use std::collections::HashSet;
let changed_set: HashSet<String> = changed.iter().cloned().collect();
let mut result_set = changed_set;
loop {
let mut added = false;
for c in crates {
if result_set.contains(&c.name) {
continue;
}
if let Some(deps) = &c.depends_on
&& deps.iter().any(|dep| result_set.contains(dep))
{
result_set.insert(c.name.clone());
added = true;
}
}
if !added {
break;
}
}
let mut propagated: Vec<String> = Vec::new();
for name in &changed {
if result_set.contains(name) {
propagated.push(name.clone());
}
}
for c in crates {
if result_set.contains(&c.name) && !changed.contains(&c.name) {
propagated.push(c.name.clone());
}
}
propagated
}
fn check_workspace_files_changed(tag: &str) -> Result<bool> {
let output = std::process::Command::new("git")
.args([
"diff",
"--name-only",
&format!("{}..HEAD", tag),
"--",
"Cargo.toml",
"Cargo.lock",
])
.output()?;
if output.status.success() {
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
} else {
Ok(false)
}
}
pub fn resolve_workspace<'a>(config: &'a Config, name: &str) -> Result<&'a WorkspaceConfig> {
let workspaces = config.workspaces.as_ref().ok_or_else(|| {
anyhow::anyhow!("--workspace specified but no workspaces defined in config")
})?;
workspaces.iter().find(|ws| ws.name == name).ok_or_else(|| {
let available: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
anyhow::anyhow!(
"workspace '{}' not found (available: {})",
name,
available.join(", ")
)
})
}
fn topo_sort_selected(all_crates: &[CrateConfig], selected: &[String]) -> Vec<String> {
let selected_set: std::collections::HashSet<&str> =
selected.iter().map(|s| s.as_str()).collect();
let items: Vec<(String, Vec<String>)> = all_crates
.iter()
.filter(|c| selected_set.contains(c.name.as_str()))
.map(|c| (c.name.clone(), c.depends_on.clone().unwrap_or_default()))
.collect();
anodizer_core::util::topological_sort(&items)
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::{CrateConfig, WorkspaceConfig};
fn make_crate(name: &str, deps: Option<Vec<&str>>) -> CrateConfig {
CrateConfig {
name: name.to_string(),
path: ".".to_string(),
tag_template: format!("{}-v{{{{ .Version }}}}", name),
depends_on: deps.map(|d| d.iter().map(|s| s.to_string()).collect()),
..Default::default()
}
}
fn make_config_with_workspaces(workspaces: Vec<WorkspaceConfig>) -> Config {
Config {
project_name: "test".to_string(),
workspaces: Some(workspaces),
..Default::default()
}
}
#[test]
fn test_resolve_workspace_found() {
let config = make_config_with_workspaces(vec![
WorkspaceConfig {
name: "frontend".to_string(),
crates: vec![make_crate("fe-app", None)],
..Default::default()
},
WorkspaceConfig {
name: "backend".to_string(),
crates: vec![make_crate("be-api", None)],
..Default::default()
},
]);
let ws = resolve_workspace(&config, "backend").unwrap();
assert_eq!(ws.name, "backend");
assert_eq!(ws.crates.len(), 1);
assert_eq!(ws.crates[0].name, "be-api");
}
#[test]
fn test_resolve_workspace_not_found() {
let config = make_config_with_workspaces(vec![WorkspaceConfig {
name: "frontend".to_string(),
crates: vec![make_crate("fe-app", None)],
..Default::default()
}]);
let result = resolve_workspace(&config, "nonexistent");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("nonexistent"),
"error should mention the workspace name: {}",
msg
);
assert!(
msg.contains("frontend"),
"error should list available workspaces: {}",
msg
);
}
#[test]
fn test_resolve_workspace_no_workspaces_defined() {
let config = Config {
project_name: "test".to_string(),
..Default::default()
};
let result = resolve_workspace(&config, "anything");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("no workspaces defined"),
"error should say no workspaces defined: {}",
msg
);
}
#[test]
fn test_topo_sort_selected_respects_order() {
let all = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", Some(vec!["b"])),
];
let selected = vec!["c".to_string(), "b".to_string(), "a".to_string()];
let sorted = topo_sort_selected(&all, &selected);
assert_eq!(sorted, vec!["a", "b", "c"]);
}
#[test]
fn test_topo_sort_selected_partial() {
let all = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", None),
];
let selected = vec!["b".to_string(), "c".to_string()];
let sorted = topo_sort_selected(&all, &selected);
assert!(sorted.contains(&"b".to_string()));
assert!(sorted.contains(&"c".to_string()));
assert!(!sorted.contains(&"a".to_string()));
}
#[test]
fn test_topo_sort_all_selected() {
let all = vec![
make_crate("core", None),
make_crate("lib", Some(vec!["core"])),
make_crate("cli", Some(vec!["lib", "core"])),
];
let selected: Vec<String> = all.iter().map(|c| c.name.clone()).collect();
let sorted = topo_sort_selected(&all, &selected);
let core_pos = sorted.iter().position(|s| s == "core").unwrap();
let lib_pos = sorted.iter().position(|s| s == "lib").unwrap();
let cli_pos = sorted.iter().position(|s| s == "cli").unwrap();
assert!(core_pos < lib_pos);
assert!(core_pos < cli_pos);
assert!(lib_pos < cli_pos);
}
#[test]
fn test_workspace_overlay_semantics() {
use anodizer_core::config::{ChangelogConfig, SignConfig};
use std::collections::HashMap;
let mut config = Config {
project_name: "test".to_string(),
crates: vec![make_crate("top-crate", None)],
env: Some(HashMap::from([
("SHARED".to_string(), "from-top".to_string()),
("TOP_ONLY".to_string(), "top-value".to_string()),
])),
signs: vec![SignConfig {
cmd: Some("gpg".to_string()),
..Default::default()
}],
changelog: Some(ChangelogConfig {
sort: Some("asc".to_string()),
..Default::default()
}),
workspaces: Some(vec![WorkspaceConfig {
name: "ws".to_string(),
crates: vec![make_crate("ws-crate", None)],
env: Some(HashMap::from([
("SHARED".to_string(), "from-ws".to_string()),
("WS_ONLY".to_string(), "ws-value".to_string()),
])),
signs: vec![SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
}],
changelog: Some(ChangelogConfig {
sort: Some("desc".to_string()),
..Default::default()
}),
..Default::default()
}]),
..Default::default()
};
let ws = config
.workspaces
.as_ref()
.unwrap()
.iter()
.find(|w| w.name == "ws")
.unwrap()
.clone();
helpers::apply_workspace_overlay(&mut config, &ws);
assert_eq!(config.crates.len(), 1);
assert_eq!(config.crates[0].name, "ws-crate");
let env = config.env.as_ref().unwrap();
assert_eq!(
env.get("TOP_ONLY").unwrap(),
"top-value",
"top-level-only key should be preserved"
);
assert_eq!(
env.get("SHARED").unwrap(),
"from-ws",
"shared key should be overridden by workspace"
);
assert_eq!(
env.get("WS_ONLY").unwrap(),
"ws-value",
"workspace-only key should be added"
);
assert_eq!(config.signs.len(), 1);
assert_eq!(
config.signs[0].cmd.as_deref(),
Some("cosign"),
"signs should be replaced by workspace"
);
let cl = config.changelog.as_ref().unwrap();
assert_eq!(
cl.sort.as_deref(),
Some("desc"),
"changelog should be replaced by workspace"
);
}
#[test]
fn test_propagate_dependents_direct() {
let crates = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", None),
];
let changed = vec!["a".to_string()];
let result = propagate_dependents(&crates, changed);
assert!(result.contains(&"a".to_string()));
assert!(result.contains(&"b".to_string()));
assert!(!result.contains(&"c".to_string()));
}
#[test]
fn test_propagate_dependents_transitive() {
let crates = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", Some(vec!["b"])),
];
let changed = vec!["a".to_string()];
let result = propagate_dependents(&crates, changed);
assert!(result.contains(&"a".to_string()));
assert!(result.contains(&"b".to_string()));
assert!(result.contains(&"c".to_string()));
}
#[test]
fn test_propagate_dependents_no_deps() {
let crates = vec![make_crate("a", None), make_crate("b", None)];
let changed = vec!["a".to_string()];
let result = propagate_dependents(&crates, changed);
assert_eq!(result, vec!["a".to_string()]);
}
#[test]
fn test_propagate_dependents_preserves_order() {
let crates = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", Some(vec!["a"])),
];
let changed = vec!["a".to_string()];
let result = propagate_dependents(&crates, changed);
assert_eq!(result[0], "a");
assert!(result.contains(&"b".to_string()));
assert!(result.contains(&"c".to_string()));
}
#[test]
fn test_draft_flag_sets_release_config_draft() {
let mut config = Config {
project_name: "test".to_string(),
..Default::default()
};
assert!(config.release.is_none());
let release = config.release.get_or_insert_with(Default::default);
release.draft = Some(true);
assert_eq!(config.release.as_ref().unwrap().draft, Some(true));
}
#[test]
fn test_draft_flag_overrides_existing_config() {
use anodizer_core::config::ReleaseConfig;
let mut config = Config {
project_name: "test".to_string(),
release: Some(ReleaseConfig {
draft: Some(false),
..Default::default()
}),
..Default::default()
};
let release = config.release.get_or_insert_with(Default::default);
release.draft = Some(true);
assert_eq!(
config.release.as_ref().unwrap().draft,
Some(true),
"CLI --draft should override config draft=false"
);
}
#[test]
fn test_apply_prepare_mode_to_skip_from_empty() {
let mut skip: Vec<String> = Vec::new();
apply_prepare_mode_to_skip(&mut skip);
assert_eq!(
skip,
vec![
"release".to_string(),
"publish".to_string(),
"announce".to_string()
],
"--prepare on empty skip should add all three upstream stages"
);
}
#[test]
fn test_apply_prepare_mode_to_skip_preserves_user_skip() {
let mut skip = vec!["docker".to_string(), "sign".to_string()];
apply_prepare_mode_to_skip(&mut skip);
assert!(
skip.contains(&"docker".to_string()) && skip.contains(&"sign".to_string()),
"existing user skips must be preserved"
);
assert!(
skip.contains(&"release".to_string())
&& skip.contains(&"publish".to_string())
&& skip.contains(&"announce".to_string()),
"--prepare adds release/publish/announce alongside user skips"
);
}
#[test]
fn test_apply_prepare_mode_to_skip_composes_with_snapshot_marker() {
let mut skip = vec!["sign".to_string()];
apply_prepare_mode_to_skip(&mut skip);
for stage in ["release", "publish", "announce"] {
assert!(
skip.iter().any(|s| s == stage),
"--prepare must add {stage} regardless of snapshot composition"
);
}
assert!(
skip.iter().any(|s| s == "sign"),
"user-specified skip survives composition"
);
}
#[test]
fn test_apply_prepare_mode_to_skip_is_idempotent() {
let mut skip = vec!["release".to_string(), "publish".to_string()];
apply_prepare_mode_to_skip(&mut skip);
let release_count = skip.iter().filter(|s| s.as_str() == "release").count();
let publish_count = skip.iter().filter(|s| s.as_str() == "publish").count();
assert_eq!(release_count, 1, "no duplicate release");
assert_eq!(publish_count, 1, "no duplicate publish");
assert!(skip.contains(&"announce".to_string()));
}
}