use super::*;
pub(crate) fn resolve_selected_crates(
opts: &ReleaseOpts,
all_known_crates: &[CrateConfig],
config: &Config,
log: &StageLogger,
) -> Result<Vec<String>> {
let selected = if opts.all {
if opts.force {
all_known_crates.iter().map(|c| c.name.clone()).collect()
} else {
let workspace_root =
crate::commands::helpers::discover_workspace_root(opts.config_override.as_deref())?;
detect_changed_crates(
&workspace_root,
all_known_crates,
config.git.as_ref(),
config.monorepo_tag_prefix(),
log,
)?
}
} else if !opts.crate_names.is_empty() {
opts.crate_names.clone()
} else {
map_head_tags_to_crates(all_known_crates, log)?
};
Ok(topo_sort_selected(all_known_crates, &selected))
}
pub(crate) fn map_head_tags_to_crates(
all_known_crates: &[CrateConfig],
log: &StageLogger,
) -> Result<Vec<String>> {
let head_tags = git::get_tags_at_head().with_context(|| "failed to read tags at HEAD")?;
if head_tags.is_empty() {
log.verbose("no tags at HEAD — release no-op");
return Ok(Vec::new());
}
log.verbose(&format!("tags at HEAD = {}", head_tags.join(", ")));
Ok(select_crates_for_tags(&head_tags, all_known_crates, log))
}
pub(crate) fn select_crates_for_tags(
head_tags: &[String],
all_known_crates: &[CrateConfig],
log: &StageLogger,
) -> Vec<String> {
let mut selected: Vec<String> = Vec::new();
for tag in head_tags {
let matches = resolve_tag_to_crates(tag, all_known_crates);
if matches.is_empty() {
log.verbose(&format!(
"skipped tag '{}' — matches no configured crate",
tag
));
continue;
}
for c in matches {
if !selected.contains(&c.name) {
selected.push(c.name.clone());
log.verbose(&format!("tag '{}' → crate '{}'", tag, c.name));
}
}
}
selected
}
pub(crate) fn resolve_tag_to_crates<'a>(
tag: &str,
crates: &'a [CrateConfig],
) -> Vec<&'a CrateConfig> {
let mut best_len: Option<usize> = None;
let mut matched: Vec<(&CrateConfig, usize)> = Vec::new();
for c in crates {
if let Some(prefix) = git::extract_tag_prefix(c.tag_template.as_deref().unwrap_or(""))
&& tag.starts_with(&prefix)
{
let remainder = &tag[prefix.len()..];
let is_version = remainder
.split('.')
.next()
.is_some_and(|s| !s.is_empty() && s.chars().all(|ch| ch.is_ascii_digit()));
if is_version {
let len = prefix.len();
best_len = Some(best_len.map_or(len, |b| b.max(len)));
matched.push((c, len));
}
}
}
let Some(best) = best_len else {
return Vec::new();
};
matched
.into_iter()
.filter(|(_, len)| *len == best)
.map(|(c, _)| c)
.collect()
}
pub(crate) const SNAPSHOT_AUTO_SKIP: &[&str] =
&["publish", "snapcraft-publish", "blob", "announce"];
pub(crate) fn compute_skip_stages(
mut skip_stages: Vec<String>,
workspace_skip: &[String],
snapshot: bool,
) -> Vec<String> {
helpers::merge_skip_stages(&mut skip_stages, workspace_skip);
if snapshot {
helpers::merge_skip_stages(&mut skip_stages, SNAPSHOT_AUTO_SKIP);
}
if skip_stages.contains(&"publish".to_string())
&& !skip_stages.contains(&"announce".to_string())
{
skip_stages.push("announce".to_string());
}
skip_stages
}
pub(crate) fn detect_changed_crates_pub(
workspace_root: &Path,
crates: &[CrateConfig],
git_config: Option<&anodizer_core::config::GitConfig>,
monorepo_prefix: Option<&str>,
log: &StageLogger,
) -> Result<Vec<String>> {
detect_changed_crates(workspace_root, crates, git_config, monorepo_prefix, log)
}
pub(crate) fn detect_changed_crates(
workspace_root: &Path,
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(
"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.resolved_tag_template(),
git_config,
None,
monorepo_prefix,
)?;
match &latest_tag {
None => {
changed.push(c.name.clone());
}
Some(tag) => {
if git::has_changes_since_in(workspace_root, 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(workspace_root, tag)?;
if ws_changed {
return Ok(crates.iter().map(|c| c.name.clone()).collect());
}
}
Ok(changed)
}
pub(crate) 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
}
pub(crate) fn check_workspace_files_changed(workspace_root: &Path, tag: &str) -> Result<bool> {
anodizer_core::git::paths_changed_since_tag_in(
workspace_root,
tag,
&["Cargo.toml", "Cargo.lock"],
)
}
pub(crate) 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)
}