use anodizer_core::config::{Config, IncludeSpec};
use anodizer_core::env_expand::expand_env as expand_env_vars;
use anodizer_core::log::StageLogger;
use anyhow::{Context as _, Result, bail};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::monorepo::apply_monorepo_defaults;
const MAX_INCLUDE_DEPTH: usize = 32;
pub fn find_config(config_override: Option<&Path>) -> Result<PathBuf> {
find_config_with_logger(config_override, None)
}
fn anchor_to_cwd(path: PathBuf) -> PathBuf {
if path.is_absolute() {
return path;
}
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
}
pub fn find_config_with_logger(
config_override: Option<&Path>,
log: Option<&StageLogger>,
) -> Result<PathBuf> {
if let Some(path) = config_override {
if path.exists() {
return Ok(path.to_path_buf());
}
bail!("config file not found: {}", path.display());
}
let candidates = [
".anodizer.yaml",
".anodizer.yml",
".anodizer.toml",
"anodizer.yaml",
"anodizer.yml",
"anodizer.toml",
];
for name in &candidates {
let path = PathBuf::from(name);
if path.exists() {
return Ok(anchor_to_cwd(path));
}
}
if Path::new("Cargo.toml").exists() {
let msg = "no anodizer config found; using defaults from Cargo.toml";
match log {
Some(l) => l.warn(msg),
None => tracing::warn!("{}", msg),
}
return Ok(anchor_to_cwd(PathBuf::from("Cargo.toml")));
}
bail!(
"no anodizer config file found (tried: {}). Run `anodizer init` to generate one.",
candidates.join(", ")
)
}
pub fn find_config_in(base: &Path) -> Result<PathBuf> {
let candidates = [
".anodizer.yaml",
".anodizer.yml",
".anodizer.toml",
"anodizer.yaml",
"anodizer.yml",
"anodizer.toml",
];
for name in &candidates {
let path = base.join(name);
if path.exists() {
return Ok(path);
}
}
let cargo_toml = base.join("Cargo.toml");
if cargo_toml.exists() {
return Ok(cargo_toml);
}
bail!(
"no anodizer config file found under {} (tried: {}). Run `anodizer init` to generate one.",
base.display(),
candidates.join(", ")
)
}
pub fn load_repo_config(base: &Path) -> Result<Config> {
let path = find_config_in(base)?;
load_config(&path)
}
fn merge_yaml(base: &mut serde_yaml_ng::Value, overlay: &serde_yaml_ng::Value) {
match (base, overlay) {
(serde_yaml_ng::Value::Mapping(base_map), serde_yaml_ng::Value::Mapping(overlay_map)) => {
for (key, value) in overlay_map {
match base_map.get_mut(key) {
Some(existing) => merge_yaml(existing, value),
None => {
base_map.insert(key.clone(), value.clone());
}
}
}
}
(serde_yaml_ng::Value::Sequence(base_seq), serde_yaml_ng::Value::Sequence(overlay_seq)) => {
base_seq.extend(overlay_seq.iter().cloned());
}
(base_val, overlay_val) => {
*base_val = overlay_val.clone();
}
}
}
pub fn load_config(path: &Path) -> Result<Config> {
if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
return Ok(Config::default());
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config file: {}", path.display()))?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if (ext == "yaml" || ext == "yml")
&& let Ok(raw) = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&content)
{
anodizer_core::config::warn_on_legacy_snapshot_name_template(&raw);
anodizer_core::config::warn_on_legacy_furies_alias(&raw);
anodizer_core::config::warn_on_legacy_nfpm_builds(&raw);
anodizer_core::config::warn_on_legacy_disable_alias(&raw);
anodizer_core::config::validate_no_docker_v1(&raw).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_no_mcp_github(&raw).map_err(anyhow::Error::msg)?;
}
let mut config = match ext {
"yaml" | "yml" => load_yaml_config_with_includes(path, &content)?,
"toml" => load_toml_config_with_includes(path, &content)?,
_ => bail!("unsupported config format: {}", ext),
};
anodizer_core::config::apply_archive_legacy_aliases(&mut config);
anodizer_core::config::apply_homebrew_cask_legacy_singulars(&mut config);
anodizer_core::config::validate_version(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_tag_sort(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_partial(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_format_overrides(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_release_backends(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_nightly_publish_repo(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_defaults_axis(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_homebrew_cask_url_template(&config)
.map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_id_uniqueness(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_builds(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_changelog_groups_depth(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::validate_changelog_paths(&config).map_err(anyhow::Error::msg)?;
anodizer_core::config::warn_on_submitter_required(&config);
anodizer_core::config::warn_on_legacy_homebrew_formula(&config);
anodizer_core::config::warn_on_legacy_docker_retry(&config);
if let Some(ref mut src) = config.source {
src.apply_prefix_template_default();
}
apply_monorepo_defaults(&mut config);
normalize_commit_author_defaults(&mut config);
anodizer_core::defaults_merge::apply_defaults(&mut config);
config.populate_derived_metadata(Path::new("."));
Ok(config)
}
fn normalize_commit_author_defaults(config: &mut anodizer_core::config::Config) {
for crate_cfg in &mut config.crates {
normalize_crate_commit_author(crate_cfg);
}
if let Some(ws_list) = config.workspaces.as_mut() {
for ws in ws_list {
for crate_cfg in &mut ws.crates {
normalize_crate_commit_author(crate_cfg);
}
}
}
}
fn normalize_crate_commit_author(crate_cfg: &mut anodizer_core::config::CrateConfig) {
let Some(ref mut pub_cfg) = crate_cfg.publish else {
return;
};
if let Some(ref mut e) = pub_cfg.homebrew
&& let Some(ref mut ca) = e.commit_author
{
ca.normalize_defaults();
}
if let Some(ref mut e) = pub_cfg.scoop
&& let Some(ref mut ca) = e.commit_author
{
ca.normalize_defaults();
}
if let Some(ref mut e) = pub_cfg.winget
&& let Some(ref mut ca) = e.commit_author
{
ca.normalize_defaults();
}
if let Some(ref mut e) = pub_cfg.nix
&& let Some(ref mut ca) = e.commit_author
{
ca.normalize_defaults();
}
if let Some(ref mut e) = pub_cfg.aur
&& let Some(ref mut ca) = e.commit_author
{
ca.normalize_defaults();
}
if let Some(ref mut e) = pub_cfg.krew
&& let Some(ref mut ca) = e.commit_author
{
ca.normalize_defaults();
}
}
fn load_yaml_config_with_includes(path: &Path, content: &str) -> Result<Config> {
let base: serde_yaml_ng::Value = serde_yaml_ng::from_str(content)
.with_context(|| format!("failed to parse YAML config: {}", path.display()))?;
let include_entries: Vec<serde_yaml_ng::Value> = base
.get("includes")
.and_then(|v| v.as_sequence())
.cloned()
.unwrap_or_default();
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut visited: HashSet<String> = HashSet::new();
let root_key = canonical_path_key(path);
if let Some(ref key) = root_key {
visited.insert(key.clone());
}
let mut merged = serde_yaml_ng::Value::Mapping(serde_yaml_ng::Mapping::new());
for entry in &include_entries {
let overlay =
resolve_include_recursive(entry, base_dir, path, &mut visited, 0, root_key.as_deref())?;
merge_yaml(&mut merged, &overlay);
}
merge_yaml(&mut merged, &base);
let path_display = path.display().to_string();
anodizer_core::config::deserialize_on_worker(move || {
serde_yaml_ng::from_value::<Config>(merged)
.with_context(|| format!("failed to deserialize config: {}", path_display))
})
}
fn load_toml_config_with_includes(path: &Path, content: &str) -> Result<Config> {
let base_toml: toml::Value = toml::from_str(content)
.with_context(|| format!("failed to parse TOML config: {}", path.display()))?;
let include_entries: Vec<toml::Value> = base_toml
.get("includes")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if include_entries.is_empty() {
let path_display = path.display().to_string();
let content_owned = content.to_string();
return anodizer_core::config::deserialize_on_worker(move || {
toml::from_str::<Config>(&content_owned)
.with_context(|| format!("failed to deserialize TOML config: {}", path_display))
});
}
let base_json = serde_json::to_value(&base_toml)
.with_context(|| "failed to convert TOML config to JSON for merging")?;
let base_yaml: serde_yaml_ng::Value = serde_yaml_ng::to_value(&base_json)
.with_context(|| "failed to convert TOML config to YAML for merging")?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut visited: HashSet<String> = HashSet::new();
let root_key = canonical_path_key(path);
if let Some(ref key) = root_key {
visited.insert(key.clone());
}
let mut merged = serde_yaml_ng::Value::Mapping(serde_yaml_ng::Mapping::new());
for entry in &include_entries {
let json_entry = serde_json::to_value(entry)
.with_context(|| "failed to convert TOML include entry to JSON")?;
let yaml_entry: serde_yaml_ng::Value = serde_yaml_ng::to_value(&json_entry)
.with_context(|| "failed to convert TOML include entry to YAML")?;
let overlay = resolve_include_recursive(
&yaml_entry,
base_dir,
path,
&mut visited,
0,
root_key.as_deref(),
)?;
merge_yaml(&mut merged, &overlay);
}
merge_yaml(&mut merged, &base_yaml);
let path_display = path.display().to_string();
anodizer_core::config::deserialize_on_worker(move || {
serde_yaml_ng::from_value::<Config>(merged)
.with_context(|| format!("failed to deserialize config: {}", path_display))
})
}
fn normalize_include_url(url: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
url.to_string()
} else {
format!("https://raw.githubusercontent.com/{}", url)
}
}
const MAX_INCLUDE_BODY_SIZE: u64 = 10 * 1024 * 1024;
fn fetch_url_as_yaml(
url: &str,
headers: Option<&std::collections::HashMap<String, String>>,
config_path: &Path,
) -> Result<serde_yaml_ng::Value> {
let client = anodizer_core::http::blocking_client(Duration::from_secs(30))
.with_context(|| "failed to build HTTP client for include URL fetch")?;
let mut request = client.get(url);
if let Some(hdrs) = headers {
for (key, value) in hdrs {
let expanded = expand_env_vars(value);
request = request.header(key.as_str(), expanded);
}
}
let response = request.send().with_context(|| {
format!(
"failed to fetch include URL '{}' (referenced from {})",
url,
config_path.display()
)
})?;
if !response.status().is_success() {
bail!(
"include URL '{}' returned HTTP {} (referenced from {})",
url,
response.status(),
config_path.display()
);
}
if let Some(content_length) = response.content_length()
&& content_length > MAX_INCLUDE_BODY_SIZE
{
bail!(
"include URL '{}' response too large ({} bytes, max {} bytes) (referenced from {})",
url,
content_length,
MAX_INCLUDE_BODY_SIZE,
config_path.display()
);
}
let body = response.text().with_context(|| {
format!(
"failed to read response body from include URL '{}' (referenced from {})",
url,
config_path.display()
)
})?;
if body.len() as u64 > MAX_INCLUDE_BODY_SIZE {
bail!(
"include URL '{}' response too large ({} bytes, max {} bytes) (referenced from {})",
url,
body.len(),
MAX_INCLUDE_BODY_SIZE,
config_path.display()
);
}
let is_toml = url
.split('?')
.next()
.and_then(|path| path.rsplit('.').next())
.map(|ext| ext.eq_ignore_ascii_case("toml"))
.unwrap_or(false);
if is_toml {
let toml_val: toml::Value = toml::from_str(&body).with_context(|| {
format!(
"failed to parse TOML from include URL '{}' (referenced from {})",
url,
config_path.display()
)
})?;
let json_val = serde_json::to_value(&toml_val).with_context(|| {
format!(
"failed to convert TOML to JSON from include URL '{}' (referenced from {})",
url,
config_path.display()
)
})?;
serde_yaml_ng::to_value(&json_val).with_context(|| {
format!(
"failed to convert TOML to YAML from include URL '{}' (referenced from {})",
url,
config_path.display()
)
})
} else {
serde_yaml_ng::from_str(&body).with_context(|| {
format!(
"failed to parse YAML from include URL '{}' (referenced from {})",
url,
config_path.display()
)
})
}
}
fn canonical_path_key(path: &Path) -> Option<String> {
match std::fs::canonicalize(path) {
Ok(p) => Some(p.to_string_lossy().to_string()),
Err(_) => path.to_str().map(|s| s.to_string()),
}
}
fn expand_path_tilde_and_env(path_str: &str) -> String {
let expanded = expand_env_vars(path_str);
anodizer_core::path_util::expand_tilde(&expanded).into_owned()
}
fn resolve_include_recursive(
entry: &serde_yaml_ng::Value,
base_dir: &Path,
config_path: &Path,
visited: &mut HashSet<String>,
depth: usize,
root_key: Option<&str>,
) -> Result<serde_yaml_ng::Value> {
if depth >= MAX_INCLUDE_DEPTH {
bail!(
"includes: depth limit ({}) exceeded (referenced from {})",
MAX_INCLUDE_DEPTH,
config_path.display(),
);
}
let spec: IncludeSpec = serde_yaml_ng::from_value(entry.clone())
.with_context(|| format!("includes: invalid entry in {}", config_path.display()))?;
let (key, mut value, child_base_dir, child_config_path) = match spec {
IncludeSpec::Path(path_str) => {
resolve_file_include_value(&path_str, base_dir, config_path)?
}
IncludeSpec::FromFile { from_file } => {
resolve_file_include_value(&from_file.path, base_dir, config_path)?
}
IncludeSpec::FromUrl { from_url } => {
let url = expand_env_vars(&normalize_include_url(&from_url.url));
let value = fetch_url_as_yaml(&url, from_url.headers.as_ref(), config_path)?;
let child_base_dir = base_dir.to_path_buf();
let child_config_path = PathBuf::from(&url);
(url, value, child_base_dir, child_config_path)
}
};
if let Some(rk) = root_key
&& key == rk
{
bail!(
"includes: self-cycle detected at '{}' (referenced from {})",
key,
config_path.display(),
);
}
if !visited.insert(key.clone()) {
if depth > 0 {
bail!(
"includes: cycle detected at '{}' (referenced from {})",
key,
config_path.display(),
);
}
return Ok(serde_yaml_ng::Value::Mapping(serde_yaml_ng::Mapping::new()));
}
let child_entries: Vec<serde_yaml_ng::Value> = match &mut value {
serde_yaml_ng::Value::Mapping(map) => map
.remove("includes")
.and_then(|v| match v {
serde_yaml_ng::Value::Sequence(seq) => Some(seq),
_ => None,
})
.unwrap_or_default(),
_ => Vec::new(),
};
let mut accumulated = serde_yaml_ng::Value::Mapping(serde_yaml_ng::Mapping::new());
for child_entry in &child_entries {
let child_overlay = resolve_include_recursive(
child_entry,
&child_base_dir,
&child_config_path,
visited,
depth + 1,
root_key,
)?;
merge_yaml(&mut accumulated, &child_overlay);
}
merge_yaml(&mut accumulated, &value);
Ok(accumulated)
}
fn resolve_file_include_value(
path_str: &str,
base_dir: &Path,
config_path: &Path,
) -> Result<(String, serde_yaml_ng::Value, PathBuf, PathBuf)> {
let expanded = expand_path_tilde_and_env(path_str);
let include_path = if Path::new(&expanded).is_absolute() {
bail!(
"includes: absolute paths are not allowed (got '{}' in {})",
path_str,
config_path.display()
);
} else {
base_dir.join(&expanded)
};
let include_content = std::fs::read_to_string(&include_path).with_context(|| {
format!(
"failed to read include file '{}' (referenced from {})",
include_path.display(),
config_path.display()
)
})?;
let value = load_include_as_yaml(&include_path, &include_content)?;
let key =
canonical_path_key(&include_path).unwrap_or_else(|| include_path.display().to_string());
let child_base_dir = include_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
Ok((key, value, child_base_dir, include_path))
}
fn load_include_as_yaml(
include_path: &Path,
include_content: &str,
) -> Result<serde_yaml_ng::Value> {
let ext = include_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"toml" => {
let toml_val: toml::Value = toml::from_str(include_content).with_context(|| {
format!("failed to parse include file: {}", include_path.display())
})?;
let json_val = serde_json::to_value(&toml_val).with_context(|| {
format!(
"failed to convert TOML include to JSON: {}",
include_path.display()
)
})?;
serde_yaml_ng::to_value(&json_val).with_context(|| {
format!(
"failed to convert TOML include to YAML: {}",
include_path.display()
)
})
}
_ => {
serde_yaml_ng::from_str(include_content).with_context(|| {
format!("failed to parse include file: {}", include_path.display())
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_find_config_with_override_existing() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("custom-config.yaml");
fs::write(&cfg_path, "project_name: test\ncrates: []\n").unwrap();
let result = find_config(Some(cfg_path.as_path()));
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
assert_eq!(result.unwrap(), cfg_path);
}
#[test]
fn test_find_config_with_override_nonexistent() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("does-not-exist.yaml");
let result = find_config(Some(cfg_path.as_path()));
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("config file not found"),
"unexpected error message: {}",
msg
);
}
#[test]
fn test_find_config_override_with_subdirectory_path() {
let tmp = TempDir::new().unwrap();
let subdir = tmp.path().join("nested").join("dir");
fs::create_dir_all(&subdir).unwrap();
let cfg_path = subdir.join("my-release.toml");
fs::write(&cfg_path, "project_name = \"test\"\ncrates = []\n").unwrap();
let result = find_config(Some(cfg_path.as_path()));
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
assert_eq!(result.unwrap(), cfg_path);
}
#[test]
fn test_merge_yaml_mappings_recursive() {
let mut base: serde_yaml_ng::Value = serde_yaml_ng::from_str("a: 1\nb: 2").unwrap();
let overlay: serde_yaml_ng::Value = serde_yaml_ng::from_str("b: 99\nc: 3").unwrap();
merge_yaml(&mut base, &overlay);
assert_eq!(base["a"], serde_yaml_ng::Value::Number(1.into()));
assert_eq!(base["b"], serde_yaml_ng::Value::Number(99.into()));
assert_eq!(base["c"], serde_yaml_ng::Value::Number(3.into()));
}
#[test]
fn test_merge_yaml_nested_mappings() {
let mut base: serde_yaml_ng::Value =
serde_yaml_ng::from_str("outer:\n x: 1\n y: 2").unwrap();
let overlay: serde_yaml_ng::Value =
serde_yaml_ng::from_str("outer:\n y: 99\n z: 3").unwrap();
merge_yaml(&mut base, &overlay);
assert_eq!(base["outer"]["x"], serde_yaml_ng::Value::Number(1.into()));
assert_eq!(base["outer"]["y"], serde_yaml_ng::Value::Number(99.into()));
assert_eq!(base["outer"]["z"], serde_yaml_ng::Value::Number(3.into()));
}
#[test]
fn test_merge_yaml_sequences_concatenate() {
let mut base: serde_yaml_ng::Value =
serde_yaml_ng::from_str("items:\n - a\n - b").unwrap();
let overlay: serde_yaml_ng::Value =
serde_yaml_ng::from_str("items:\n - c\n - d").unwrap();
merge_yaml(&mut base, &overlay);
let items = base["items"].as_sequence().unwrap();
assert_eq!(items.len(), 4);
assert_eq!(items[0].as_str().unwrap(), "a");
assert_eq!(items[1].as_str().unwrap(), "b");
assert_eq!(items[2].as_str().unwrap(), "c");
assert_eq!(items[3].as_str().unwrap(), "d");
}
#[test]
fn test_merge_yaml_scalar_override() {
let mut base: serde_yaml_ng::Value = serde_yaml_ng::from_str("name: base").unwrap();
let overlay: serde_yaml_ng::Value = serde_yaml_ng::from_str("name: overlay").unwrap();
merge_yaml(&mut base, &overlay);
assert_eq!(base["name"].as_str().unwrap(), "overlay");
}
#[test]
fn test_load_config_includes_field_parses() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: myproject\nincludes:\n - extra.yaml\ncrates: []\n",
)
.unwrap();
let extra_path = tmp.path().join("extra.yaml");
fs::write(&extra_path, "report_sizes: true\n").unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "myproject");
assert_eq!(
config.includes,
Some(vec![anodizer_core::config::IncludeSpec::Path(
"extra.yaml".to_string()
)])
);
assert_eq!(config.report_sizes, Some(true));
}
#[test]
fn test_load_config_includes_merges_base_and_include() {
let tmp = TempDir::new().unwrap();
let include_path = tmp.path().join("overrides.yaml");
fs::write(&include_path, "dist: /custom/dist\n").unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: merged\nincludes:\n - overrides.yaml\ncrates: []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "merged");
assert_eq!(config.dist, std::path::PathBuf::from("/custom/dist"));
}
#[test]
fn test_load_config_includes_sequences_concatenated() {
let tmp = TempDir::new().unwrap();
let include_path = tmp.path().join("more-crates.yaml");
fs::write(
&include_path,
"crates:\n - name: extra-crate\n path: crates/extra\n",
)
.unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: seq-test\nincludes:\n - more-crates.yaml\ncrates:\n - name: base-crate\n path: crates/base\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.crates.len(), 2);
assert_eq!(config.crates[0].name, "extra-crate");
assert_eq!(config.crates[1].name, "base-crate");
}
#[test]
fn test_load_config_base_wins_over_include_for_scalar() {
let tmp = TempDir::new().unwrap();
let include_path = tmp.path().join("defaults.yaml");
fs::write(&include_path, "dist: /from-include\n").unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: priority-test\nincludes:\n - defaults.yaml\ndist: /from-base\ncrates: []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(
config.dist,
std::path::PathBuf::from("/from-base"),
"base config should override include for scalar values"
);
}
#[test]
fn test_load_config_missing_include_file_returns_error() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: test\nincludes:\n - nonexistent.yaml\ncrates: []\n",
)
.unwrap();
let result = load_config(&cfg_path);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("nonexistent.yaml") || msg.contains("include"),
"unexpected error message: {}",
msg
);
}
#[test]
fn test_load_config_no_includes_works_as_before() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(&cfg_path, "project_name: simple\ncrates: []\n").unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "simple");
assert!(config.includes.is_none());
}
#[test]
fn test_load_config_includes_recursive_two_level() {
let tmp = TempDir::new().unwrap();
let c_path = tmp.path().join("c.yaml");
fs::write(&c_path, "dist: /from-c\nreport_sizes: true\n").unwrap();
let b_path = tmp.path().join("b.yaml");
fs::write(
&b_path,
"includes:\n - c.yaml\ncrates:\n - name: from-b\n path: crates/b\n",
)
.unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: recursive\nincludes:\n - b.yaml\ncrates:\n - name: base\n path: crates/base\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "recursive");
assert_eq!(
config.dist,
std::path::PathBuf::from("/from-c"),
"c.yaml's scalar value should propagate up two levels"
);
assert_eq!(
config.report_sizes,
Some(true),
"c.yaml's report_sizes should propagate up"
);
let names: Vec<&str> = config.crates.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
vec!["from-b", "base"],
"crates concat in declaration order with base last"
);
}
#[test]
fn test_load_config_includes_cycle_detected() {
let tmp = TempDir::new().unwrap();
let b_path = tmp.path().join("b.yaml");
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(&b_path, "includes:\n - anodizer.yaml\n").unwrap();
fs::write(
&cfg_path,
"project_name: cycle\nincludes:\n - b.yaml\ncrates: []\n",
)
.unwrap();
let err = load_config(&cfg_path).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("cycle detected"),
"expected cycle-detected error, got: {msg}"
);
}
#[test]
fn test_load_config_includes_self_cycle() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: self\nincludes:\n - anodizer.yaml\ncrates: []\n",
)
.unwrap();
let err = load_config(&cfg_path).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("self-cycle"),
"expected self-cycle error, got: {msg}"
);
}
#[test]
fn test_load_config_includes_path_relative_to_included_file() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("nested");
fs::create_dir_all(&nested).unwrap();
let c_path = nested.join("c.yaml");
fs::write(&c_path, "dist: /from-nested-c\n").unwrap();
let b_path = nested.join("b.yaml");
fs::write(&b_path, "includes:\n - c.yaml\nreport_sizes: true\n").unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: rel\nincludes:\n - nested/b.yaml\ncrates: []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(
config.dist,
std::path::PathBuf::from("/from-nested-c"),
"second-level include resolved relative to its own directory"
);
}
#[test]
fn test_expand_path_tilde_user_form_not_supported() {
let got = expand_path_tilde_and_env("~bob/foo");
assert_eq!(
got, "~bob/foo",
"~user/... must NOT be expanded (POSIX user-home form unsupported)"
);
let got_no_slash = expand_path_tilde_and_env("~bob");
assert_eq!(
got_no_slash, "~bob",
"~user with no trailing slash must NOT be expanded either"
);
}
#[test]
fn test_load_config_includes_dedup_same_file_twice() {
let tmp = TempDir::new().unwrap();
let extra = tmp.path().join("extra.yaml");
fs::write(&extra, "crates:\n - name: only-once\n path: crates/x\n").unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: dedup\nincludes:\n - extra.yaml\n - extra.yaml\ncrates: []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(
config.crates.len(),
1,
"duplicate include should only contribute once"
);
}
#[test]
fn test_load_config_version_1_accepted() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(&cfg_path, "project_name: test\nversion: 1\ncrates: []\n").unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.version, Some(1));
}
#[test]
fn test_load_config_version_2_accepted() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(&cfg_path, "project_name: test\nversion: 2\ncrates: []\n").unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.version, Some(2));
}
#[test]
fn test_load_config_version_99_rejected() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(&cfg_path, "project_name: test\nversion: 99\ncrates: []\n").unwrap();
let result = load_config(&cfg_path);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("unsupported config version"),
"error should mention unsupported version: {}",
msg
);
}
#[test]
fn test_load_config_env_files_list_form() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: test\nenv_files:\n - .env\n - .release.env\ncrates: []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
let env_files = config.env_files.unwrap();
let files = env_files
.as_list()
.unwrap_or_else(|| panic!("expected List variant"));
assert_eq!(files, &[".env", ".release.env"]);
}
#[test]
fn test_load_config_env_files_struct_form() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: test\nenv_files:\n github_token: /tmp/gh_token\n gitlab_token: /tmp/gl_token\ncrates: []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
let env_files = config.env_files.unwrap();
let tokens = env_files
.as_token_files()
.unwrap_or_else(|| panic!("expected TokenFiles variant"));
assert_eq!(tokens.github_token.as_deref(), Some("/tmp/gh_token"));
assert_eq!(tokens.gitlab_token.as_deref(), Some("/tmp/gl_token"));
assert!(tokens.gitea_token.is_none());
}
#[test]
fn test_load_config_with_ignore_and_overrides() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
r#"
project_name: test
defaults:
targets:
- x86_64-unknown-linux-gnu
builds:
ignore:
- os: windows
arch: arm64
overrides:
- targets: ["x86_64-*"]
features: [simd]
crates: []
"#,
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
let builds = config.defaults.unwrap().builds.unwrap();
assert_eq!(builds.ignore.unwrap().len(), 1);
assert_eq!(builds.overrides.unwrap().len(), 1);
}
#[test]
fn test_includes_from_file_structured_form() {
let tmp = TempDir::new().unwrap();
let include_path = tmp.path().join("shared.yaml");
fs::write(&include_path, "report_sizes: true\n").unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: structured\nincludes:\n - from_file:\n path: shared.yaml\ncrates: []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "structured");
assert_eq!(config.report_sizes, Some(true));
assert_eq!(
config.includes,
Some(vec![anodizer_core::config::IncludeSpec::FromFile {
from_file: anodizer_core::config::IncludeFilePath {
path: "shared.yaml".to_string(),
},
}])
);
}
#[test]
fn test_includes_mixed_string_and_structured() {
let tmp = TempDir::new().unwrap();
let extra1 = tmp.path().join("extra1.yaml");
fs::write(&extra1, "report_sizes: true\n").unwrap();
let extra2 = tmp.path().join("extra2.yaml");
fs::write(&extra2, "dist: /custom\n").unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
r#"project_name: mixed
includes:
- extra1.yaml
- from_file:
path: extra2.yaml
crates: []
"#,
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "mixed");
assert_eq!(config.report_sizes, Some(true));
assert_eq!(config.dist, std::path::PathBuf::from("/custom"));
assert_eq!(config.includes.as_ref().unwrap().len(), 2);
}
#[test]
fn test_includes_from_file_absolute_path_rejected() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
format!(
"project_name: test\nincludes:\n - from_file:\n path: {}\ncrates: []\n",
if cfg!(windows) {
"C:\\Windows\\System32\\drivers\\etc\\hosts"
} else {
"/etc/passwd"
}
),
)
.unwrap();
let result = load_config(&cfg_path);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("absolute paths are not allowed"),
"expected absolute path error, got: {}",
msg
);
}
#[test]
fn test_includes_from_file_missing_path_field() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: test\nincludes:\n - from_file:\n wrong_key: value\ncrates: []\n",
)
.unwrap();
let result = load_config(&cfg_path);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("invalid entry")
|| msg.contains("missing field")
|| msg.contains("from_file"),
"expected invalid entry error, got: {}",
msg
);
}
#[test]
fn test_includes_backward_compat_plain_strings() {
let tmp = TempDir::new().unwrap();
let inc1 = tmp.path().join("inc1.yaml");
fs::write(&inc1, "dist: /from-inc1\n").unwrap();
let inc2 = tmp.path().join("inc2.yaml");
fs::write(&inc2, "report_sizes: true\n").unwrap();
let cfg_path = tmp.path().join("anodizer.yaml");
fs::write(
&cfg_path,
"project_name: backcompat\nincludes:\n - inc1.yaml\n - inc2.yaml\ncrates: []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "backcompat");
assert_eq!(config.dist, std::path::PathBuf::from("/from-inc1"));
assert_eq!(config.report_sizes, Some(true));
}
#[test]
fn test_normalize_url_full_https() {
let result = normalize_include_url("https://example.com/config.yaml");
assert_eq!(result, "https://example.com/config.yaml");
}
#[test]
fn test_normalize_url_full_http() {
let result = normalize_include_url("http://internal.corp/config.yaml");
assert_eq!(result, "http://internal.corp/config.yaml");
}
#[test]
fn test_normalize_url_github_shorthand() {
let result = normalize_include_url("caarlos0/goreleaserfiles/main/packages.yml");
assert_eq!(
result,
"https://raw.githubusercontent.com/caarlos0/goreleaserfiles/main/packages.yml"
);
}
#[test]
fn test_normalize_url_github_shorthand_complex() {
let result = normalize_include_url("org/repo/branch/path/to/config.yaml");
assert_eq!(
result,
"https://raw.githubusercontent.com/org/repo/branch/path/to/config.yaml"
);
}
#[test]
fn test_toml_includes_plain_string_backward_compat() {
let tmp = TempDir::new().unwrap();
let include_path = tmp.path().join("defaults.yaml");
fs::write(&include_path, "report_sizes: true\n").unwrap();
let cfg_path = tmp.path().join("anodizer.toml");
fs::write(
&cfg_path,
"project_name = \"toml-test\"\nincludes = [\"defaults.yaml\"]\ncrates = []\n",
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "toml-test");
assert_eq!(config.report_sizes, Some(true));
}
#[test]
fn test_toml_includes_from_file_structured() {
let tmp = TempDir::new().unwrap();
let include_path = tmp.path().join("shared.yaml");
fs::write(&include_path, "dist: /shared-dist\n").unwrap();
let cfg_path = tmp.path().join("anodizer.toml");
fs::write(
&cfg_path,
r#"project_name = "toml-structured"
crates = []
[[includes]]
[includes.from_file]
path = "shared.yaml"
"#,
)
.unwrap();
let config = load_config(&cfg_path).unwrap();
assert_eq!(config.project_name, "toml-structured");
assert_eq!(config.dist, std::path::PathBuf::from("/shared-dist"));
}
#[test]
fn test_header_keys_not_expanded_only_values() {
let lookup = |name: &str| match name {
"ANODIZER_HDR_VAL" => Some("expanded_val".to_string()),
_ => None,
};
let mut headers = std::collections::HashMap::new();
headers.insert(
"$KEY_LITERAL".to_string(),
"${ANODIZER_HDR_VAL}".to_string(),
);
let key = "$KEY_LITERAL";
let value = "${ANODIZER_HDR_VAL}";
assert_eq!(
key, "$KEY_LITERAL",
"header key must be preserved literally"
);
assert_eq!(
anodizer_core::env_expand::expand_with(value, lookup),
"expanded_val",
"header value must be expanded"
);
assert_eq!(
anodizer_core::env_expand::expand_with(key, lookup),
"",
"expanding a key with valid var name destroys it — proves keys must not be expanded"
);
}
#[test]
fn test_fetch_url_unreachable_returns_error() {
let result = fetch_url_as_yaml(
"http://127.0.0.1:1/nonexistent.yaml",
None,
Path::new("test-config.yaml"),
);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("failed to fetch include URL") || msg.contains("127.0.0.1"),
"expected connection error, got: {}",
msg
);
}
#[test]
fn test_toml_includes_from_url_structured_form() {
let tmp = TempDir::new().unwrap();
let include_path = tmp.path().join("shared.yaml");
fs::write(&include_path, "report_sizes: true\n").unwrap();
let cfg_path = tmp.path().join("anodizer.toml");
fs::write(
&cfg_path,
r#"project_name = "toml-from-url-test"
crates = []
[[includes]]
[includes.from_url]
url = "https://example.com/config.yaml"
"#,
)
.unwrap();
let config_result = load_config(&cfg_path);
assert!(config_result.is_err());
let msg = config_result.unwrap_err().to_string();
assert!(
msg.contains("fetch") || msg.contains("include URL"),
"should fail at fetch, not parse: {}",
msg
);
}
#[test]
fn load_config_succeeds_on_small_caller_stack() {
let tmp = TempDir::new().unwrap();
let cfg_path = tmp.path().join(".anodizer.yaml");
fs::write(
&cfg_path,
r#"version: 2
project_name: stack-probe
crates:
- name: demo
path: .
tag_template: "v{{ Version }}"
"#,
)
.unwrap();
let cfg_path_string = cfg_path.to_string_lossy().to_string();
let handle = std::thread::Builder::new()
.stack_size(512 * 1024)
.name("load_config_small_stack_probe".to_string())
.spawn(move || {
load_config(std::path::Path::new(&cfg_path_string))
.map(|c| c.project_name)
.map_err(|e| e.to_string())
})
.expect("spawn small-stack probe thread");
let result = handle.join().expect("probe thread did not panic");
assert_eq!(
result.as_deref(),
Ok("stack-probe"),
"load_config must succeed from a small caller stack: {:?}",
result
);
}
#[test]
fn find_config_in_finds_anodizer_yaml_under_base() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join(".anodizer.yaml");
fs::write(&cfg, "project_name: based\ncrates: []\n").unwrap();
let found = find_config_in(tmp.path()).expect("must find the config under base");
assert_eq!(found, cfg);
}
#[test]
fn find_config_in_falls_back_to_cargo_toml() {
let tmp = TempDir::new().unwrap();
let cargo = tmp.path().join("Cargo.toml");
fs::write(&cargo, "[package]\nname = \"x\"\nversion = \"0.1.0\"\n").unwrap();
let found = find_config_in(tmp.path()).expect("must fall back to Cargo.toml");
assert_eq!(found, cargo);
let cfg = load_repo_config(tmp.path()).expect("load_repo_config must succeed");
assert!(cfg.project_name.is_empty());
}
#[test]
fn find_config_in_bails_when_neither_present() {
let tmp = TempDir::new().unwrap();
let err = find_config_in(tmp.path())
.expect_err("empty dir must bail")
.to_string();
assert!(
err.contains("no anodizer config file found"),
"error must explain the miss: {err}"
);
assert!(load_repo_config(tmp.path()).is_err());
}
#[test]
fn load_repo_config_loads_yaml_under_base() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anodizer.yaml"),
"project_name: loaded-from-base\ncrates: []\n",
)
.unwrap();
let cfg = load_repo_config(tmp.path()).expect("must load the config under base");
assert_eq!(cfg.project_name, "loaded-from-base");
}
}