use crate::pipeline;
use anodizer_core::artifact;
use anodizer_core::config::Config;
use anodizer_core::context::Context;
use anyhow::{Context as _, Result};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct SplitArtifact {
pub name: String,
pub path: String,
pub os: Option<String>,
pub arch: Option<String>,
pub target: Option<String>,
#[serde(rename = "internal_type")]
pub kind: String,
#[serde(rename = "type")]
pub type_s: String,
pub crate_name: String,
pub extra: BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct SplitContext {
pub partial_target: String,
pub template_vars: BTreeMap<String, String>,
#[serde(default)]
pub env_vars: BTreeMap<String, String>,
pub git_tag: Option<String>,
pub git_commit: Option<String>,
pub git_branch: Option<String>,
pub artifacts: Vec<SplitArtifact>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct SplitMatrix {
pub split_by: String,
pub include: Vec<MatrixEntry>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct MatrixEntry {
pub target: String,
pub runner: String,
}
fn redact_secret_env_vars(env: &HashMap<String, String>) -> HashMap<String, String> {
const SECRET_SUFFIXES: &[&str] = &[
"_TOKEN",
"_SECRET",
"_PASSWORD",
"_KEY",
"_PASSPHRASE",
"_API_KEY",
];
const SECRET_SUBSTRINGS: &[&str] = &["CREDENTIAL", "APIKEY"];
env.iter()
.map(|(k, v)| {
let k_upper = k.to_uppercase();
let is_secret = SECRET_SUFFIXES.iter().any(|s| k_upper.ends_with(s))
|| SECRET_SUBSTRINGS.iter().any(|s| k_upper.contains(s));
let value = if is_secret && !v.is_empty() {
"[redacted]".to_string()
} else {
v.clone()
};
(k.clone(), value)
})
.collect()
}
fn artifact_to_split(a: &artifact::Artifact) -> SplitArtifact {
SplitArtifact {
name: a.name().to_string(),
path: a.path.to_string_lossy().into_owned(),
os: a.goos(),
arch: a.goarch(),
target: a.target.clone(),
kind: a.kind.as_str().to_string(),
type_s: format!("{:?}", a.kind),
crate_name: a.crate_name.clone(),
extra: a
.metadata
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect::<BTreeMap<_, _>>(),
sha256: None,
size: a.size,
}
}
pub(super) fn run_split(
ctx: &mut Context,
config: &Config,
log: &anodizer_core::log::StageLogger,
) -> Result<()> {
let partial_target =
anodizer_core::partial::resolve_partial_target_with_env(&config.partial, ctx.env_source())?;
let subdir = partial_target.dist_subdir();
log.status(&format!(
"split mode: building for {} (dist/{})",
match &partial_target {
anodizer_core::partial::PartialTarget::Exact(t) => t.clone(),
anodizer_core::partial::PartialTarget::OsArch { os, arch } => {
if let Some(a) = arch {
format!("{}/{}", os, a)
} else {
os.clone()
}
}
anodizer_core::partial::PartialTarget::Targets(list) => list.join(","),
},
subdir
));
let all_targets = collect_build_targets(config, ctx);
let matching = partial_target.filter_targets(&all_targets);
if matching.is_empty() && !all_targets.is_empty() {
anyhow::bail!(
"split: no build targets match {}. Available targets: [{}]",
match &partial_target {
anodizer_core::partial::PartialTarget::Exact(t) => format!("TARGET={}", t),
anodizer_core::partial::PartialTarget::OsArch { os, arch } => {
if let Some(a) = arch {
format!("ANODIZER_OS={}, ANODIZER_ARCH={}", os, a)
} else {
format!("ANODIZER_OS={}", os)
}
}
anodizer_core::partial::PartialTarget::Targets(list) =>
format!("--targets={}", list.join(",")),
},
all_targets.join(", ")
);
}
ctx.options.partial_target = Some(partial_target.clone());
let original_dist = config.dist.clone();
let split_dist = original_dist.join(&subdir);
ctx.config.dist = split_dist.clone();
std::fs::create_dir_all(&split_dist)
.with_context(|| format!("create split dist directory: {}", split_dist.display()))?;
let p = pipeline::build_split_pipeline();
p.run(ctx, log)?;
for artifact in ctx.artifacts.all_mut() {
if !artifact.path.exists() {
continue; }
if let Some(file_name) = artifact.path.file_name().map(|n| n.to_os_string()) {
let target_subdir = artifact.target.as_deref().unwrap_or("default");
let dest_dir = split_dist.join(target_subdir);
std::fs::create_dir_all(&dest_dir)
.with_context(|| format!("split: create target dir {}", dest_dir.display()))?;
let dest = dest_dir.join(&file_name);
if artifact.path != dest {
std::fs::copy(&artifact.path, &dest).with_context(|| {
format!(
"split: copy {} -> {}",
artifact.path.display(),
dest.display()
)
})?;
artifact.path = dest;
}
}
}
let split_artifacts: Vec<SplitArtifact> =
ctx.artifacts.all().iter().map(artifact_to_split).collect();
let env_vars_redacted = redact_secret_env_vars(ctx.template_vars().all_config_env());
let template_vars: BTreeMap<String, String> = ctx
.template_vars()
.all()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let env_vars: BTreeMap<String, String> = env_vars_redacted.into_iter().collect();
let split_ctx = SplitContext {
partial_target: subdir.clone(),
template_vars,
env_vars,
git_tag: ctx.template_vars().get("Tag").map(String::from),
git_commit: ctx.template_vars().get("FullCommit").map(String::from),
git_branch: ctx.template_vars().get("Branch").map(String::from),
artifacts: split_artifacts,
};
let ctx_path = split_dist.join("context.json");
let json = serde_json::to_string_pretty(&split_ctx).context("serialize split context")?;
let tmp_path = ctx_path.with_extension("json.tmp");
std::fs::write(&tmp_path, &json)
.with_context(|| format!("write split context tmp to {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &ctx_path).with_context(|| {
format!(
"rename split context {} -> {}",
tmp_path.display(),
ctx_path.display()
)
})?;
log.status(&format!(
"split: wrote {} artifact(s) + context to {}",
split_ctx.artifacts.len(),
ctx_path.display()
));
let all_targets = collect_build_targets(config, ctx);
if !all_targets.is_empty() {
let split_by = config
.partial
.as_ref()
.and_then(|p| p.by.as_deref())
.unwrap_or("os");
let matrix = build_matrix(&all_targets, split_by);
let matrix_json = serde_json::to_string_pretty(&matrix).context("serialize matrix")?;
let matrix_path = original_dist.join("matrix.json");
std::fs::create_dir_all(&original_dist)?;
std::fs::write(&matrix_path, &matrix_json)
.with_context(|| format!("write matrix to {}", matrix_path.display()))?;
log.status(&format!(
"split: wrote matrix to {} ({} entries, split by: {})",
matrix_path.display(),
matrix.include.len(),
split_by
));
}
Ok(())
}
fn build_matrix(targets: &[String], split_by: &str) -> SplitMatrix {
let mut entries = Vec::new();
let mut seen = std::collections::HashSet::new();
for t in targets {
let entry_target = if split_by == "os" {
let (os, _) = anodizer_core::target::map_target(t);
os
} else {
t.clone()
};
if seen.insert(entry_target.clone()) {
let (os, _) = anodizer_core::target::map_target(t);
let runner = anodizer_core::partial::suggest_runner(&os);
entries.push(MatrixEntry {
target: entry_target,
runner: runner.to_string(),
});
}
}
SplitMatrix {
split_by: split_by.to_string(),
include: entries,
}
}
fn check_split_worker_completeness(
dist: &Path,
context_files: &[PathBuf],
log: &anodizer_core::log::StageLogger,
) -> Result<()> {
let matrix_path = dist.join("matrix.json");
if !matrix_path.exists() {
log.verbose(&format!(
"merge: no matrix.json at {} — skipping worker-completeness check",
matrix_path.display()
));
return Ok(());
}
let matrix_content = std::fs::read_to_string(&matrix_path)
.with_context(|| format!("read matrix: {}", matrix_path.display()))?;
let matrix: SplitMatrix = serde_json::from_str(&matrix_content)
.with_context(|| format!("parse matrix: {}", matrix_path.display()))?;
let expected: std::collections::BTreeSet<String> =
matrix.include.iter().map(|e| e.target.clone()).collect();
let mut got: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for ctx_file in context_files {
let content = std::fs::read_to_string(ctx_file)
.with_context(|| format!("read split context: {}", ctx_file.display()))?;
let split_ctx: SplitContext = serde_json::from_str(&content)
.with_context(|| format!("parse split context: {}", ctx_file.display()))?;
got.insert(split_ctx.partial_target);
}
let missing: Vec<&String> = expected.difference(&got).collect();
let surplus: Vec<&String> = got.difference(&expected).collect();
if !missing.is_empty() || !surplus.is_empty() {
let mut msg = format!(
"merge: split-worker manifest mismatch (expected {} workers from {}, got {})",
expected.len(),
matrix_path.display(),
got.len()
);
if !missing.is_empty() {
msg.push_str(&format!(
".\n missing context.json from worker(s): {}",
missing
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
));
msg.push_str(
".\n Each missing worker corresponds to a split-build job that did \
not write `dist/<target>/context.json` — typically a CI runner that \
was cancelled, ran out of disk, or hit a transient build failure. \
Re-run those workers, or pass `--skip <stage>` to merge a \
deliberately-incomplete release.",
);
}
if !surplus.is_empty() {
msg.push_str(&format!(
".\n unexpected context.json from worker(s) not in matrix: {}",
surplus
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
));
msg.push_str(
".\n These contexts are likely left over from an earlier split \
run; clean `dist/` (or pass --clean on the next `release --split`) \
before retrying.",
);
}
anyhow::bail!("{}", msg);
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum SplitLoadOutcome {
Modern,
Legacy,
}
pub fn load_split_contexts_into(
ctx: &mut Context,
dist: &Path,
log: &anodizer_core::log::StageLogger,
) -> Result<SplitLoadOutcome> {
ctx.options.merge = true;
let context_files = find_split_contexts(dist)?;
if context_files.is_empty() {
let artifact_files = find_split_artifacts(dist)?;
if artifact_files.is_empty() {
anyhow::bail!(
"merge: no context.json or artifacts.json files found in {}. \
Run `anodizer release --split` first.",
dist.display()
);
}
load_legacy_artifacts(ctx, log, &artifact_files)?;
return Ok(SplitLoadOutcome::Legacy);
}
check_split_worker_completeness(dist, &context_files, log)?;
let mut total_loaded = 0;
let mut seen_paths: std::collections::HashMap<String, (PathBuf, String, Option<String>)> =
std::collections::HashMap::new();
let mut first_vars: Option<BTreeMap<String, String>> = None;
for ctx_file in &context_files {
let content = std::fs::read_to_string(ctx_file)
.with_context(|| format!("read split context: {}", ctx_file.display()))?;
let split_ctx: SplitContext = serde_json::from_str(&content)
.with_context(|| format!("parse split context: {}", ctx_file.display()))?;
if first_vars.is_none() {
for (key, value) in &split_ctx.template_vars {
ctx.template_vars_mut().set(key, value);
}
for (key, value) in &split_ctx.env_vars {
ctx.template_vars_mut().set_config_env(key, value);
}
first_vars = Some(split_ctx.template_vars.clone());
}
for sa in &split_ctx.artifacts {
if let Some((prior_ctx, prior_crate, prior_target)) = seen_paths.get(&sa.path) {
anyhow::bail!(
"merge: artifact path collision: '{}' from split job '{}' (crate={}, target={:?}) \
also claimed by split job '{}' (crate={}, target={:?}). \
Expected per-target subdirectory isolation (e.g. dist/<target>/); \
check your `no_unique_dist_dir` / `split.subdir` config.",
sa.path,
prior_ctx.display(),
prior_crate,
prior_target,
ctx_file.display(),
sa.crate_name,
sa.target,
);
}
seen_paths.insert(
sa.path.clone(),
(ctx_file.clone(), sa.crate_name.clone(), sa.target.clone()),
);
let kind = match artifact::ArtifactKind::parse(&sa.kind) {
Some(k) => k,
None => {
log.warn(&format!(
"merge: unknown artifact kind '{}' in {}, skipping",
sa.kind,
ctx_file.display()
));
continue;
}
};
let metadata: HashMap<String, String> = sa
.extra
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
ctx.artifacts.add(artifact::Artifact {
kind,
name: String::new(),
path: PathBuf::from(&sa.path),
target: sa.target.clone(),
crate_name: sa.crate_name.clone(),
metadata,
size: None,
});
total_loaded += 1;
}
}
log.status(&format!(
"merge: loaded {} artifact(s) from {} context(s)",
total_loaded,
context_files.len()
));
crate::commands::helpers::detect_missing_files(
ctx.artifacts.all().iter().map(|a| a.path.as_path()),
dist,
)?;
Ok(SplitLoadOutcome::Modern)
}
fn load_legacy_artifacts(
ctx: &mut Context,
log: &anodizer_core::log::StageLogger,
artifact_files: &[PathBuf],
) -> Result<usize> {
#[derive(serde::Deserialize)]
struct LegacyOutput {
artifacts: Vec<LegacyArtifact>,
}
#[derive(serde::Deserialize)]
struct LegacyArtifact {
kind: String,
path: String,
target: Option<String>,
crate_name: String,
#[serde(default)]
metadata: HashMap<String, String>,
}
let mut total_loaded = 0;
let mut seen_paths = std::collections::HashSet::new();
for artifact_file in artifact_files {
let content = std::fs::read_to_string(artifact_file)
.with_context(|| format!("read split artifacts: {}", artifact_file.display()))?;
let output: LegacyOutput = serde_json::from_str(&content)
.with_context(|| format!("parse split artifacts: {}", artifact_file.display()))?;
for sa in &output.artifacts {
if !seen_paths.insert(sa.path.clone()) {
continue;
}
let kind = artifact::ArtifactKind::parse(&sa.kind)
.ok_or_else(|| anyhow::anyhow!("unknown artifact kind: {}", sa.kind))?;
ctx.artifacts.add(artifact::Artifact {
kind,
name: String::new(),
path: PathBuf::from(&sa.path),
target: sa.target.clone(),
crate_name: sa.crate_name.clone(),
metadata: sa.metadata.clone(),
size: None,
});
total_loaded += 1;
}
}
log.status(&format!(
"merge (legacy): loaded {} artifact(s) from {} file(s)",
total_loaded,
artifact_files.len()
));
Ok(total_loaded)
}
pub fn run_merge(
ctx: &mut Context,
config: &Config,
log: &anodizer_core::log::StageLogger,
dry_run: bool,
dist_override: Option<&Path>,
) -> Result<()> {
log.status("running in merge mode (post-build stages)...");
let dist = dist_override.unwrap_or(&config.dist);
let outcome = load_split_contexts_into(ctx, dist, log)?;
if outcome == SplitLoadOutcome::Legacy {
return run_merge_legacy_tail(ctx, config, log, dry_run);
}
let p = pipeline::build_merge_pipeline();
let result = p.run(ctx, log);
if result.is_ok() {
super::run_post_pipeline(ctx, config, dry_run, log)?;
}
if result.is_ok() {
super::gate_required_failures(ctx)?;
}
result
}
fn run_merge_legacy_tail(
ctx: &mut Context,
config: &Config,
log: &anodizer_core::log::StageLogger,
dry_run: bool,
) -> Result<()> {
let p = pipeline::build_merge_pipeline();
let result = p.run(ctx, log);
if result.is_ok() {
super::run_post_pipeline(ctx, config, dry_run, log)?;
}
if result.is_ok() {
super::gate_required_failures(ctx)?;
}
result
}
fn collect_build_targets(config: &Config, ctx: &Context) -> Vec<String> {
crate::commands::helpers::collect_build_targets(config, &ctx.options.selected_crates)
}
pub fn find_split_contexts(dist: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if dist.is_dir()
&& let Ok(entries) = std::fs::read_dir(dist)
{
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let ctx_file = path.join("context.json");
if ctx_file.exists() {
files.push(ctx_file);
}
}
}
}
files.sort();
Ok(files)
}
fn find_split_artifacts(dist: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let top = dist.join("artifacts.json");
if top.exists() {
files.push(top);
}
if dist.is_dir()
&& let Ok(entries) = std::fs::read_dir(dist)
{
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let sub_artifacts = path.join("artifacts.json");
if sub_artifacts.exists() {
files.push(sub_artifacts);
}
}
}
}
files.sort();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::CrateConfig;
use std::collections::BTreeMap;
fn make_split_artifact(kind: &str, path: &str, target: Option<&str>) -> SplitArtifact {
SplitArtifact {
name: std::path::Path::new(path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
path: path.to_string(),
os: target.map(|t| anodizer_core::target::map_target(t).0),
arch: target.map(|t| anodizer_core::target::map_target(t).1),
target: target.map(String::from),
kind: kind.to_string(),
type_s: kind.to_string(),
crate_name: "myapp".to_string(),
extra: BTreeMap::new(),
sha256: None,
size: None,
}
}
#[test]
fn test_split_artifact_serialization_roundtrip() {
let artifact =
make_split_artifact("binary", "/tmp/myapp", Some("x86_64-unknown-linux-gnu"));
let json = serde_json::to_string(&artifact).unwrap();
let deserialized: SplitArtifact = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.kind, "binary");
assert_eq!(deserialized.path, "/tmp/myapp");
assert_eq!(
deserialized.target.as_deref(),
Some("x86_64-unknown-linux-gnu")
);
assert_eq!(deserialized.os.as_deref(), Some("linux"));
assert_eq!(deserialized.arch.as_deref(), Some("amd64"));
assert_eq!(deserialized.crate_name, "myapp");
}
#[test]
fn test_split_context_serialization_roundtrip() {
let ctx = SplitContext {
partial_target: "linux".to_string(),
template_vars: BTreeMap::from([
("Tag".to_string(), "v1.0.0".to_string()),
("ProjectName".to_string(), "myapp".to_string()),
]),
env_vars: BTreeMap::from([("GITHUB_TOKEN".to_string(), "ghp_secret".to_string())]),
git_tag: Some("v1.0.0".to_string()),
git_commit: Some("abc123".to_string()),
git_branch: Some("main".to_string()),
artifacts: vec![
make_split_artifact("binary", "/tmp/myapp", Some("aarch64-apple-darwin")),
make_split_artifact("archive", "/tmp/myapp.tar.gz", Some("aarch64-apple-darwin")),
],
};
let json = serde_json::to_string_pretty(&ctx).unwrap();
let deserialized: SplitContext = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.partial_target, "linux");
assert_eq!(deserialized.template_vars.get("Tag").unwrap(), "v1.0.0");
assert_eq!(deserialized.git_tag.as_deref(), Some("v1.0.0"));
assert_eq!(deserialized.artifacts.len(), 2);
assert_eq!(deserialized.artifacts[0].kind, "binary");
assert_eq!(deserialized.artifacts[1].kind, "archive");
}
#[test]
fn test_split_context_empty() {
let ctx = SplitContext {
partial_target: "linux".to_string(),
template_vars: BTreeMap::new(),
env_vars: BTreeMap::new(),
git_tag: None,
git_commit: None,
git_branch: None,
artifacts: vec![],
};
let json = serde_json::to_string(&ctx).unwrap();
let deserialized: SplitContext = serde_json::from_str(&json).unwrap();
assert!(deserialized.artifacts.is_empty());
assert!(deserialized.git_tag.is_none());
}
#[test]
fn test_find_split_artifacts_top_level() {
let tmp = tempfile::TempDir::new().unwrap();
let artifacts_path = tmp.path().join("artifacts.json");
std::fs::write(&artifacts_path, "{}").unwrap();
let files = find_split_artifacts(tmp.path()).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0], artifacts_path);
}
#[test]
fn test_find_split_artifacts_subdirectories() {
let tmp = tempfile::TempDir::new().unwrap();
let linux_dir = tmp.path().join("linux");
std::fs::create_dir(&linux_dir).unwrap();
std::fs::write(linux_dir.join("artifacts.json"), "{}").unwrap();
let darwin_dir = tmp.path().join("darwin");
std::fs::create_dir(&darwin_dir).unwrap();
std::fs::write(darwin_dir.join("artifacts.json"), "{}").unwrap();
let files = find_split_artifacts(tmp.path()).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn test_find_split_artifacts_both_levels() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("artifacts.json"), "{}").unwrap();
let sub = tmp.path().join("linux");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("artifacts.json"), "{}").unwrap();
let files = find_split_artifacts(tmp.path()).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn test_find_split_artifacts_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let files = find_split_artifacts(tmp.path()).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_find_split_artifacts_nonexistent_dir() {
let files = find_split_artifacts(std::path::Path::new("/nonexistent/path")).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_collect_build_targets() {
use anodizer_core::config::BuildConfig;
let config = Config {
project_name: "test".to_string(),
crates: vec![CrateConfig {
name: "myapp".to_string(),
path: ".".to_string(),
builds: Some(vec![BuildConfig {
binary: Some("myapp".to_string()),
targets: Some(vec![
"x86_64-unknown-linux-gnu".to_string(),
"aarch64-apple-darwin".to_string(),
]),
..Default::default()
}]),
..Default::default()
}],
..Default::default()
};
let opts = anodizer_core::context::ContextOptions::default();
let ctx = anodizer_core::context::Context::new(config.clone(), opts);
let targets = collect_build_targets(&config, &ctx);
assert_eq!(targets.len(), 2);
assert!(targets.contains(&"x86_64-unknown-linux-gnu".to_string()));
assert!(targets.contains(&"aarch64-apple-darwin".to_string()));
}
#[test]
fn test_collect_build_targets_deduplicates() {
use anodizer_core::config::BuildConfig;
let config = Config {
project_name: "test".to_string(),
crates: vec![
CrateConfig {
name: "a".to_string(),
path: ".".to_string(),
builds: Some(vec![BuildConfig {
binary: Some("a".to_string()),
targets: Some(vec!["x86_64-unknown-linux-gnu".to_string()]),
..Default::default()
}]),
..Default::default()
},
CrateConfig {
name: "b".to_string(),
path: ".".to_string(),
builds: Some(vec![BuildConfig {
binary: Some("b".to_string()),
targets: Some(vec!["x86_64-unknown-linux-gnu".to_string()]),
..Default::default()
}]),
..Default::default()
},
],
..Default::default()
};
let opts = anodizer_core::context::ContextOptions::default();
let ctx = anodizer_core::context::Context::new(config.clone(), opts);
let targets = collect_build_targets(&config, &ctx);
assert_eq!(targets.len(), 1, "should deduplicate targets");
}
#[test]
fn test_collect_build_targets_from_defaults() {
use anodizer_core::config::Defaults;
let config = Config {
project_name: "test".to_string(),
defaults: Some(Defaults {
targets: Some(vec![
"x86_64-unknown-linux-gnu".to_string(),
"x86_64-pc-windows-msvc".to_string(),
]),
..Default::default()
}),
crates: vec![CrateConfig {
name: "myapp".to_string(),
path: ".".to_string(),
..Default::default()
}],
..Default::default()
};
let opts = anodizer_core::context::ContextOptions::default();
let ctx = anodizer_core::context::Context::new(config.clone(), opts);
let targets = collect_build_targets(&config, &ctx);
assert_eq!(targets.len(), 2);
}
#[test]
fn test_split_matrix_serialization() {
let matrix = SplitMatrix {
split_by: "target".to_string(),
include: vec![
MatrixEntry {
target: "x86_64-unknown-linux-gnu".to_string(),
runner: "ubuntu-latest".to_string(),
},
MatrixEntry {
target: "aarch64-apple-darwin".to_string(),
runner: "macos-latest".to_string(),
},
],
};
let json = serde_json::to_string_pretty(&matrix).unwrap();
assert!(json.contains("x86_64-unknown-linux-gnu"));
assert!(json.contains("ubuntu-latest"));
assert!(json.contains("macos-latest"));
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["include"].is_array());
assert_eq!(parsed["include"].as_array().unwrap().len(), 2);
}
#[test]
fn test_build_matrix_os_deduplicates() {
let targets = vec![
"x86_64-unknown-linux-gnu".to_string(),
"aarch64-unknown-linux-gnu".to_string(),
"x86_64-apple-darwin".to_string(),
"aarch64-apple-darwin".to_string(),
"x86_64-pc-windows-msvc".to_string(),
];
let matrix = build_matrix(&targets, "os");
assert_eq!(matrix.include.len(), 3, "should deduplicate by OS");
assert_eq!(matrix.include[0].target, "linux");
assert_eq!(matrix.include[0].runner, "ubuntu-latest");
assert_eq!(matrix.include[1].target, "darwin");
assert_eq!(matrix.include[1].runner, "macos-latest");
assert_eq!(matrix.include[2].target, "windows");
assert_eq!(matrix.include[2].runner, "windows-latest");
}
#[test]
fn test_build_matrix_target_no_dedup() {
let targets = vec![
"x86_64-unknown-linux-gnu".to_string(),
"aarch64-unknown-linux-gnu".to_string(),
];
let matrix = build_matrix(&targets, "target");
assert_eq!(
matrix.include.len(),
2,
"target mode should not deduplicate"
);
}
#[test]
fn test_find_split_contexts() {
let tmp = tempfile::TempDir::new().unwrap();
let linux_dir = tmp.path().join("linux");
std::fs::create_dir(&linux_dir).unwrap();
std::fs::write(linux_dir.join("context.json"), "{}").unwrap();
let darwin_dir = tmp.path().join("darwin");
std::fs::create_dir(&darwin_dir).unwrap();
std::fs::write(darwin_dir.join("context.json"), "{}").unwrap();
let files = find_split_contexts(tmp.path()).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn test_find_split_contexts_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let files = find_split_contexts(tmp.path()).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_split_merge_artifact_kind_roundtrip() {
use anodizer_core::artifact::ArtifactKind;
let kinds = [
ArtifactKind::Binary,
ArtifactKind::Archive,
ArtifactKind::Checksum,
ArtifactKind::DockerImage,
ArtifactKind::LinuxPackage,
ArtifactKind::Metadata,
ArtifactKind::Library,
ArtifactKind::Wasm,
ArtifactKind::SourceArchive,
ArtifactKind::Sbom,
ArtifactKind::Snap,
ArtifactKind::DiskImage,
ArtifactKind::Installer,
ArtifactKind::MacOsPackage,
];
for kind in &kinds {
let s = kind.as_str();
let parsed = ArtifactKind::parse(s);
assert!(
parsed.is_some(),
"ArtifactKind::parse({:?}) should succeed",
s
);
assert_eq!(*kind, parsed.unwrap());
}
}
#[test]
fn test_artifact_kind_from_str_unknown() {
use anodizer_core::artifact::ArtifactKind;
assert!(ArtifactKind::parse("unknown_kind").is_none());
assert!(ArtifactKind::parse("").is_none());
}
fn write_split_context(dist: &Path, subdir: &str, partial_target: &str) -> PathBuf {
let dir = dist.join(subdir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("context.json");
let ctx = SplitContext {
partial_target: partial_target.to_string(),
template_vars: BTreeMap::new(),
env_vars: BTreeMap::new(),
git_tag: None,
git_commit: None,
git_branch: None,
artifacts: Vec::new(),
};
std::fs::write(&path, serde_json::to_string(&ctx).unwrap()).unwrap();
path
}
fn write_matrix(dist: &Path, targets: &[&str]) {
let matrix = SplitMatrix {
split_by: "os".to_string(),
include: targets
.iter()
.map(|t| MatrixEntry {
target: (*t).to_string(),
runner: "ubuntu-latest".to_string(),
})
.collect(),
};
std::fs::write(
dist.join("matrix.json"),
serde_json::to_string(&matrix).unwrap(),
)
.unwrap();
}
fn null_logger() -> anodizer_core::log::StageLogger {
anodizer_core::log::StageLogger::new("test", anodizer_core::log::Verbosity::Quiet)
}
#[test]
fn worker_completeness_passes_when_all_workers_contributed() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
write_matrix(dist, &["linux", "darwin", "windows"]);
let ctx_files = vec![
write_split_context(dist, "linux", "linux"),
write_split_context(dist, "darwin", "darwin"),
write_split_context(dist, "windows", "windows"),
];
check_split_worker_completeness(dist, &ctx_files, &null_logger())
.expect("all expected workers contributed → must succeed");
}
#[test]
fn worker_completeness_errors_when_workers_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
write_matrix(dist, &["linux", "darwin", "windows"]);
let ctx_files = vec![write_split_context(dist, "linux", "linux")];
let err = check_split_worker_completeness(dist, &ctx_files, &null_logger())
.expect_err("incomplete worker set must error");
let msg = err.to_string();
assert!(
msg.contains("missing context.json from worker(s)"),
"expected missing-worker diagnostic, got: {}",
msg
);
assert!(
msg.contains("darwin") && msg.contains("windows"),
"diagnostic must enumerate every missing worker, got: {}",
msg
);
}
#[test]
fn worker_completeness_errors_when_surplus_workers_present() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
write_matrix(dist, &["linux"]);
let ctx_files = vec![
write_split_context(dist, "linux", "linux"),
write_split_context(dist, "darwin", "darwin"),
];
let err = check_split_worker_completeness(dist, &ctx_files, &null_logger())
.expect_err("surplus worker set must error");
let msg = err.to_string();
assert!(
msg.contains("unexpected context.json"),
"expected surplus-worker diagnostic, got: {}",
msg
);
assert!(
msg.contains("darwin"),
"diagnostic must name the surplus worker, got: {}",
msg
);
}
#[test]
fn worker_completeness_skips_check_when_matrix_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
let ctx_files = vec![write_split_context(dist, "linux", "linux")];
check_split_worker_completeness(dist, &ctx_files, &null_logger())
.expect("absent matrix.json must skip the check, not error");
}
#[test]
fn find_split_contexts_returns_sorted_order() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
for sub in ["windows", "linux", "darwin", "freebsd"] {
let dir = dist.join(sub);
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("context.json"), "{}").unwrap();
}
let files = find_split_contexts(dist).unwrap();
let names: Vec<String> = files
.iter()
.map(|p| {
p.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(names, vec!["darwin", "freebsd", "linux", "windows"]);
}
#[test]
fn find_split_artifacts_returns_sorted_order() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
for sub in ["windows", "linux", "darwin"] {
let dir = dist.join(sub);
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("artifacts.json"), "{}").unwrap();
}
let files = find_split_artifacts(dist).unwrap();
let names: Vec<String> = files
.iter()
.map(|p| {
p.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(names, vec!["darwin", "linux", "windows"]);
}
#[test]
fn split_context_serialization_is_byte_stable_across_runs() {
let make_ctx = || SplitContext {
partial_target: "linux".to_string(),
template_vars: BTreeMap::from([
("Tag".to_string(), "v1.0.0".to_string()),
("ProjectName".to_string(), "myapp".to_string()),
("Version".to_string(), "1.0.0".to_string()),
]),
env_vars: BTreeMap::from([("GITHUB_TOKEN".to_string(), "[redacted]".to_string())]),
git_tag: Some("v1.0.0".to_string()),
git_commit: Some("abc123".to_string()),
git_branch: Some("main".to_string()),
artifacts: vec![make_split_artifact(
"binary",
"/tmp/myapp",
Some("x86_64-unknown-linux-gnu"),
)],
};
let first = serde_json::to_string_pretty(&make_ctx()).unwrap();
let second = serde_json::to_string_pretty(&make_ctx()).unwrap();
assert_eq!(
first, second,
"split context serialization must be byte-stable across re-runs"
);
}
fn make_bare_context() -> Context {
use anodizer_core::config::Config;
use anodizer_core::context::ContextOptions;
let config = Config {
project_name: "test".to_string(),
crates: vec![CrateConfig {
name: "test".to_string(),
path: ".".to_string(),
..Default::default()
}],
..Default::default()
};
Context::new(config, ContextOptions::default())
}
fn write_split_context_full(
dist: &Path,
subdir: &str,
partial_target: &str,
artifacts: Vec<SplitArtifact>,
) -> PathBuf {
let dir = dist.join(subdir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("context.json");
let ctx = SplitContext {
partial_target: partial_target.to_string(),
template_vars: BTreeMap::new(),
env_vars: BTreeMap::new(),
git_tag: None,
git_commit: None,
git_branch: None,
artifacts,
};
std::fs::write(&path, serde_json::to_string(&ctx).unwrap()).unwrap();
path
}
#[test]
fn load_split_contexts_into_yields_deterministic_artifact_order() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
let mk_artifact = |name: &str, target: &str| {
let file = dist.join(name);
std::fs::write(&file, b"x").unwrap();
SplitArtifact {
name: name.to_string(),
path: file.to_string_lossy().into_owned(),
os: Some(anodizer_core::target::map_target(target).0),
arch: Some(anodizer_core::target::map_target(target).1),
target: Some(target.to_string()),
kind: "binary".to_string(),
type_s: "binary".to_string(),
crate_name: "test".to_string(),
extra: BTreeMap::new(),
sha256: None,
size: None,
}
};
write_split_context_full(
dist,
"windows",
"windows",
vec![mk_artifact("test.exe", "x86_64-pc-windows-msvc")],
);
write_split_context_full(
dist,
"darwin",
"darwin",
vec![mk_artifact("test-mac", "x86_64-apple-darwin")],
);
write_split_context_full(
dist,
"linux",
"linux",
vec![mk_artifact("test-lin", "x86_64-unknown-linux-gnu")],
);
let mut ctx_a = make_bare_context();
load_split_contexts_into(&mut ctx_a, dist, &null_logger()).unwrap();
let order_a: Vec<String> = ctx_a
.artifacts
.all()
.iter()
.map(|a| a.path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
let mut ctx_b = make_bare_context();
load_split_contexts_into(&mut ctx_b, dist, &null_logger()).unwrap();
let order_b: Vec<String> = ctx_b
.artifacts
.all()
.iter()
.map(|a| a.path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
order_a, order_b,
"two from-clean loads of the same shard set must yield the same artifact order"
);
assert_eq!(order_a, vec!["test-mac", "test-lin", "test.exe"]);
}
#[test]
fn load_split_contexts_into_marks_context_as_merge_mode() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
write_split_context_full(dist, "linux", "linux", vec![]);
let mut ctx = make_bare_context();
assert!(!ctx.options.merge, "precondition: fresh ctx is not merge");
load_split_contexts_into(&mut ctx, dist, &null_logger()).unwrap();
assert!(
ctx.options.merge,
"loader must mark context as merge-mode so downstream stages see the flag"
);
}
#[test]
fn load_split_contexts_into_rejects_duplicate_artifact_paths() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
let dup = dist.join("dup");
std::fs::write(&dup, b"x").unwrap();
let make_dup = || SplitArtifact {
name: "dup".to_string(),
path: dup.to_string_lossy().into_owned(),
os: Some("linux".to_string()),
arch: Some("amd64".to_string()),
target: Some("x86_64-unknown-linux-gnu".to_string()),
kind: "binary".to_string(),
type_s: "binary".to_string(),
crate_name: "test".to_string(),
extra: BTreeMap::new(),
sha256: None,
size: None,
};
write_split_context_full(dist, "shard-a", "linux", vec![make_dup()]);
write_split_context_full(dist, "shard-b", "linux", vec![make_dup()]);
let mut ctx = make_bare_context();
let err = load_split_contexts_into(&mut ctx, dist, &null_logger())
.expect_err("colliding artifact paths must error");
assert!(
err.to_string().contains("artifact path collision"),
"expected collision diagnostic, got: {}",
err
);
}
#[test]
fn redact_replaces_secret_suffix_values_keeps_keys() {
let mut env = HashMap::new();
env.insert("GITHUB_TOKEN".to_string(), "ghp_abc".to_string());
env.insert("DEPLOY_SECRET".to_string(), "s3cr3t".to_string());
env.insert("DB_PASSWORD".to_string(), "hunter2".to_string());
env.insert("SIGNING_KEY".to_string(), "k".to_string());
env.insert("GPG_PASSPHRASE".to_string(), "p".to_string());
env.insert("SOME_API_KEY".to_string(), "ak".to_string());
let out = redact_secret_env_vars(&env);
for key in [
"GITHUB_TOKEN",
"DEPLOY_SECRET",
"DB_PASSWORD",
"SIGNING_KEY",
"GPG_PASSPHRASE",
"SOME_API_KEY",
] {
assert_eq!(
out.get(key).map(String::as_str),
Some("[redacted]"),
"key {key} must be redacted but present"
);
}
}
#[test]
fn redact_masks_substring_hints_case_insensitively() {
let mut env = HashMap::new();
env.insert("aws_credential".to_string(), "v".to_string());
env.insert("MyApiKeyThing".to_string(), "v".to_string());
let out = redact_secret_env_vars(&env);
assert_eq!(out.get("aws_credential").unwrap(), "[redacted]");
assert_eq!(out.get("MyApiKeyThing").unwrap(), "[redacted]");
}
#[test]
fn redact_leaves_non_secret_values_intact() {
let mut env = HashMap::new();
env.insert("ANODIZER_OS".to_string(), "linux".to_string());
env.insert("CARGO_TERM_COLOR".to_string(), "always".to_string());
let out = redact_secret_env_vars(&env);
assert_eq!(out.get("ANODIZER_OS").unwrap(), "linux");
assert_eq!(out.get("CARGO_TERM_COLOR").unwrap(), "always");
}
#[test]
fn redact_leaves_empty_secret_values_empty() {
let mut env = HashMap::new();
env.insert("GITHUB_TOKEN".to_string(), String::new());
let out = redact_secret_env_vars(&env);
assert_eq!(out.get("GITHUB_TOKEN").unwrap(), "");
}
#[test]
fn artifact_to_split_projects_all_fields() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
let mut metadata = HashMap::new();
metadata.insert("Format".to_string(), "tar.gz".to_string());
let art = Artifact {
kind: ArtifactKind::Archive,
name: "myapp.tar.gz".to_string(),
path: PathBuf::from("/dist/linux/myapp.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata,
size: Some(4096),
};
let split = artifact_to_split(&art);
assert_eq!(split.name, "myapp.tar.gz");
assert_eq!(split.path, "/dist/linux/myapp.tar.gz");
assert_eq!(split.os.as_deref(), Some("linux"));
assert_eq!(split.arch.as_deref(), Some("amd64"));
assert_eq!(split.target.as_deref(), Some("x86_64-unknown-linux-gnu"));
assert_eq!(split.kind, "archive");
assert_eq!(split.crate_name, "myapp");
assert_eq!(
split.extra.get("Format").and_then(|v| v.as_str()),
Some("tar.gz")
);
assert!(split.sha256.is_none());
assert_eq!(split.size, Some(4096));
}
#[test]
fn artifact_to_split_no_target_yields_no_os_arch() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
let art = Artifact {
kind: ArtifactKind::Metadata,
name: "checksums.txt".to_string(),
path: PathBuf::from("/dist/checksums.txt"),
target: None,
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
};
let split = artifact_to_split(&art);
assert!(split.os.is_none(), "no target → no os");
assert!(split.arch.is_none(), "no target → no arch");
assert!(split.target.is_none());
assert_eq!(split.kind, "metadata");
assert!(split.extra.is_empty());
}
#[test]
fn build_matrix_empty_targets_yields_empty_include() {
let matrix = build_matrix(&[], "os");
assert_eq!(matrix.split_by, "os");
assert!(matrix.include.is_empty());
}
#[test]
fn build_matrix_single_target_os_mode() {
let matrix = build_matrix(&["aarch64-apple-darwin".to_string()], "os");
assert_eq!(matrix.include.len(), 1);
assert_eq!(matrix.include[0].target, "darwin");
assert_eq!(matrix.include[0].runner, "macos-latest");
}
#[test]
fn build_matrix_target_mode_preserves_full_triples_and_order() {
let targets = vec![
"x86_64-pc-windows-msvc".to_string(),
"x86_64-unknown-linux-gnu".to_string(),
];
let matrix = build_matrix(&targets, "target");
assert_eq!(matrix.split_by, "target");
assert_eq!(matrix.include[0].target, "x86_64-pc-windows-msvc");
assert_eq!(matrix.include[0].runner, "windows-latest");
assert_eq!(matrix.include[1].target, "x86_64-unknown-linux-gnu");
assert_eq!(matrix.include[1].runner, "ubuntu-latest");
}
#[test]
fn build_matrix_unknown_os_falls_back_to_ubuntu_runner() {
let matrix = build_matrix(&["wasm32-unknown-unknown".to_string()], "os");
assert_eq!(matrix.include.len(), 1);
assert_eq!(matrix.include[0].target, "unknown");
assert_eq!(matrix.include[0].runner, "ubuntu-latest");
}
fn write_legacy_artifacts(dist: &Path, subdir: Option<&str>, body: &str) {
let dir = match subdir {
Some(s) => {
let d = dist.join(s);
std::fs::create_dir_all(&d).unwrap();
d
}
None => dist.to_path_buf(),
};
std::fs::write(dir.join("artifacts.json"), body).unwrap();
}
#[test]
fn loader_falls_back_to_legacy_artifacts_json() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
write_legacy_artifacts(
dist,
Some("linux"),
r#"{"artifacts":[{"kind":"binary","path":"/dist/linux/app","target":"x86_64-unknown-linux-gnu","crate_name":"app"}]}"#,
);
write_legacy_artifacts(
dist,
Some("darwin"),
r#"{"artifacts":[{"kind":"binary","path":"/dist/darwin/app","target":"x86_64-apple-darwin","crate_name":"app"}]}"#,
);
let mut ctx = make_bare_context();
let outcome = load_split_contexts_into(&mut ctx, dist, &null_logger()).unwrap();
assert_eq!(
outcome,
SplitLoadOutcome::Legacy,
"no context.json present → legacy fallback"
);
let paths: Vec<String> = ctx
.artifacts
.all()
.iter()
.map(|a| a.path.to_string_lossy().into_owned())
.collect();
assert!(paths.contains(&"/dist/linux/app".to_string()));
assert!(paths.contains(&"/dist/darwin/app".to_string()));
assert_eq!(paths.len(), 2);
}
#[test]
fn legacy_loader_dedups_repeated_paths_across_shards() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
let body = r#"{"artifacts":[{"kind":"binary","path":"/dist/app","target":"x86_64-unknown-linux-gnu","crate_name":"app"}]}"#;
write_legacy_artifacts(dist, Some("a"), body);
write_legacy_artifacts(dist, Some("b"), body);
let mut ctx = make_bare_context();
let outcome = load_split_contexts_into(&mut ctx, dist, &null_logger()).unwrap();
assert_eq!(outcome, SplitLoadOutcome::Legacy);
assert_eq!(
ctx.artifacts.all().len(),
1,
"duplicate legacy path must be deduplicated, not double-counted"
);
}
#[test]
fn legacy_loader_errors_on_unknown_kind() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
write_legacy_artifacts(
dist,
Some("linux"),
r#"{"artifacts":[{"kind":"not_a_kind","path":"/dist/x","target":null,"crate_name":"app"}]}"#,
);
let mut ctx = make_bare_context();
let err = load_split_contexts_into(&mut ctx, dist, &null_logger())
.expect_err("legacy loader must reject an unknown artifact kind");
assert!(
err.to_string().contains("unknown artifact kind"),
"got: {}",
err
);
}
#[test]
fn loader_errors_when_dist_has_neither_format() {
let tmp = tempfile::TempDir::new().unwrap();
let mut ctx = make_bare_context();
let err = load_split_contexts_into(&mut ctx, tmp.path(), &null_logger())
.expect_err("empty dist must error directing the user to --split");
assert!(
err.to_string()
.contains("no context.json or artifacts.json"),
"got: {}",
err
);
}
#[test]
fn modern_loader_skips_unknown_kind_but_loads_the_rest() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
let good = dist.join("good");
std::fs::write(&good, b"x").unwrap();
let known = SplitArtifact {
name: "good".to_string(),
path: good.to_string_lossy().into_owned(),
os: Some("linux".to_string()),
arch: Some("amd64".to_string()),
target: Some("x86_64-unknown-linux-gnu".to_string()),
kind: "binary".to_string(),
type_s: "binary".to_string(),
crate_name: "app".to_string(),
extra: BTreeMap::new(),
sha256: None,
size: None,
};
let mut unknown = known.clone();
unknown.kind = "future_kind".to_string();
unknown.path = "/dist/future".to_string();
write_split_context_full(dist, "linux", "linux", vec![unknown, known]);
let mut ctx = make_bare_context();
let outcome = load_split_contexts_into(&mut ctx, dist, &null_logger()).unwrap();
assert_eq!(outcome, SplitLoadOutcome::Modern);
assert_eq!(
ctx.artifacts.all().len(),
1,
"unknown kind skipped, known kind loaded"
);
assert_eq!(ctx.artifacts.all()[0].crate_name, "app");
}
#[test]
fn modern_loader_rehydrates_template_and_env_vars_from_first_shard() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path();
let dir = dist.join("linux");
std::fs::create_dir_all(&dir).unwrap();
let sc = SplitContext {
partial_target: "linux".to_string(),
template_vars: BTreeMap::from([("Tag".to_string(), "v2.3.4".to_string())]),
env_vars: BTreeMap::from([("ANODIZER_OS".to_string(), "linux".to_string())]),
git_tag: Some("v2.3.4".to_string()),
git_commit: None,
git_branch: None,
artifacts: vec![],
};
std::fs::write(
dir.join("context.json"),
serde_json::to_string(&sc).unwrap(),
)
.unwrap();
let mut ctx = make_bare_context();
load_split_contexts_into(&mut ctx, dist, &null_logger()).unwrap();
assert_eq!(
ctx.template_vars().get("Tag").map(String::as_str),
Some("v2.3.4"),
"loader must restore template vars from the first shard"
);
}
#[test]
fn collect_build_targets_unions_distinct_targets_across_crates() {
use anodizer_core::config::BuildConfig;
let config = Config {
project_name: "ws".to_string(),
crates: vec![
CrateConfig {
name: "a".to_string(),
path: "crates/a".to_string(),
builds: Some(vec![BuildConfig {
binary: Some("a".to_string()),
targets: Some(vec!["x86_64-unknown-linux-gnu".to_string()]),
..Default::default()
}]),
..Default::default()
},
CrateConfig {
name: "b".to_string(),
path: "crates/b".to_string(),
builds: Some(vec![BuildConfig {
binary: Some("b".to_string()),
targets: Some(vec!["aarch64-apple-darwin".to_string()]),
..Default::default()
}]),
..Default::default()
},
],
..Default::default()
};
let ctx = Context::new(
config.clone(),
anodizer_core::context::ContextOptions::default(),
);
let targets = collect_build_targets(&config, &ctx);
assert!(targets.contains(&"x86_64-unknown-linux-gnu".to_string()));
assert!(targets.contains(&"aarch64-apple-darwin".to_string()));
assert_eq!(targets.len(), 2, "distinct per-crate targets must union");
}
}