use anyhow::{Context as _, Result, bail};
use serde_json::json;
pub(crate) struct ResolveTagOpts {
pub tag: String,
pub json: bool,
pub config_override: Option<std::path::PathBuf>,
}
pub(crate) 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"),
};
let all_crates: Vec<anodizer_core::config::CrateConfig> =
config.crate_universe().into_iter().cloned().collect();
let Some(crate_cfg) = resolve_owner(&opts.tag, &all_crates) else {
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(())
}
pub(crate) fn resolve_owner<'a>(
tag: &str,
crates: &'a [anodizer_core::config::CrateConfig],
) -> Option<&'a anodizer_core::config::CrateConfig> {
crate::commands::release::resolve_tag_to_crates(tag, crates)
.into_iter()
.next()
}
#[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();
assert!(
!remainder
.split('.')
.next()
.unwrap()
.chars()
.all(|ch| ch.is_ascii_digit())
);
}
#[test]
fn test_resolve_tag_longer_prefix_wins() {
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
}
});
assert!(matched.is_some());
}
use super::{ResolveTagOpts, run};
use serial_test::serial;
use std::fs;
fn write_simple_config(dir: &std::path::Path) -> std::path::PathBuf {
let p = dir.join(".anodizer.yaml");
fs::write(
&p,
r#"project_name: app
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
"#,
)
.unwrap();
p
}
fn write_workspace_config(dir: &std::path::Path) -> std::path::PathBuf {
let p = dir.join(".anodizer.yaml");
fs::write(
&p,
r#"project_name: app
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
workspaces:
- name: core
crates:
- name: app-core
path: "core"
tag_template: "core-v{{ .Version }}"
"#,
)
.unwrap();
p
}
#[test]
#[serial(cwd)]
fn missing_config_bails() {
let tmp = tempfile::tempdir().unwrap();
let err = run(ResolveTagOpts {
tag: "v1.0.0".into(),
json: false,
config_override: Some(tmp.path().join("nope.yaml")),
})
.unwrap_err()
.to_string();
assert!(
err.contains("no anodizer config found")
|| err.contains("config file not found")
|| err.contains("no crate matches"),
"expected a missing-config-family error: {err}"
);
}
#[test]
#[serial(cwd)]
fn unmatched_tag_bails_with_named_tag() {
let tmp = tempfile::tempdir().unwrap();
let cfg = write_simple_config(tmp.path());
let err = run(ResolveTagOpts {
tag: "abc-not-a-tag".into(),
json: false,
config_override: Some(cfg),
})
.unwrap_err()
.to_string();
assert!(err.contains("no crate matches"), "{err}");
assert!(err.contains("abc-not-a-tag"), "{err}");
}
#[test]
#[serial(cwd)]
fn simple_tag_resolves_to_root_crate_text_form() {
let tmp = tempfile::tempdir().unwrap();
let cfg = write_simple_config(tmp.path());
run(ResolveTagOpts {
tag: "v0.4.2".into(),
json: false,
config_override: Some(cfg),
})
.expect("simple v-tag must resolve");
}
#[test]
#[serial(cwd)]
fn longer_prefix_wins_for_workspace_crate() {
let tmp = tempfile::tempdir().unwrap();
let cfg = write_workspace_config(tmp.path());
run(ResolveTagOpts {
tag: "core-v2.0.1".into(),
json: false,
config_override: Some(cfg),
})
.expect("longer prefix must win on workspace crate");
}
#[test]
#[serial(cwd)]
fn json_output_succeeds() {
let tmp = tempfile::tempdir().unwrap();
let cfg = write_simple_config(tmp.path());
run(ResolveTagOpts {
tag: "v1.0.0".into(),
json: true,
config_override: Some(cfg),
})
.expect("json output should serialize successfully");
}
#[test]
#[serial(cwd)]
fn non_version_remainder_does_not_match() {
let tmp = tempfile::tempdir().unwrap();
let cfg = write_simple_config(tmp.path());
let err = run(ResolveTagOpts {
tag: "v-not-a-version".into(),
json: false,
config_override: Some(cfg),
})
.unwrap_err()
.to_string();
assert!(err.contains("no crate matches"), "{err}");
}
}