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::HashMap;
use std::path::{Path, PathBuf};
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct SplitArtifact {
pub name: String,
pub path: String,
pub goos: Option<String>,
pub goarch: 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: HashMap<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: HashMap<String, String>,
#[serde(default)]
pub env_vars: HashMap<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(),
goos: a.goos(),
goarch: 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(),
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(&config.partial)?;
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 split_ctx = SplitContext {
partial_target: subdir.clone(),
template_vars: ctx.template_vars().all().clone(),
env_vars: env_vars_redacted,
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("goos");
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 == "goos" {
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(())
}
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 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()
);
}
return run_merge_legacy(ctx, config, log, dry_run, &artifact_files);
}
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<HashMap<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,
)?;
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(
ctx: &mut Context,
config: &Config,
log: &anodizer_core::log::StageLogger,
dry_run: bool,
artifact_files: &[PathBuf],
) -> Result<()> {
#[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()
));
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)
}
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);
}
}
}
}
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);
}
}
}
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::CrateConfig;
use std::collections::HashMap;
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(),
goos: target.map(|t| anodizer_core::target::map_target(t).0),
goarch: 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: HashMap::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.goos.as_deref(), Some("linux"));
assert_eq!(deserialized.goarch.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: HashMap::from([
("Tag".to_string(), "v1.0.0".to_string()),
("ProjectName".to_string(), "myapp".to_string()),
]),
env_vars: HashMap::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: HashMap::new(),
env_vars: HashMap::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_goos_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, "goos");
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: HashMap::new(),
env_vars: HashMap::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: "goos".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");
}
}