use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{Config, ForceTokenKind, GitHubConfig, WorkspaceConfig};
use anodizer_core::context::Context;
use anodizer_core::git;
use anodizer_core::log::StageLogger;
use anodizer_core::scm::{self, ScmTokenType};
use anyhow::{Context as _, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub(crate) fn parse_csv_list(
raw: Option<&str>,
flag_help: &str,
) -> Result<Option<Vec<String>>, String> {
match raw {
None => Ok(None),
Some(list) => {
let parsed: Vec<String> = list
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if parsed.is_empty() {
return Err(format!(
"{flag_help} must list at least one entry (got empty / whitespace-only input)"
));
}
Ok(Some(parsed))
}
}
}
pub(crate) fn detect_duplicate_paths<'a, I>(paths: I) -> Result<()>
where
I: IntoIterator<Item = &'a Path>,
{
use std::collections::BTreeMap;
let mut counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
for p in paths {
*counts.entry(p.to_path_buf()).or_insert(0) += 1;
}
let duplicates: Vec<(PathBuf, usize)> = counts.into_iter().filter(|(_, n)| *n > 1).collect();
if duplicates.is_empty() {
return Ok(());
}
let summary = duplicates
.iter()
.map(|(p, n)| format!("{} ({}×)", p.display(), n))
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"duplicate artifact path(s) after merging per-shard manifests: {summary}. \
Hypothesis: two shards overlapped on the same target, so both \
emitted an artifact for the same path. Inspect the matrix in \
`.github/workflows/release.yml` (or the equivalent dispatcher) \
to confirm the shards partition the target set."
);
}
pub(crate) fn detect_missing_files<'a, I>(paths: I, dist: &Path) -> Result<()>
where
I: IntoIterator<Item = &'a Path>,
{
let mut missing: Vec<PathBuf> = Vec::new();
for p in paths {
if p.is_absolute() {
if !p.is_file() {
missing.push(p.to_path_buf());
}
} else if !p.is_file() && !dist.join(p).is_file() {
missing.push(p.to_path_buf());
}
}
if missing.is_empty() {
return Ok(());
}
missing.sort();
let summary = missing
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"artifacts manifest references file(s) not present under {}: {summary}. \
The preserved dist is incomplete; re-run \
`anodize check determinism --preserve-dist=<dist>` to repopulate, or \
remove the stale manifest entries before retrying.",
dist.display(),
);
}
fn set_env_var_single_threaded(key: &str, value: &str) {
unsafe { std::env::set_var(key, value) };
}
fn resolve_force_token(config: &Config) -> Option<ForceTokenKind> {
config.force_token.as_ref().cloned().or_else(|| {
let env_val = std::env::var("ANODIZER_FORCE_TOKEN")
.ok()
.or_else(|| std::env::var("GORELEASER_FORCE_TOKEN").ok())?;
match env_val.to_lowercase().as_str() {
"github" => Some(ForceTokenKind::GitHub),
"gitlab" => Some(ForceTokenKind::GitLab),
"gitea" => Some(ForceTokenKind::Gitea),
_ => None,
}
})
}
pub fn collect_build_targets(config: &Config, selected_crates: &[String]) -> Vec<String> {
let mut targets: Vec<String> = Vec::new();
let default_targets = config
.defaults
.as_ref()
.and_then(|d| d.targets.as_deref())
.unwrap_or(&[]);
let all_crates = config.crates.iter().chain(
config
.workspaces
.as_deref()
.unwrap_or_default()
.iter()
.flat_map(|w| w.crates.iter()),
);
let mut have_any_build = false;
for krate in all_crates {
if !selected_crates.is_empty() && !selected_crates.contains(&krate.name) {
continue;
}
if let Some(ref builds) = krate.builds {
for build in builds {
have_any_build = true;
let chosen = match build.targets.as_deref() {
Some(ts) => ts,
None => default_targets,
};
for t in chosen {
if !targets.contains(t) {
targets.push(t.clone());
}
}
}
}
}
if !have_any_build {
for t in default_targets {
if !targets.contains(t) {
targets.push(t.clone());
}
}
}
if let Some(ignores) = config
.defaults
.as_ref()
.and_then(|d| d.builds.as_ref())
.and_then(|b| b.ignore.as_ref())
{
targets.retain(|t| {
let (os, arch) = anodizer_core::target::map_target(t);
!ignores.iter().any(|ig| ig.os == os && ig.arch == arch)
});
}
targets
}
pub fn apply_workspace_overlay(config: &mut Config, ws: &WorkspaceConfig) {
config.crates = ws.crates.clone();
if ws.changelog.is_some() {
config.changelog = ws.changelog.clone();
}
if !ws.signs.is_empty() {
config.signs = ws.signs.clone();
}
if !ws.binary_signs.is_empty() {
config.binary_signs = ws.binary_signs.clone();
}
if ws.before.is_some() {
config.before = ws.before.clone();
}
if ws.after.is_some() {
config.after = ws.after.clone();
}
if let Some(ref env_list) = ws.env {
let merged = config.env.get_or_insert_with(Vec::new);
merged.extend(env_list.iter().cloned());
}
}
pub fn resolve_git_context(
ctx: &mut Context,
config: &Config,
log: &StageLogger,
) -> anyhow::Result<()> {
if git::is_shallow_clone() {
eprintln!(
"WARNING: shallow clone detected; tag discovery may be incomplete. Use `git fetch --unshallow` in CI."
);
}
let tag_override = std::env::var("ANODIZER_CURRENT_TAG")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::var("GORELEASER_CURRENT_TAG")
.ok()
.filter(|s| !s.is_empty())
});
let first_crate = ctx
.options
.selected_crates
.first()
.and_then(|name| {
config.crates.iter().find(|c| &c.name == name).or_else(|| {
config.workspaces.as_ref().and_then(|ws_list| {
ws_list
.iter()
.flat_map(|w| w.crates.iter())
.find(|c| &c.name == name)
})
})
})
.or_else(|| config.crates.first())
.or_else(|| {
config
.workspaces
.as_ref()
.and_then(|ws_list| ws_list.iter().flat_map(|w| w.crates.iter()).next())
});
if let Some(crate_cfg) = first_crate {
let tag = if let Some(ref override_tag) = tag_override {
log.verbose(&format!(
"using ANODIZER_CURRENT_TAG override: {}",
override_tag
));
override_tag.clone()
} else {
let monorepo_prefix = config.monorepo_tag_prefix();
let latest_tag = match git::find_latest_tag_matching_with_prefix(
&crate_cfg.tag_template,
config.git.as_ref(),
Some(ctx.template_vars()),
monorepo_prefix,
) {
Ok(found) => found,
Err(e) => {
log.warn(&format!("error finding tags matching template: {e}"));
None
}
};
match latest_tag {
Some(t) => t,
None => {
if ctx.options.snapshot {
log.warn("no git tags found, defaulting to v0.0.0 (snapshot mode).");
"v0.0.0".to_string()
} else if ctx.options.dry_run {
log.warn("no git tags found, defaulting to v0.0.0 (dry-run mode).");
"v0.0.0".to_string()
} else {
anyhow::bail!("no git tag found; create a tag or use --snapshot");
}
}
}
};
let is_synthetic_tag = tag == "v0.0.0" && tag_override.is_none();
if !is_synthetic_tag
&& let Ok(false) = git::tag_points_at_head(&tag)
&& !ctx.options.snapshot
{
let head = git::get_short_commit().unwrap_or_else(|_| "unknown".to_string());
anyhow::bail!(
"tag {} does not point at HEAD ({}). Check out the tag or use --snapshot to skip this check.",
tag,
head
);
}
match git::detect_git_info(&tag, ctx.skip_validate()) {
Ok(mut git_info) => {
if git_info.dirty && !ctx.options.snapshot {
if ctx.options.dry_run {
log.warn("git is in a dirty state; run `git status` to see what changed.");
} else {
anyhow::bail!(
"git is in a dirty state; run `git status` to see what changed. \
Use --snapshot to force."
);
}
}
let prev_override = std::env::var("ANODIZER_PREVIOUS_TAG")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::var("GORELEASER_PREVIOUS_TAG")
.ok()
.filter(|s| !s.is_empty())
});
if let Some(prev_override) = prev_override {
log.verbose(&format!(
"using ANODIZER_PREVIOUS_TAG override: {}",
prev_override
));
git_info.previous_tag = Some(prev_override);
} else {
let crate_prefix = git::extract_tag_prefix(&crate_cfg.tag_template);
let prefix = crate_prefix
.as_deref()
.or_else(|| config.monorepo_tag_prefix());
git_info.previous_tag = git::find_previous_tag_with_prefix(
&tag,
config.git.as_ref(),
Some(ctx.template_vars()),
prefix,
)
.ok()
.flatten();
}
ctx.git_info = Some(git_info);
ctx.populate_git_vars();
}
Err(e) => {
if ctx.options.snapshot {
log.warn(&format!(
"could not detect git info in snapshot mode, using defaults: {e}"
));
ctx.git_info = Some(git::GitInfo {
tag: tag.clone(),
commit: "none".to_string(),
short_commit: "none".to_string(),
branch: "none".to_string(),
dirty: true,
semver: git::SemVer {
major: 0,
minor: 0,
patch: 0,
prerelease: None,
build_metadata: None,
},
commit_date: String::new(),
commit_timestamp: String::new(),
previous_tag: None,
remote_url: String::new(),
summary: "snapshot".to_string(),
tag_subject: String::new(),
tag_contents: String::new(),
tag_body: String::new(),
first_commit: None,
});
ctx.populate_git_vars();
} else {
return Err(anyhow::anyhow!("could not detect git info: {e}"));
}
}
}
} else {
ctx.populate_git_vars();
}
Ok(())
}
fn merge_env_with_defaults(
defaults_env: Option<&Vec<String>>,
config_env: Option<&Vec<String>>,
) -> Option<Vec<String>> {
match (defaults_env, config_env) {
(None, None) => None,
(Some(d), None) => Some(d.clone()),
(None, Some(c)) => Some(c.clone()),
(Some(d), Some(c)) => {
let mut v = Vec::with_capacity(d.len() + c.len());
v.extend(d.iter().cloned());
v.extend(c.iter().cloned());
Some(v)
}
}
}
pub fn setup_env(
ctx: &mut Context,
config: &Config,
log: &anodizer_core::log::StageLogger,
) -> anyhow::Result<()> {
for (key, value) in std::env::vars() {
ctx.template_vars_mut().set_env(&key, &value);
}
if let Some(ref env_files_config) = config.env_files {
match env_files_config {
anodizer_core::config::EnvFilesConfig::List(files) => {
let env_vars = anodizer_core::config::load_env_files(files, log, ctx.is_strict())
.map_err(anyhow::Error::msg)?;
for (key, value) in &env_vars {
ctx.template_vars_mut().set_config_env(key, value);
}
}
anodizer_core::config::EnvFilesConfig::TokenFiles(token_config) => {
let token_vars = anodizer_core::config::load_token_files(token_config, log)
.map_err(anyhow::Error::msg)?;
for (key, value) in &token_vars {
ctx.template_vars_mut().set_config_env(key, value);
set_env_var_single_threaded(key, value);
}
}
}
} else {
let default_config = anodizer_core::config::EnvFilesTokenConfig::default();
let token_vars = anodizer_core::config::load_token_files(&default_config, log)
.map_err(anyhow::Error::msg)?;
for (key, value) in &token_vars {
ctx.template_vars_mut().set_config_env(key, value);
set_env_var_single_threaded(key, value);
}
}
let merged_env = merge_env_with_defaults(
config.defaults.as_ref().and_then(|d| d.env.as_ref()),
config.env.as_ref(),
);
if let Some(ref env_list) = merged_env {
let rendered_pairs =
anodizer_core::config::render_env_entries(env_list, |v| ctx.render_template(v))
.with_context(|| "config.env: parse and render entries")?;
for (key, rendered) in rendered_pairs {
ctx.template_vars_mut().set_config_env(&key, &rendered);
set_env_var_single_threaded(&key, &rendered);
}
}
if let Some(ref vars_map) = config.variables {
for (key, value) in vars_map {
let rendered = ctx.render_template(value).unwrap_or_else(|_| value.clone());
ctx.template_vars_mut().set_custom_var(key, &rendered);
}
}
let resolved_force = resolve_force_token(config);
if let Some(ref forced) = resolved_force {
let keep_github = matches!(forced, ForceTokenKind::GitHub);
let keep_gitlab = matches!(forced, ForceTokenKind::GitLab);
let keep_gitea = matches!(forced, ForceTokenKind::Gitea);
if !keep_github {
unsafe {
std::env::remove_var("GITHUB_TOKEN");
std::env::remove_var("ANODIZER_GITHUB_TOKEN");
}
}
if !keep_gitlab {
unsafe { std::env::remove_var("GITLAB_TOKEN") };
}
if !keep_gitea {
unsafe { std::env::remove_var("GITEA_TOKEN") };
}
}
if resolved_force.is_none() {
let has_github =
std::env::var("GITHUB_TOKEN").is_ok() || std::env::var("ANODIZER_GITHUB_TOKEN").is_ok();
let has_gitlab = std::env::var("GITLAB_TOKEN").is_ok();
let has_gitea = std::env::var("GITEA_TOKEN").is_ok();
let count = [has_github, has_gitlab, has_gitea]
.iter()
.filter(|&&b| b)
.count();
if count > 1 {
anyhow::bail!(
"multiple SCM tokens set simultaneously ({}). Set force_token in config \
or ANODIZER_FORCE_TOKEN env var to specify which to use.",
[
if has_github {
Some("GITHUB_TOKEN")
} else {
None
},
if has_gitlab {
Some("GITLAB_TOKEN")
} else {
None
},
if has_gitea { Some("GITEA_TOKEN") } else { None },
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(", ")
);
}
}
if ctx.options.token.is_none()
&& !ctx.is_snapshot()
&& !ctx.is_dry_run()
&& !ctx.options.publish_only
{
let release_skipped = match config
.crates
.first()
.and_then(|c| c.release.as_ref()?.skip.as_ref())
{
Some(d) => d
.try_evaluates_to_true(|t| ctx.render_template(t))
.with_context(|| "release: render skip template")?,
None => false,
};
let needs_token = config.crates.iter().any(|c| c.release.is_some())
&& !ctx.should_skip("release")
&& !release_skipped;
if needs_token {
let hint = match ctx.token_type {
anodizer_core::scm::ScmTokenType::GitLab => {
"no GitLab token found. Set GITLAB_TOKEN."
}
anodizer_core::scm::ScmTokenType::Gitea => "no Gitea token found. Set GITEA_TOKEN.",
anodizer_core::scm::ScmTokenType::GitHub => {
"no GitHub token found. Set GITHUB_TOKEN or ANODIZER_GITHUB_TOKEN."
}
};
anyhow::bail!("{}", hint);
}
}
Ok(())
}
pub fn write_effective_config(config: &Config, log: &StageLogger) -> Result<()> {
let dist = &config.dist;
std::fs::create_dir_all(dist)
.with_context(|| format!("failed to create dist directory: {}", dist.display()))?;
let effective_path = dist.join("config.yaml");
let mut value: serde_yaml_ng::Value =
serde_yaml_ng::to_value(config).context("failed to serialize effective config")?;
sort_yaml_mapping(&mut value);
let yaml = serde_yaml_ng::to_string(&value).context("failed to serialize effective config")?;
std::fs::write(&effective_path, &yaml)
.with_context(|| format!("failed to write {}", effective_path.display()))?;
log.verbose(&format!(
"wrote effective config to {}",
effective_path.display()
));
Ok(())
}
fn sort_yaml_mapping(value: &mut serde_yaml_ng::Value) {
use serde_yaml_ng::{Mapping, Value};
match value {
Value::Mapping(map) => {
let mut entries: Vec<(Value, Value)> = std::mem::take(map).into_iter().collect();
entries.sort_by_key(|(a, _)| yaml_key_sort_key(a));
let mut sorted = Mapping::with_capacity(entries.len());
for (k, mut v) in entries {
sort_yaml_mapping(&mut v);
sorted.insert(k, v);
}
*map = sorted;
}
Value::Sequence(seq) => {
for v in seq.iter_mut() {
sort_yaml_mapping(v);
}
}
Value::Tagged(tagged) => sort_yaml_mapping(&mut tagged.value),
_ => {}
}
}
fn yaml_key_sort_key(v: &serde_yaml_ng::Value) -> String {
match v {
serde_yaml_ng::Value::String(s) => s.clone(),
other => format!("{:?}", other),
}
}
pub fn run_report_sizes(ctx: &mut Context, config: &Config, log: &StageLogger) {
if config.report_sizes.unwrap_or(false) {
anodizer_core::artifact::print_size_report(&mut ctx.artifacts, log);
}
}
pub fn write_metadata_and_artifacts(
ctx: &mut Context,
config: &Config,
log: &StageLogger,
) -> Result<()> {
let dist = &config.dist;
std::fs::create_dir_all(dist)
.with_context(|| format!("failed to create dist directory: {}", dist.display()))?;
let metadata_path = dist.join("metadata.json");
let goos = anodizer_core::context::map_os_to_goos(std::env::consts::OS);
let goarch = anodizer_core::context::map_arch_to_goarch(std::env::consts::ARCH);
let tag = ctx.template_vars().get("Tag").cloned().unwrap_or_default();
let previous_tag = ctx
.template_vars()
.get("PreviousTag")
.cloned()
.unwrap_or_default();
let version = ctx.version();
let commit = ctx
.template_vars()
.get("FullCommit")
.cloned()
.unwrap_or_default();
let date = ctx.template_vars().get("Date").cloned().unwrap_or_default();
let project_metadata = serde_json::json!({
"project_name": config.project_name,
"tag": tag,
"previous_tag": previous_tag,
"version": version,
"commit": commit,
"date": date,
"runtime": {
"goos": goos,
"goarch": goarch,
}
});
let json_str = serde_json::to_string_pretty(&project_metadata)
.context("failed to serialize project metadata JSON")?;
std::fs::write(&metadata_path, &json_str)
.with_context(|| format!("failed to write {}", metadata_path.display()))?;
log.status(&format!("wrote {}", metadata_path.display()));
ctx.artifacts.add(anodizer_core::artifact::Artifact {
kind: ArtifactKind::Metadata,
name: "metadata.json".to_string(),
path: metadata_path.clone(),
target: None,
crate_name: config.project_name.clone(),
metadata: Default::default(),
size: None,
});
let artifacts_path = dist.join("artifacts.json");
let artifacts_json = ctx
.artifacts
.to_artifacts_json()
.context("failed to serialize artifact list")?;
let json_str = serde_json::to_string_pretty(&artifacts_json)
.context("failed to serialize artifacts JSON")?;
std::fs::write(&artifacts_path, &json_str)
.with_context(|| format!("failed to write {}", artifacts_path.display()))?;
log.status(&format!("wrote {}", artifacts_path.display()));
if let Some(ref meta) = config.metadata
&& let Some(ref ts_tmpl) = meta.mod_timestamp
{
let rendered = ctx
.render_template(ts_tmpl)
.context("failed to render metadata.mod_timestamp template")?;
if !rendered.is_empty() {
let mtime = anodizer_core::util::parse_mod_timestamp(&rendered)
.with_context(|| format!("invalid metadata.mod_timestamp value: {:?}", rendered))?;
anodizer_core::util::set_file_mtime(&metadata_path, mtime)?;
anodizer_core::util::set_file_mtime(&artifacts_path, mtime)?;
log.status(&format!(
"set mtime on metadata.json and artifacts.json to {}",
rendered
));
}
}
Ok(())
}
pub fn infer_project_name(config: &mut Config, log: &StageLogger) {
if !config.project_name.is_empty() {
return;
}
if let Ok(cargo_toml) = std::fs::read_to_string("Cargo.toml")
&& let Ok(doc) = cargo_toml.parse::<toml_edit::DocumentMut>()
&& let Some(name) = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
{
config.project_name = name.to_string();
log.verbose(&format!("inferred project_name '{}' from Cargo.toml", name));
}
}
pub fn auto_detect_github(config: &mut Config, log: &StageLogger) {
let detected_github = git::detect_github_repo().ok();
for crate_cfg in &mut config.crates {
if let Some(ref mut release) = crate_cfg.release
&& release.github.is_none()
{
if let Some((ref owner, ref name)) = detected_github {
release.github = Some(GitHubConfig {
owner: owner.clone(),
name: name.clone(),
});
} else {
log.warn("could not auto-detect GitHub repo from git remote");
}
}
}
}
pub fn setup_context(ctx: &mut Context, config: &Config, log: &StageLogger) -> Result<()> {
resolve_scm_token_type(ctx, config);
ctx.populate_time_vars();
ctx.populate_runtime_vars();
ctx.template_vars_mut().set("IsPrepare", "false");
setup_env(ctx, config, log)?;
resolve_git_context(ctx, config, log)?;
Ok(())
}
pub fn resolve_scm_token_type(ctx: &mut Context, config: &Config) {
let env_hint = if std::env::var("GITLAB_TOKEN").is_ok() {
Some("gitlab")
} else if std::env::var("GITEA_TOKEN").is_ok() {
Some("gitea")
} else {
None
};
let force_token = resolve_force_token(config);
ctx.token_type = scm::resolve_token_type(force_token.as_ref(), env_hint);
if ctx.options.token.is_none() {
ctx.options.token = match ctx.token_type {
ScmTokenType::GitLab => std::env::var("GITLAB_TOKEN").ok(),
ScmTokenType::Gitea => std::env::var("GITEA_TOKEN").ok(),
ScmTokenType::GitHub => std::env::var("ANODIZER_GITHUB_TOKEN")
.ok()
.or_else(|| std::env::var("GITHUB_TOKEN").ok()),
};
}
}
pub fn init_publish_stage_ctx(
config_override: Option<&Path>,
ctx_opts: anodizer_core::context::ContextOptions,
dist_override: Option<&Path>,
infer_project: bool,
log: &StageLogger,
) -> Result<(Config, Context, std::path::PathBuf)> {
let config_path = crate::pipeline::find_config_with_logger(config_override, Some(log))?;
let mut config = crate::pipeline::load_config(&config_path)?;
if infer_project {
infer_project_name(&mut config, log);
}
auto_detect_github(&mut config, log);
let mut ctx = Context::new(config.clone(), ctx_opts);
setup_context(&mut ctx, &config, log)?;
let dist = dist_override.unwrap_or(&config.dist).to_path_buf();
load_artifacts_from_dist(&mut ctx, &dist)?;
log.status(&format!(
"loaded {} artifact(s) from {}",
ctx.artifacts.all().len(),
dist.display()
));
Ok((config, ctx, dist))
}
pub fn load_artifacts_from_dist(ctx: &mut Context, dist: &Path) -> Result<()> {
let artifacts_path = dist.join("artifacts.json");
load_artifacts_from_manifest(ctx, dist, &artifacts_path)
}
pub fn load_artifacts_from_manifest(
ctx: &mut Context,
dist: &Path,
manifest_path: &Path,
) -> Result<()> {
if !manifest_path.exists() {
anyhow::bail!(
"no artifacts manifest found at {} (under {}). Run a full release or merge first.",
manifest_path.display(),
dist.display()
);
}
let content = std::fs::read_to_string(manifest_path)
.with_context(|| format!("read {}", manifest_path.display()))?;
#[derive(serde::Deserialize)]
struct MetadataArtifact {
kind: String,
#[serde(default)]
name: Option<String>,
path: String,
target: Option<String>,
crate_name: String,
#[serde(default)]
metadata: HashMap<String, String>,
#[serde(default)]
size: Option<u64>,
}
let artifacts: Vec<MetadataArtifact> = serde_json::from_str(&content)
.with_context(|| format!("parse {}", manifest_path.display()))?;
for a in artifacts {
let kind = ArtifactKind::parse(&a.kind)
.ok_or_else(|| anyhow::anyhow!("unknown artifact kind: {}", a.kind))?;
ctx.artifacts.add(Artifact {
kind,
name: a.name.unwrap_or_default(),
path: std::path::PathBuf::from(&a.path),
target: a.target,
crate_name: a.crate_name,
metadata: a.metadata,
size: a.size,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::{ChangelogConfig, CrateConfig, SignConfig};
use anodizer_core::context::ContextOptions;
use anodizer_core::scm::ScmTokenType;
#[test]
fn write_effective_config_emits_sorted_keys() {
let tmp = tempfile::tempdir().unwrap();
let mut variables = HashMap::new();
variables.insert("zeta".to_string(), "1".to_string());
variables.insert("alpha".to_string(), "2".to_string());
variables.insert("mu".to_string(), "3".to_string());
variables.insert("beta".to_string(), "4".to_string());
variables.insert("nu".to_string(), "5".to_string());
let config = Config {
project_name: "anodize".to_string(),
dist: tmp.path().to_path_buf(),
variables: Some(variables),
..Default::default()
};
let log = StageLogger::new("test", anodizer_core::log::Verbosity::Quiet);
let mut variables_reversed = HashMap::new();
for key in ["nu", "beta", "mu", "alpha", "zeta"] {
let v = match key {
"zeta" => "1",
"alpha" => "2",
"mu" => "3",
"beta" => "4",
"nu" => "5",
_ => unreachable!(),
};
variables_reversed.insert(key.to_string(), v.to_string());
}
let config_reversed = Config {
variables: Some(variables_reversed),
..config.clone()
};
write_effective_config(&config, &log).expect("first write");
let yaml1 = std::fs::read_to_string(tmp.path().join("config.yaml")).unwrap();
write_effective_config(&config_reversed, &log).expect("second write");
let yaml2 = std::fs::read_to_string(tmp.path().join("config.yaml")).unwrap();
assert_eq!(
yaml1, yaml2,
"two write_effective_config calls with identical input keys \
must produce byte-identical YAML regardless of HashMap \
insertion order (HashMap-iteration drift would fail this)"
);
let var_block_lines: Vec<&str> = yaml1
.lines()
.skip_while(|l| !l.starts_with("variables:"))
.skip(1)
.take_while(|l| l.starts_with(" ") || l.starts_with('\t'))
.collect();
let keys: Vec<&str> = var_block_lines
.iter()
.filter_map(|l| l.trim().split(':').next())
.collect();
assert_eq!(
keys,
vec!["alpha", "beta", "mu", "nu", "zeta"],
"variables: keys must be emitted in alphabetical order; got {:?} \
from yaml:\n{}",
keys,
yaml1,
);
}
#[test]
fn sort_yaml_mapping_recurses_into_nested_maps_and_sequences() {
let yaml = "\
top:
z: 1
a: 2
list:
- inner_z: 1
inner_a: 2
- solo: 3
";
let mut value: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml).unwrap();
sort_yaml_mapping(&mut value);
let out = serde_yaml_ng::to_string(&value).unwrap();
let first_line = out.lines().next().unwrap();
assert!(
first_line.starts_with("list:"),
"top-level keys must be sorted alphabetically; got {out:?}"
);
let top_pos = out.find("top:").unwrap();
let top_block = &out[top_pos..];
let a_pos = top_block.find("a:").expect("a: present");
let z_pos = top_block.find("z:").expect("z: present");
assert!(
a_pos < z_pos,
"nested mapping under `top:` must be sorted; got {out:?}"
);
let list_pos = out.find("list:").unwrap();
let list_block = &out[list_pos..];
let inner_a = list_block.find("inner_a:").expect("inner_a: present");
let inner_z = list_block.find("inner_z:").expect("inner_z: present");
assert!(
inner_a < inner_z,
"nested mapping inside a sequence element must be sorted; got {out:?}"
);
}
fn make_crate(name: &str) -> CrateConfig {
CrateConfig {
name: name.to_string(),
path: ".".to_string(),
tag_template: format!("{}-v{{{{ .Version }}}}", name),
..Default::default()
}
}
#[test]
fn test_apply_workspace_overlay_replaces_crates() {
let mut config = Config {
project_name: "test".to_string(),
crates: vec![make_crate("original")],
..Default::default()
};
let ws = WorkspaceConfig {
name: "ws".to_string(),
crates: vec![make_crate("ws-crate")],
..Default::default()
};
apply_workspace_overlay(&mut config, &ws);
assert_eq!(config.crates.len(), 1);
assert_eq!(config.crates[0].name, "ws-crate");
}
#[test]
fn test_apply_workspace_overlay_merges_env() {
let mut config = Config {
project_name: "test".to_string(),
env: Some(vec![
"SHARED=from-top".to_string(),
"TOP_ONLY=top-value".to_string(),
]),
..Default::default()
};
let ws = WorkspaceConfig {
name: "ws".to_string(),
crates: vec![],
env: Some(vec![
"SHARED=from-ws".to_string(),
"WS_ONLY=ws-value".to_string(),
]),
..Default::default()
};
apply_workspace_overlay(&mut config, &ws);
let env = config.env.as_ref().unwrap();
assert!(env.contains(&"TOP_ONLY=top-value".to_string()));
assert!(env.contains(&"SHARED=from-ws".to_string()));
assert!(env.contains(&"WS_ONLY=ws-value".to_string()));
}
#[test]
fn test_apply_workspace_overlay_replaces_signs() {
let mut config = Config {
project_name: "test".to_string(),
signs: vec![SignConfig {
cmd: Some("gpg".to_string()),
..Default::default()
}],
..Default::default()
};
let ws = WorkspaceConfig {
name: "ws".to_string(),
crates: vec![],
signs: vec![SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
}],
..Default::default()
};
apply_workspace_overlay(&mut config, &ws);
assert_eq!(config.signs.len(), 1);
assert_eq!(config.signs[0].cmd.as_deref(), Some("cosign"));
}
#[test]
fn test_apply_workspace_overlay_replaces_changelog() {
let mut config = Config {
project_name: "test".to_string(),
changelog: Some(ChangelogConfig {
sort: Some("asc".to_string()),
..Default::default()
}),
..Default::default()
};
let ws = WorkspaceConfig {
name: "ws".to_string(),
crates: vec![],
changelog: Some(ChangelogConfig {
sort: Some("desc".to_string()),
..Default::default()
}),
..Default::default()
};
apply_workspace_overlay(&mut config, &ws);
assert_eq!(
config.changelog.as_ref().unwrap().sort.as_deref(),
Some("desc")
);
}
#[test]
fn test_apply_workspace_overlay_skips_none_fields() {
let mut config = Config {
project_name: "test".to_string(),
changelog: Some(ChangelogConfig {
sort: Some("asc".to_string()),
..Default::default()
}),
..Default::default()
};
let ws = WorkspaceConfig {
name: "ws".to_string(),
crates: vec![],
..Default::default()
};
apply_workspace_overlay(&mut config, &ws);
assert_eq!(
config.changelog.as_ref().unwrap().sort.as_deref(),
Some("asc")
);
}
#[test]
fn test_load_artifacts_from_dist_valid() {
use anodizer_core::artifact::ArtifactKind;
use anodizer_core::context::{Context, ContextOptions};
let dir = tempfile::TempDir::new().unwrap();
let artifacts_json = serde_json::json!([
{
"kind": "binary",
"name": "myapp",
"path": "dist/myapp",
"target": "x86_64-unknown-linux-gnu",
"crate_name": "myapp",
"metadata": {},
"size": 4096
},
{
"kind": "archive",
"name": "myapp.tar.gz",
"path": "dist/myapp.tar.gz",
"target": null,
"crate_name": "myapp",
"metadata": {"format": "tar.gz"}
}
]);
std::fs::write(
dir.path().join("artifacts.json"),
serde_json::to_string_pretty(&artifacts_json).unwrap(),
)
.unwrap();
let config = Config::default();
let mut ctx = Context::new(config, ContextOptions::default());
load_artifacts_from_dist(&mut ctx, dir.path()).unwrap();
let all = ctx.artifacts.all();
assert_eq!(all.len(), 2);
assert_eq!(all[0].kind, ArtifactKind::Binary);
assert_eq!(all[0].name, "myapp");
assert_eq!(
all[0].size,
Some(4096),
"size should be preserved from JSON"
);
assert_eq!(all[1].kind, ArtifactKind::Archive);
assert_eq!(all[1].name, "myapp.tar.gz");
assert_eq!(
all[1].metadata.get("format").map(|s| s.as_str()),
Some("tar.gz")
);
assert_eq!(
all[1].size, None,
"size should be None when absent from JSON"
);
}
#[test]
fn test_load_artifacts_from_dist_missing_file() {
use anodizer_core::context::{Context, ContextOptions};
let dir = tempfile::TempDir::new().unwrap();
let config = Config::default();
let mut ctx = Context::new(config, ContextOptions::default());
let result = load_artifacts_from_dist(&mut ctx, dir.path());
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("no artifacts manifest found"),
"error should mention missing file: {msg}"
);
}
#[test]
fn test_load_artifacts_from_dist_invalid_json() {
use anodizer_core::context::{Context, ContextOptions};
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("artifacts.json"), "not valid json").unwrap();
let config = Config::default();
let mut ctx = Context::new(config, ContextOptions::default());
let result = load_artifacts_from_dist(&mut ctx, dir.path());
assert!(result.is_err());
}
#[test]
fn test_load_artifacts_from_dist_unknown_kind() {
use anodizer_core::context::{Context, ContextOptions};
let dir = tempfile::TempDir::new().unwrap();
let artifacts_json = serde_json::json!([
{
"kind": "unknown_kind",
"name": "thing",
"path": "dist/thing",
"target": null,
"crate_name": "myapp",
"metadata": {}
}
]);
std::fs::write(
dir.path().join("artifacts.json"),
serde_json::to_string_pretty(&artifacts_json).unwrap(),
)
.unwrap();
let config = Config::default();
let mut ctx = Context::new(config, ContextOptions::default());
let result = load_artifacts_from_dist(&mut ctx, dir.path());
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("unknown artifact kind"),
"error should mention unknown kind: {msg}"
);
}
#[test]
fn test_load_artifacts_from_dist_roundtrip() {
use anodizer_core::artifact::{Artifact, ArtifactKind, ArtifactRegistry};
use anodizer_core::context::{Context, ContextOptions};
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Checksum,
name: String::new(),
path: std::path::PathBuf::from("dist/checksums.txt"),
target: None,
crate_name: "myapp".to_string(),
metadata: Default::default(),
size: Some(256),
});
registry.add(Artifact {
kind: ArtifactKind::Binary,
name: String::new(),
path: std::path::PathBuf::from("dist/myapp"),
target: Some("aarch64-apple-darwin".to_string()),
crate_name: "myapp".to_string(),
metadata: Default::default(),
size: None,
});
let json_val = registry.to_artifacts_json().unwrap();
let json_str = serde_json::to_string_pretty(&json_val).unwrap();
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("artifacts.json"), &json_str).unwrap();
let config = Config::default();
let mut ctx = Context::new(config, ContextOptions::default());
load_artifacts_from_dist(&mut ctx, dir.path()).unwrap();
let loaded = ctx.artifacts.all();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].kind, ArtifactKind::Binary);
assert_eq!(loaded[0].name, "myapp");
assert_eq!(loaded[0].target.as_deref(), Some("aarch64-apple-darwin"));
assert_eq!(loaded[0].size, None);
assert_eq!(loaded[1].kind, ArtifactKind::Checksum);
assert_eq!(loaded[1].name, "checksums.txt");
assert_eq!(loaded[1].size, Some(256));
}
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_clean_token_env<F: FnOnce()>(f: F) {
let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let saved: Vec<(&str, Option<String>)> = [
"GITLAB_TOKEN",
"GITEA_TOKEN",
"ANODIZER_GITHUB_TOKEN",
"GITHUB_TOKEN",
"ANODIZER_FORCE_TOKEN",
"GORELEASER_FORCE_TOKEN",
]
.iter()
.map(|&k| (k, std::env::var(k).ok()))
.collect();
for &(k, _) in &saved {
unsafe { std::env::remove_var(k) };
}
f();
for (k, v) in saved {
match v {
Some(val) => unsafe { std::env::set_var(k, val) },
None => unsafe { std::env::remove_var(k) },
}
}
}
#[test]
fn test_resolve_scm_token_type_default_is_github() {
with_clean_token_env(|| {
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(ctx.token_type, ScmTokenType::GitHub);
assert!(ctx.options.token.is_none());
});
}
#[test]
fn test_resolve_scm_token_type_force_gitlab() {
with_clean_token_env(|| {
let config = Config {
force_token: Some(ForceTokenKind::GitLab),
..Default::default()
};
let mut ctx = Context::new(config.clone(), ContextOptions::default());
unsafe { std::env::set_var("GITLAB_TOKEN", "glpat-test123") };
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(ctx.token_type, ScmTokenType::GitLab);
assert_eq!(ctx.options.token.as_deref(), Some("glpat-test123"));
});
}
#[test]
fn test_resolve_scm_token_type_force_gitea() {
with_clean_token_env(|| {
let config = Config {
force_token: Some(ForceTokenKind::Gitea),
..Default::default()
};
let mut ctx = Context::new(config.clone(), ContextOptions::default());
unsafe { std::env::set_var("GITEA_TOKEN", "gitea-tok") };
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(ctx.token_type, ScmTokenType::Gitea);
assert_eq!(ctx.options.token.as_deref(), Some("gitea-tok"));
});
}
#[test]
fn test_resolve_scm_token_type_env_gitlab_detected() {
with_clean_token_env(|| {
unsafe { std::env::set_var("GITLAB_TOKEN", "glpat-env") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(ctx.token_type, ScmTokenType::GitLab);
assert_eq!(ctx.options.token.as_deref(), Some("glpat-env"));
});
}
#[test]
fn test_resolve_scm_token_type_env_gitea_detected() {
with_clean_token_env(|| {
unsafe { std::env::set_var("GITEA_TOKEN", "gitea-env") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(ctx.token_type, ScmTokenType::Gitea);
assert_eq!(ctx.options.token.as_deref(), Some("gitea-env"));
});
}
#[test]
fn test_resolve_scm_token_type_github_token_from_env() {
with_clean_token_env(|| {
unsafe { std::env::set_var("GITHUB_TOKEN", "ghp-from-env") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(ctx.token_type, ScmTokenType::GitHub);
assert_eq!(ctx.options.token.as_deref(), Some("ghp-from-env"));
});
}
#[test]
fn test_resolve_scm_token_type_anodizer_github_token_takes_precedence() {
with_clean_token_env(|| {
unsafe { std::env::set_var("ANODIZER_GITHUB_TOKEN", "anodizer-tok") };
unsafe { std::env::set_var("GITHUB_TOKEN", "gh-tok") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(ctx.token_type, ScmTokenType::GitHub);
assert_eq!(
ctx.options.token.as_deref(),
Some("anodizer-tok"),
"ANODIZER_GITHUB_TOKEN should take precedence over GITHUB_TOKEN"
);
});
}
#[test]
fn test_resolve_scm_token_type_cli_token_preserved() {
with_clean_token_env(|| {
unsafe { std::env::set_var("GITHUB_TOKEN", "from-env") };
let config = Config::default();
let opts = ContextOptions {
token: Some("from-cli".to_string()),
..Default::default()
};
let mut ctx = Context::new(config.clone(), opts);
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(ctx.token_type, ScmTokenType::GitHub);
assert_eq!(
ctx.options.token.as_deref(),
Some("from-cli"),
"CLI --token flag should not be overwritten by env var"
);
});
}
#[test]
fn test_resolve_scm_token_type_force_overrides_env_detection() {
with_clean_token_env(|| {
unsafe { std::env::set_var("GITLAB_TOKEN", "glpat-ignored") };
let config = Config {
force_token: Some(ForceTokenKind::GitHub),
..Default::default()
};
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(
ctx.token_type,
ScmTokenType::GitHub,
"force_token should override env-based detection"
);
assert!(
ctx.options.token.is_none(),
"no GitHub token env var set, so token should remain None"
);
});
}
#[test]
fn test_resolve_scm_token_type_gitlab_priority_over_gitea() {
with_clean_token_env(|| {
unsafe { std::env::set_var("GITLAB_TOKEN", "gl-tok") };
unsafe { std::env::set_var("GITEA_TOKEN", "gt-tok") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(
ctx.token_type,
ScmTokenType::GitLab,
"GITLAB_TOKEN should be checked before GITEA_TOKEN"
);
assert_eq!(ctx.options.token.as_deref(), Some("gl-tok"));
});
}
#[test]
fn test_resolve_scm_token_type_anodizer_force_token_env_gitlab() {
with_clean_token_env(|| {
unsafe { std::env::set_var("ANODIZER_FORCE_TOKEN", "gitlab") };
unsafe { std::env::set_var("GITLAB_TOKEN", "glpat-env") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(
ctx.token_type,
ScmTokenType::GitLab,
"ANODIZER_FORCE_TOKEN=gitlab should force GitLab"
);
assert_eq!(ctx.options.token.as_deref(), Some("glpat-env"));
});
}
#[test]
fn test_resolve_scm_token_type_anodizer_force_token_env_github() {
with_clean_token_env(|| {
unsafe { std::env::set_var("ANODIZER_FORCE_TOKEN", "github") };
unsafe { std::env::set_var("GITLAB_TOKEN", "glpat-ignored") };
unsafe { std::env::set_var("GITHUB_TOKEN", "ghp-forced") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(
ctx.token_type,
ScmTokenType::GitHub,
"ANODIZER_FORCE_TOKEN=github should override GITLAB_TOKEN detection"
);
assert_eq!(ctx.options.token.as_deref(), Some("ghp-forced"));
});
}
#[test]
fn test_resolve_scm_token_type_goreleaser_force_token_compat() {
with_clean_token_env(|| {
unsafe { std::env::set_var("GORELEASER_FORCE_TOKEN", "gitea") };
unsafe { std::env::set_var("GITEA_TOKEN", "gitea-compat") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(
ctx.token_type,
ScmTokenType::Gitea,
"GORELEASER_FORCE_TOKEN should work as compat fallback"
);
assert_eq!(ctx.options.token.as_deref(), Some("gitea-compat"));
});
}
#[test]
fn test_resolve_scm_token_type_anodizer_force_token_overrides_goreleaser() {
with_clean_token_env(|| {
unsafe { std::env::set_var("ANODIZER_FORCE_TOKEN", "github") };
unsafe { std::env::set_var("GORELEASER_FORCE_TOKEN", "gitlab") };
unsafe { std::env::set_var("GITHUB_TOKEN", "ghp-wins") };
unsafe { std::env::set_var("GITLAB_TOKEN", "glpat-loses") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(
ctx.token_type,
ScmTokenType::GitHub,
"ANODIZER_FORCE_TOKEN should take precedence over GORELEASER_FORCE_TOKEN"
);
assert_eq!(ctx.options.token.as_deref(), Some("ghp-wins"));
});
}
#[test]
fn test_resolve_scm_token_type_config_force_token_overrides_env() {
with_clean_token_env(|| {
unsafe { std::env::set_var("ANODIZER_FORCE_TOKEN", "gitlab") };
unsafe { std::env::set_var("GITHUB_TOKEN", "ghp-config") };
let config = Config {
force_token: Some(ForceTokenKind::GitHub),
..Default::default()
};
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(
ctx.token_type,
ScmTokenType::GitHub,
"config.force_token should override ANODIZER_FORCE_TOKEN env var"
);
assert_eq!(ctx.options.token.as_deref(), Some("ghp-config"));
});
}
#[test]
fn test_resolve_scm_token_type_invalid_force_token_env_ignored() {
with_clean_token_env(|| {
unsafe { std::env::set_var("ANODIZER_FORCE_TOKEN", "invalid") };
unsafe { std::env::set_var("GITLAB_TOKEN", "glpat-detected") };
let config = Config::default();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
resolve_scm_token_type(&mut ctx, &config);
assert_eq!(
ctx.token_type,
ScmTokenType::GitLab,
"invalid ANODIZER_FORCE_TOKEN should fall back to env detection"
);
assert_eq!(ctx.options.token.as_deref(), Some("glpat-detected"));
});
}
#[test]
fn test_collect_build_targets_per_build_overrides_defaults() {
use anodizer_core::config::{BuildConfig, Defaults};
let config = Config {
project_name: "test".to_string(),
defaults: Some(Defaults {
targets: Some(vec!["a".to_string(), "b".to_string()]),
..Default::default()
}),
crates: vec![CrateConfig {
name: "k1".to_string(),
path: ".".to_string(),
tag_template: "v{{ Version }}".to_string(),
builds: Some(vec![BuildConfig {
targets: Some(vec!["c".to_string()]),
..Default::default()
}]),
..Default::default()
}],
..Default::default()
};
let result = collect_build_targets(&config, &[]);
assert_eq!(
result,
vec!["c".to_string()],
"per-build targets should REPLACE defaults.targets, not concat",
);
}
#[test]
fn test_collect_build_targets_per_build_none_falls_back_to_defaults() {
use anodizer_core::config::{BuildConfig, Defaults};
let config = Config {
project_name: "test".to_string(),
defaults: Some(Defaults {
targets: Some(vec!["a".to_string(), "b".to_string()]),
..Default::default()
}),
crates: vec![CrateConfig {
name: "k1".to_string(),
path: ".".to_string(),
tag_template: "v{{ Version }}".to_string(),
builds: Some(vec![BuildConfig {
targets: None, ..Default::default()
}]),
..Default::default()
}],
..Default::default()
};
let result = collect_build_targets(&config, &[]);
assert_eq!(
result,
vec!["a".to_string(), "b".to_string()],
"build with targets=None should inherit defaults.targets",
);
}
#[test]
fn test_merge_env_with_defaults_both_none_yields_none() {
assert!(merge_env_with_defaults(None, None).is_none());
}
#[test]
fn test_merge_env_with_defaults_only_defaults_yields_defaults() {
let d = vec!["FOO=defaults".to_string()];
let merged = merge_env_with_defaults(Some(&d), None).unwrap();
assert_eq!(merged, vec!["FOO=defaults".to_string()]);
}
#[test]
fn test_merge_env_with_defaults_only_config_yields_config() {
let c = vec!["BAR=top".to_string()];
let merged = merge_env_with_defaults(None, Some(&c)).unwrap();
assert_eq!(merged, vec!["BAR=top".to_string()]);
}
#[test]
fn test_merge_env_with_defaults_disjoint_keys_concat() {
let d = vec!["FOO=defaults".to_string()];
let c = vec!["BAR=top".to_string()];
let merged = merge_env_with_defaults(Some(&d), Some(&c)).unwrap();
assert_eq!(
merged,
vec!["FOO=defaults".to_string(), "BAR=top".to_string()]
);
}
#[test]
fn test_merge_env_with_defaults_top_level_wins_on_collision() {
let d = vec!["FOO=a".to_string()];
let c = vec!["FOO=b".to_string()];
let merged = merge_env_with_defaults(Some(&d), Some(&c)).unwrap();
assert_eq!(merged.len(), 2);
assert_eq!(merged[0], "FOO=a");
assert_eq!(merged[1], "FOO=b");
}
use anodizer_core::config::Defaults;
use serial_test::serial;
#[test]
#[serial]
fn test_setup_env_inherits_defaults_env_when_crate_unset() {
with_clean_token_env(|| {
unsafe { std::env::remove_var("DEFAULTS_ENV_INHERITED") };
let config = Config {
defaults: Some(Defaults {
env: Some(vec!["DEFAULTS_ENV_INHERITED=defaults".to_string()]),
..Default::default()
}),
..Default::default()
};
let mut ctx = Context::new(config.clone(), ContextOptions::default());
let log =
anodizer_core::log::StageLogger::new("test", anodizer_core::log::Verbosity::Quiet);
setup_env(&mut ctx, &config, &log).expect("setup_env should succeed");
assert_eq!(
ctx.template_vars()
.all_config_env()
.get("DEFAULTS_ENV_INHERITED")
.map(|s| s.as_str()),
Some("defaults"),
"defaults.env entry should populate the template context",
);
unsafe { std::env::remove_var("DEFAULTS_ENV_INHERITED") };
});
}
#[test]
#[serial]
fn test_setup_env_top_level_env_wins_over_defaults_env() {
with_clean_token_env(|| {
unsafe { std::env::remove_var("DEFAULTS_ENV_OVERRIDE") };
let config = Config {
defaults: Some(Defaults {
env: Some(vec!["DEFAULTS_ENV_OVERRIDE=a".to_string()]),
..Default::default()
}),
env: Some(vec!["DEFAULTS_ENV_OVERRIDE=b".to_string()]),
..Default::default()
};
let mut ctx = Context::new(config.clone(), ContextOptions::default());
let log =
anodizer_core::log::StageLogger::new("test", anodizer_core::log::Verbosity::Quiet);
setup_env(&mut ctx, &config, &log).expect("setup_env should succeed");
assert_eq!(
ctx.template_vars()
.all_config_env()
.get("DEFAULTS_ENV_OVERRIDE")
.map(|s| s.as_str()),
Some("b"),
"top-level config.env should override defaults.env on duplicate key",
);
unsafe { std::env::remove_var("DEFAULTS_ENV_OVERRIDE") };
});
}
}