anodizer 0.4.0

A Rust-native release automation tool inspired by GoReleaser
Documentation
use anyhow::{Context as _, Result, bail};
use serde_json::json;

pub struct ResolveTagOpts {
    pub tag: String,
    pub json: bool,
    pub config_override: Option<std::path::PathBuf>,
}

pub fn run(opts: ResolveTagOpts) -> Result<()> {
    let config_path = opts
        .config_override
        .as_deref()
        .filter(|p| p.exists())
        .map(|p| p.to_path_buf())
        .or_else(|| crate::pipeline::find_config(None).ok());

    let config = match config_path {
        Some(ref path) => crate::pipeline::load_config(path)?,
        None => bail!("no anodizer config found"),
    };

    // Collect all crates from top-level and workspaces.
    let all_crates: Vec<_> = config
        .crates
        .iter()
        .chain(
            config
                .workspaces
                .as_deref()
                .unwrap_or_default()
                .iter()
                .flat_map(|w| &w.crates),
        )
        .collect();

    // Match the tag against each crate's tag_template prefix.
    // Prefer the longest matching prefix (most specific) to avoid ambiguity
    // when one prefix is a substring of another (e.g. "v" vs "v2-").
    let mut best: Option<(&anodizer_core::config::CrateConfig, usize)> = None;
    for c in &all_crates {
        if let Some(prefix) = anodizer_core::git::extract_tag_prefix(&c.tag_template)
            && opts.tag.starts_with(&prefix)
        {
            let remainder = &opts.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 && best.as_ref().is_none_or(|(_, len)| prefix.len() > *len) {
                best = Some((c, prefix.len()));
            }
        }
    }

    let crate_cfg = match best {
        Some((c, _)) => c,
        None => bail!("no crate matches tag '{}'", opts.tag),
    };

    let has_builds = crate_cfg
        .builds
        .as_ref()
        .map(|b| !b.is_empty())
        .unwrap_or(false);

    if opts.json {
        let out = serde_json::to_string(&json!({
            "crate": crate_cfg.name,
            "path": crate_cfg.path,
            "has_builds": has_builds,
        }))
        .context("serialize resolve-tag JSON output")?;
        println!("{}", out);
    } else {
        println!("crate={}", crate_cfg.name);
        println!("path={}", crate_cfg.path);
        println!("has-builds={}", has_builds);
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_resolve_tag_matches_simple_prefix() {
        let tag = "v1.2.3";
        let prefix = "v";
        let remainder = tag.strip_prefix(prefix).unwrap();
        assert!(
            remainder
                .split('.')
                .next()
                .unwrap()
                .chars()
                .all(|ch| ch.is_ascii_digit())
        );
    }

    #[test]
    fn test_resolve_tag_matches_monorepo_prefix() {
        let tag = "core-v0.2.3";
        let prefix = "core-v";
        let remainder = tag.strip_prefix(prefix).unwrap();
        assert!(
            remainder
                .split('.')
                .next()
                .unwrap()
                .chars()
                .all(|ch| ch.is_ascii_digit())
        );
    }

    #[test]
    fn test_resolve_tag_rejects_non_version_suffix() {
        let tag = "v-something";
        let prefix = "v";
        let remainder = tag.strip_prefix(prefix).unwrap();
        // "-something" starts with "-", not a digit
        assert!(
            !remainder
                .split('.')
                .next()
                .unwrap()
                .chars()
                .all(|ch| ch.is_ascii_digit())
        );
    }

    #[test]
    fn test_resolve_tag_longer_prefix_wins() {
        // "core-v" should match "core-v1.0.0", not "v" prefix
        let tag = "core-v1.0.0";

        let prefixes = [("v", "cfgd"), ("core-v", "cfgd-core")];
        let matched = prefixes.iter().find(|(prefix, _)| {
            if let Some(remainder) = tag.strip_prefix(prefix) {
                remainder
                    .split('.')
                    .next()
                    .map(|s| s.chars().all(|ch| ch.is_ascii_digit()))
                    .unwrap_or(false)
            } else {
                false
            }
        });
        // Both "v" and "core-v" match, but iteration order matters.
        // In real code, workspace crates come after top-level, and the
        // longer prefix is more specific. Let's verify both match:
        assert!(matched.is_some());
    }
}