use std::collections::HashMap;
use std::path::PathBuf;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ArtifactKind {
Binary,
UploadableBinary,
UniversalBinary,
Library,
Header,
CArchive,
CShared,
Wasm,
Archive,
SourceArchive,
Makeself,
LinuxPackage,
Snap,
PublishableSnapcraft,
Flatpak,
SourceRpm,
DiskImage,
Installer,
MacOsPackage,
DockerImage,
DockerImageV2,
PublishableDockerImage,
DockerManifest,
DockerDigest,
BrewFormula,
BrewCask,
Nixpkg,
ScoopManifest,
PublishableChocolatey,
WingetInstaller,
WingetDefaultLocale,
WingetVersion,
PkgBuild,
SrcInfo,
SourcePkgBuild,
SourceSrcInfo,
KrewPluginManifest,
Checksum,
Signature,
Certificate,
Sbom,
Metadata,
UploadableFile,
}
impl std::fmt::Display for ArtifactKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl ArtifactKind {
pub fn as_str(&self) -> &'static str {
match self {
ArtifactKind::Binary => "binary",
ArtifactKind::UploadableBinary => "uploadable_binary",
ArtifactKind::UniversalBinary => "universal_binary",
ArtifactKind::Library => "library",
ArtifactKind::Header => "header",
ArtifactKind::CArchive => "c_archive",
ArtifactKind::CShared => "c_shared",
ArtifactKind::Wasm => "wasm",
ArtifactKind::Archive => "archive",
ArtifactKind::SourceArchive => "source_archive",
ArtifactKind::Makeself => "makeself",
ArtifactKind::LinuxPackage => "linux_package",
ArtifactKind::Snap => "snap",
ArtifactKind::PublishableSnapcraft => "publishable_snapcraft",
ArtifactKind::Flatpak => "flatpak",
ArtifactKind::SourceRpm => "source_rpm",
ArtifactKind::DiskImage => "disk_image",
ArtifactKind::Installer => "installer",
ArtifactKind::MacOsPackage => "macos_package",
ArtifactKind::DockerImage => "docker_image",
ArtifactKind::DockerImageV2 => "docker_image_v2",
ArtifactKind::PublishableDockerImage => "publishable_docker_image",
ArtifactKind::DockerManifest => "docker_manifest",
ArtifactKind::DockerDigest => "docker_digest",
ArtifactKind::BrewFormula => "brew_formula",
ArtifactKind::BrewCask => "brew_cask",
ArtifactKind::Nixpkg => "nixpkg",
ArtifactKind::ScoopManifest => "scoop_manifest",
ArtifactKind::PublishableChocolatey => "publishable_chocolatey",
ArtifactKind::WingetInstaller => "winget_installer",
ArtifactKind::WingetDefaultLocale => "winget_default_locale",
ArtifactKind::WingetVersion => "winget_version",
ArtifactKind::PkgBuild => "pkg_build",
ArtifactKind::SrcInfo => "src_info",
ArtifactKind::SourcePkgBuild => "source_pkg_build",
ArtifactKind::SourceSrcInfo => "source_src_info",
ArtifactKind::KrewPluginManifest => "krew_plugin_manifest",
ArtifactKind::Checksum => "checksum",
ArtifactKind::Signature => "signature",
ArtifactKind::Certificate => "certificate",
ArtifactKind::Sbom => "sbom",
ArtifactKind::Metadata => "metadata",
ArtifactKind::UploadableFile => "uploadable_file",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"binary" => Some(ArtifactKind::Binary),
"uploadable_binary" => Some(ArtifactKind::UploadableBinary),
"universal_binary" => Some(ArtifactKind::UniversalBinary),
"library" => Some(ArtifactKind::Library),
"header" => Some(ArtifactKind::Header),
"c_archive" => Some(ArtifactKind::CArchive),
"c_shared" => Some(ArtifactKind::CShared),
"wasm" => Some(ArtifactKind::Wasm),
"archive" => Some(ArtifactKind::Archive),
"source_archive" => Some(ArtifactKind::SourceArchive),
"makeself" => Some(ArtifactKind::Makeself),
"linux_package" => Some(ArtifactKind::LinuxPackage),
"snap" => Some(ArtifactKind::Snap),
"publishable_snapcraft" => Some(ArtifactKind::PublishableSnapcraft),
"flatpak" => Some(ArtifactKind::Flatpak),
"source_rpm" => Some(ArtifactKind::SourceRpm),
"disk_image" => Some(ArtifactKind::DiskImage),
"installer" => Some(ArtifactKind::Installer),
"macos_package" => Some(ArtifactKind::MacOsPackage),
"docker_image" => Some(ArtifactKind::DockerImage),
"docker_image_v2" => Some(ArtifactKind::DockerImageV2),
"publishable_docker_image" => Some(ArtifactKind::PublishableDockerImage),
"docker_manifest" => Some(ArtifactKind::DockerManifest),
"docker_digest" => Some(ArtifactKind::DockerDigest),
"brew_formula" => Some(ArtifactKind::BrewFormula),
"brew_cask" => Some(ArtifactKind::BrewCask),
"nixpkg" => Some(ArtifactKind::Nixpkg),
"scoop_manifest" => Some(ArtifactKind::ScoopManifest),
"publishable_chocolatey" => Some(ArtifactKind::PublishableChocolatey),
"winget_installer" => Some(ArtifactKind::WingetInstaller),
"winget_default_locale" => Some(ArtifactKind::WingetDefaultLocale),
"winget_version" => Some(ArtifactKind::WingetVersion),
"pkg_build" => Some(ArtifactKind::PkgBuild),
"src_info" => Some(ArtifactKind::SrcInfo),
"source_pkg_build" => Some(ArtifactKind::SourcePkgBuild),
"source_src_info" => Some(ArtifactKind::SourceSrcInfo),
"krew_plugin_manifest" => Some(ArtifactKind::KrewPluginManifest),
"checksum" => Some(ArtifactKind::Checksum),
"signature" => Some(ArtifactKind::Signature),
"certificate" => Some(ArtifactKind::Certificate),
"sbom" => Some(ArtifactKind::Sbom),
"metadata" => Some(ArtifactKind::Metadata),
"uploadable_file" => Some(ArtifactKind::UploadableFile),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Artifact {
pub kind: ArtifactKind,
pub path: PathBuf,
pub name: String,
pub target: Option<String>,
pub crate_name: String,
#[serde(serialize_with = "serialize_metadata_sorted")]
pub metadata: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
const METADATA_HASH_KEYS: &[&str] = &[
"Checksum", "sha256", "sha512", "sha384", "sha224", "sha1", "md5", "blake2b", "blake3", "crc32",
];
fn serialize_metadata_sorted<S>(map: &HashMap<String, String>, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap as _;
let sorted: std::collections::BTreeMap<&String, &String> = map
.iter()
.filter(|(k, _)| !METADATA_HASH_KEYS.contains(&k.as_str()))
.collect();
let mut m = ser.serialize_map(Some(sorted.len()))?;
for (k, v) in sorted {
m.serialize_entry(k, v)?;
}
m.end()
}
impl Artifact {
pub fn name(&self) -> &str {
&self.name
}
pub fn goos(&self) -> Option<String> {
self.target.as_ref().map(|t| crate::target::map_target(t).0)
}
pub fn goarch(&self) -> Option<String> {
self.target.as_ref().map(|t| crate::target::map_target(t).1)
}
pub fn only_replacing_unibins(&self) -> bool {
self.metadata.get("replaces").is_none_or(|v| v != "false")
}
pub fn extra_binaries(&self) -> Vec<String> {
self.metadata
.get("extra_binaries")
.map(|v| {
v.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default()
}
pub fn extra_binary(&self) -> Option<String> {
self.metadata.get("binary").cloned()
}
pub fn ext(&self) -> String {
if let Some(ext) = self.metadata.get("ext")
&& !ext.is_empty()
{
return ext.clone();
}
crate::template::extract_artifact_ext(&self.name).to_string()
}
}
#[derive(Debug, Default)]
pub struct ArtifactRegistry {
artifacts: Vec<Artifact>,
}
impl ArtifactRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, mut artifact: Artifact) {
let name = if artifact.name.is_empty() {
let derived = artifact
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("artifact")
.trim()
.to_string();
artifact.name = derived.clone();
derived
} else {
artifact.name.clone()
};
if should_relativize_path(artifact.kind)
&& artifact.path.is_absolute()
&& let Ok(cwd) = std::env::current_dir()
&& cwd.parent().is_some()
&& let Ok(rel) = artifact.path.strip_prefix(&cwd)
{
artifact.path = rel.to_path_buf();
}
let path_str = crate::util::normalize_path_separators(&artifact.path.to_string_lossy());
artifact.path = PathBuf::from(path_str);
if is_uploadable(artifact.kind)
&& let Some(existing) = self
.artifacts
.iter()
.find(|a| is_uploadable(a.kind) && a.name == name)
{
tracing::warn!(
artifact = %name,
existing = %existing.path.display(),
new = %artifact.path.display(),
"artifact already registered; upload may fail with duplicate error",
);
}
self.artifacts.push(artifact);
}
pub fn dedupe_targetless_duplicates(&mut self) {
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
self.artifacts.retain(|a| {
if a.target.is_some() {
return true;
}
seen.insert(a.path.clone())
});
}
pub fn by_kind(&self, kind: ArtifactKind) -> Vec<&Artifact> {
self.artifacts.iter().filter(|a| a.kind == kind).collect()
}
pub fn by_kind_and_crate(&self, kind: ArtifactKind, crate_name: &str) -> Vec<&Artifact> {
self.artifacts
.iter()
.filter(|a| a.kind == kind && a.crate_name == crate_name)
.collect()
}
pub fn by_kinds_and_crate(&self, kinds: &[ArtifactKind], crate_name: &str) -> Vec<&Artifact> {
self.artifacts
.iter()
.filter(|a| kinds.contains(&a.kind) && a.crate_name == crate_name)
.collect()
}
pub fn binary_like_dedup(&self) -> Vec<&Artifact> {
let uploadable_paths: std::collections::HashSet<&std::path::Path> = self
.artifacts
.iter()
.filter(|a| a.kind == ArtifactKind::UploadableBinary)
.map(|a| a.path.as_path())
.collect();
self.artifacts
.iter()
.filter(|a| {
matches!(
a.kind,
ArtifactKind::Binary
| ArtifactKind::UploadableBinary
| ArtifactKind::UniversalBinary
)
})
.filter(|a| {
a.kind == ArtifactKind::UploadableBinary
|| !uploadable_paths.contains(a.path.as_path())
})
.collect()
}
pub fn all(&self) -> &[Artifact] {
&self.artifacts
}
pub fn all_mut(&mut self) -> &mut [Artifact] {
&mut self.artifacts
}
pub fn filter<F: Fn(&Artifact) -> bool>(&self, predicate: F) -> Vec<&Artifact> {
self.artifacts.iter().filter(|a| predicate(a)).collect()
}
pub fn remove_by_paths(&mut self, paths: &[std::path::PathBuf]) {
self.artifacts.retain(|a| !paths.contains(&a.path));
}
pub fn to_artifacts_json(&self) -> anyhow::Result<serde_json::Value> {
let mut sorted: Vec<&Artifact> = self.artifacts.iter().collect();
sorted.sort_by(|a, b| {
(
a.kind.as_str(),
a.target.as_deref().unwrap_or(""),
a.crate_name.as_str(),
a.name.as_str(),
a.path.as_path(),
)
.cmp(&(
b.kind.as_str(),
b.target.as_deref().unwrap_or(""),
b.crate_name.as_str(),
b.name.as_str(),
b.path.as_path(),
))
});
let mut val = serde_json::to_value(&sorted)?;
if let Some(arr) = val.as_array_mut() {
for entry in arr {
if let Some(path) = entry
.get("path")
.and_then(|p| p.as_str())
.map(crate::util::normalize_path_separators)
{
entry["path"] = serde_json::Value::String(path);
}
}
}
Ok(val)
}
}
pub fn size_reportable_kinds() -> &'static [ArtifactKind] {
&[
ArtifactKind::Archive,
ArtifactKind::SourceArchive,
ArtifactKind::UploadableFile,
ArtifactKind::Makeself,
ArtifactKind::LinuxPackage,
ArtifactKind::Flatpak,
ArtifactKind::SourceRpm,
ArtifactKind::Sbom,
ArtifactKind::Checksum,
ArtifactKind::Signature,
ArtifactKind::Certificate,
ArtifactKind::DiskImage,
ArtifactKind::Installer,
ArtifactKind::MacOsPackage,
ArtifactKind::Snap,
ArtifactKind::PublishableSnapcraft,
ArtifactKind::Binary,
ArtifactKind::UploadableBinary,
ArtifactKind::UniversalBinary,
ArtifactKind::Library,
ArtifactKind::Header,
ArtifactKind::CArchive,
ArtifactKind::CShared,
ArtifactKind::Wasm,
]
}
pub fn uploadable_kinds() -> &'static [ArtifactKind] {
&[
ArtifactKind::Archive,
ArtifactKind::UploadableBinary,
ArtifactKind::SourceArchive,
ArtifactKind::UploadableFile,
ArtifactKind::Makeself,
ArtifactKind::LinuxPackage,
ArtifactKind::PublishableSnapcraft,
ArtifactKind::Flatpak,
ArtifactKind::SourceRpm,
ArtifactKind::Sbom,
ArtifactKind::Checksum,
ArtifactKind::Signature,
ArtifactKind::Certificate,
ArtifactKind::DiskImage,
ArtifactKind::Installer,
ArtifactKind::MacOsPackage,
]
}
pub fn release_uploadable_kinds() -> &'static [ArtifactKind] {
&[
ArtifactKind::Archive,
ArtifactKind::UploadableBinary,
ArtifactKind::UploadableFile,
ArtifactKind::SourceArchive,
ArtifactKind::Makeself,
ArtifactKind::LinuxPackage,
ArtifactKind::Flatpak,
ArtifactKind::SourceRpm,
ArtifactKind::Installer,
ArtifactKind::DiskImage,
ArtifactKind::MacOsPackage,
ArtifactKind::Sbom,
ArtifactKind::Checksum,
ArtifactKind::Signature,
ArtifactKind::Certificate,
]
}
fn is_uploadable(kind: ArtifactKind) -> bool {
uploadable_kinds().contains(&kind)
}
fn should_relativize_path(kind: ArtifactKind) -> bool {
!matches!(
kind,
ArtifactKind::DockerImage
| ArtifactKind::DockerImageV2
| ArtifactKind::PublishableDockerImage
| ArtifactKind::DockerManifest
| ArtifactKind::DockerDigest
)
}
pub fn is_binary_sign_output(artifact: &Artifact) -> bool {
artifact
.metadata
.get("binary_sign")
.is_some_and(|v| v == "true")
}
pub fn matches_id_filter(artifact: &Artifact, ids: Option<&[String]>) -> bool {
let Some(id_list) = ids else { return true };
if id_list.is_empty() {
return true;
}
if matches!(
artifact.kind,
ArtifactKind::Checksum
| ArtifactKind::SourceArchive
| ArtifactKind::UploadableFile
| ArtifactKind::Metadata
) {
return true;
}
let artifact_id = artifact
.metadata
.get("id")
.map(|s| s.as_str())
.unwrap_or("");
id_list.iter().any(|id| id == artifact_id)
}
pub fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let b = bytes as f64;
if b >= GB {
format!("{:.1} GB", b / GB)
} else if b >= MB {
format!("{:.1} MB", b / MB)
} else if b >= KB {
format!("{:.1} KB", b / KB)
} else {
format!("{} B", bytes)
}
}
pub fn print_size_report(registry: &mut ArtifactRegistry, log: &crate::log::StageLogger) {
let reportable = size_reportable_kinds();
let mut entries: Vec<(String, u64)> = Vec::new();
let mut total: u64 = 0;
for artifact in registry.all_mut() {
if !reportable.contains(&artifact.kind) {
continue;
}
if let Ok(meta) = std::fs::metadata(&artifact.path) {
let size = meta.len();
artifact.size = Some(size);
let name = artifact
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| artifact.path.display().to_string());
entries.push((name, size));
total += size;
}
}
if entries.is_empty() {
return;
}
let max_name_len = entries.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
log.status("");
log.status("Artifact Sizes:");
for (name, size) in &entries {
log.status(&format!(
" {:<width$} {}",
name,
format_size(*size),
width = max_name_len
));
}
log.status(&format!(
" {:<width$} {}",
"Total:",
format_size(total),
width = max_name_len
));
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_add_and_query_artifacts() {
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Binary,
name: String::new(),
path: PathBuf::from("dist/cfgd"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "cfgd".to_string(),
metadata: Default::default(),
size: None,
});
registry.add(Artifact {
kind: ArtifactKind::Archive,
name: String::new(),
path: PathBuf::from("dist/cfgd.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "cfgd".to_string(),
metadata: Default::default(),
size: None,
});
let binaries = registry.by_kind(ArtifactKind::Binary);
assert_eq!(binaries.len(), 1);
let archives = registry.by_kind_and_crate(ArtifactKind::Archive, "cfgd");
assert_eq!(archives.len(), 1);
}
#[test]
fn test_empty_query() {
let registry = ArtifactRegistry::new();
assert!(registry.by_kind(ArtifactKind::Binary).is_empty());
}
#[test]
fn dedupe_targetless_duplicates_collapses_cross_shard_dups() {
let mut registry = ArtifactRegistry::new();
for _ in 0..3 {
registry.add(Artifact {
kind: ArtifactKind::SourceArchive,
name: "anodizer-0.3.0-source.tar.gz".to_string(),
path: PathBuf::from("dist/anodizer-0.3.0-source.tar.gz"),
target: None,
crate_name: "anodizer".to_string(),
metadata: HashMap::new(),
size: None,
});
}
for triple in &["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] {
registry.add(Artifact {
kind: ArtifactKind::Archive,
name: format!("anodizer-0.3.0-{}.tar.gz", triple),
path: PathBuf::from(format!("dist/anodizer-0.3.0-{}.tar.gz", triple)),
target: Some((*triple).to_string()),
crate_name: "anodizer".to_string(),
metadata: HashMap::new(),
size: None,
});
}
registry.dedupe_targetless_duplicates();
let sources: Vec<_> = registry.by_kind(ArtifactKind::SourceArchive);
assert_eq!(
sources.len(),
1,
"cross-shard target-None duplicates must collapse to 1 entry"
);
assert_eq!(registry.by_kind(ArtifactKind::Archive).len(), 2);
}
#[test]
fn dedupe_targetless_duplicates_leaves_per_target_duplicates_intact() {
let mut registry = ArtifactRegistry::new();
for _ in 0..3 {
registry.add(Artifact {
kind: ArtifactKind::Archive,
name: "anodizer-x86_64.tar.gz".to_string(),
path: PathBuf::from("dist/anodizer-x86_64.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "anodizer".to_string(),
metadata: HashMap::new(),
size: None,
});
}
registry.dedupe_targetless_duplicates();
assert_eq!(
registry.by_kind(ArtifactKind::Archive).len(),
3,
"per-target duplicates must remain so detect_duplicate_artifact_paths can flag them"
);
}
#[test]
fn test_by_kinds_and_crate() {
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Binary,
name: "bin".to_string(),
path: PathBuf::from("bin"),
target: None,
crate_name: "app".to_string(),
metadata: HashMap::new(),
size: None,
});
registry.add(Artifact {
kind: ArtifactKind::UniversalBinary,
name: "ubin".to_string(),
path: PathBuf::from("ubin"),
target: None,
crate_name: "app".to_string(),
metadata: HashMap::new(),
size: None,
});
registry.add(Artifact {
kind: ArtifactKind::Header,
name: "hdr".to_string(),
path: PathBuf::from("hdr"),
target: None,
crate_name: "other".to_string(),
metadata: HashMap::new(),
size: None,
});
let results = registry.by_kinds_and_crate(
&[ArtifactKind::Binary, ArtifactKind::UniversalBinary],
"app",
);
assert_eq!(results.len(), 2);
let results = registry.by_kinds_and_crate(&[ArtifactKind::Header], "app");
assert_eq!(results.len(), 0);
}
#[test]
fn test_to_artifacts_json_empty() {
let registry = ArtifactRegistry::new();
let json = registry.to_artifacts_json().unwrap();
assert!(json.is_array());
assert_eq!(json.as_array().unwrap().len(), 0);
}
#[test]
fn test_to_artifacts_json_with_artifacts() {
let mut registry = ArtifactRegistry::new();
let mut meta = HashMap::new();
meta.insert("format".to_string(), "tar.gz".to_string());
registry.add(Artifact {
kind: ArtifactKind::Archive,
name: String::new(),
path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: meta,
size: None,
});
registry.add(Artifact {
kind: ArtifactKind::Checksum,
name: String::new(),
path: PathBuf::from("dist/myapp_1.0.0_checksums.txt"),
target: None,
crate_name: "myapp".to_string(),
metadata: Default::default(),
size: None,
});
let json = registry.to_artifacts_json().unwrap();
let arr = json.as_array().unwrap();
assert_eq!(arr.len(), 2);
let first = &arr[0];
assert_eq!(first["kind"], "archive");
assert_eq!(first["path"], "dist/myapp-1.0.0-linux-amd64.tar.gz");
assert_eq!(first["target"], "x86_64-unknown-linux-gnu");
assert_eq!(first["crate_name"], "myapp");
assert_eq!(first["metadata"]["format"], "tar.gz");
let second = &arr[1];
assert_eq!(second["kind"], "checksum");
assert!(second["target"].is_null());
}
#[test]
#[serial_test::serial(cwd)]
fn to_artifacts_json_strips_absolute_worktree_prefix() {
let cwd_guard = tempfile::tempdir().unwrap();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(cwd_guard.path()).unwrap();
let canonical_cwd = std::env::current_dir().unwrap();
let abs = canonical_cwd
.join("dist")
.join("anodize-1.0.0-linux-amd64.tar.gz");
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Archive,
name: String::new(),
path: abs,
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "anodize".to_string(),
metadata: Default::default(),
size: None,
});
let json = registry.to_artifacts_json().unwrap();
let arr = json.as_array().unwrap();
assert_eq!(
arr[0]["path"], "dist/anodize-1.0.0-linux-amd64.tar.gz",
"absolute worktree prefix must be stripped at add() time so two \
determinism-harness runs at different worktree paths produce \
byte-identical artifacts.json"
);
std::env::set_current_dir(original_cwd).unwrap();
}
#[test]
fn to_artifacts_json_output_is_order_insensitive() {
let mut reg_a = ArtifactRegistry::new();
reg_a.add(Artifact {
kind: ArtifactKind::Archive,
name: String::new(),
path: PathBuf::from("dist/anodize-1.0.0-linux-arm64.tar.gz"),
target: Some("aarch64-unknown-linux-gnu".to_string()),
crate_name: "anodize".to_string(),
metadata: Default::default(),
size: Some(15_000_000),
});
reg_a.add(Artifact {
kind: ArtifactKind::Archive,
name: String::new(),
path: PathBuf::from("dist/anodize-1.0.0-linux-amd64.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "anodize".to_string(),
metadata: Default::default(),
size: Some(18_000_000),
});
let mut reg_b = ArtifactRegistry::new();
reg_b.add(Artifact {
kind: ArtifactKind::Archive,
name: String::new(),
path: PathBuf::from("dist/anodize-1.0.0-linux-amd64.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "anodize".to_string(),
metadata: Default::default(),
size: Some(18_000_000),
});
reg_b.add(Artifact {
kind: ArtifactKind::Archive,
name: String::new(),
path: PathBuf::from("dist/anodize-1.0.0-linux-arm64.tar.gz"),
target: Some("aarch64-unknown-linux-gnu".to_string()),
crate_name: "anodize".to_string(),
metadata: Default::default(),
size: Some(15_000_000),
});
let json_a = serde_json::to_string_pretty(®_a.to_artifacts_json().unwrap()).unwrap();
let json_b = serde_json::to_string_pretty(®_b.to_artifacts_json().unwrap()).unwrap();
assert_eq!(
json_a, json_b,
"two registries with the same artifacts in different insertion \
orders must produce byte-identical artifacts.json — otherwise \
the determinism harness will surface per-run drift in dist/"
);
}
#[test]
#[serial_test::serial(cwd)]
fn to_artifacts_json_preserves_docker_image_refs() {
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::DockerImage,
name: "myorg/myimage:v1.2.3".to_string(),
path: PathBuf::from("/myorg/myimage:v1.2.3"),
target: None,
crate_name: "myapp".to_string(),
metadata: Default::default(),
size: None,
});
let json = registry.to_artifacts_json().unwrap();
let arr = json.as_array().unwrap();
assert_eq!(
arr[0]["path"], "/myorg/myimage:v1.2.3",
"docker image refs are pass-through and must not be relativized"
);
}
#[test]
fn to_artifacts_json_drops_content_hash_keys() {
let mut metadata = HashMap::new();
metadata.insert("format".into(), "deb".into());
metadata.insert("id".into(), "default".into());
metadata.insert("Checksum".into(), "sha256:abc".into());
metadata.insert("sha256".into(), "abc".into());
metadata.insert("blake3".into(), "xyz".into());
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::LinuxPackage,
name: "pkg.deb".to_string(),
path: PathBuf::from("dist/pkg.deb"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata,
size: None,
});
let json = registry.to_artifacts_json().unwrap();
let meta = &json.as_array().unwrap()[0]["metadata"];
assert_eq!(meta["format"], "deb");
assert_eq!(meta["id"], "default");
assert!(
meta.get("Checksum").is_none(),
"Checksum (content-hash) must be filtered from artifacts.json: {meta:?}"
);
assert!(meta.get("sha256").is_none(), "sha256 must be filtered");
assert!(meta.get("blake3").is_none(), "blake3 must be filtered");
}
#[test]
fn test_metadata_json_is_valid_json_string() {
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Binary,
name: String::new(),
path: PathBuf::from("dist/myapp"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: Default::default(),
size: None,
});
let json = registry.to_artifacts_json().unwrap();
let serialized = serde_json::to_string_pretty(&json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(parsed, json);
}
#[test]
fn test_format_size_bytes() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1023), "1023 B");
}
#[test]
fn test_format_size_kilobytes() {
assert_eq!(format_size(1024), "1.0 KB");
assert_eq!(format_size(1536), "1.5 KB");
assert_eq!(format_size(10240), "10.0 KB");
}
#[test]
fn test_format_size_megabytes() {
assert_eq!(format_size(1048576), "1.0 MB");
assert_eq!(format_size(4404019), "4.2 MB");
}
#[test]
fn test_format_size_gigabytes() {
assert_eq!(format_size(1073741824), "1.0 GB");
assert_eq!(format_size(2147483648), "2.0 GB");
}
#[test]
fn test_artifact_kind_serializes_to_snake_case() {
let json = serde_json::to_value(ArtifactKind::DockerImage).unwrap();
assert_eq!(json, "docker_image");
let json = serde_json::to_value(ArtifactKind::LinuxPackage).unwrap();
assert_eq!(json, "linux_package");
let json = serde_json::to_value(ArtifactKind::Binary).unwrap();
assert_eq!(json, "binary");
}
#[test]
fn test_artifact_kind_new_variants_serialize() {
assert_eq!(
serde_json::to_value(ArtifactKind::UploadableBinary).unwrap(),
"uploadable_binary"
);
assert_eq!(
serde_json::to_value(ArtifactKind::UniversalBinary).unwrap(),
"universal_binary"
);
assert_eq!(
serde_json::to_value(ArtifactKind::Header).unwrap(),
"header"
);
assert_eq!(
serde_json::to_value(ArtifactKind::CArchive).unwrap(),
"c_archive"
);
assert_eq!(
serde_json::to_value(ArtifactKind::CShared).unwrap(),
"c_shared"
);
assert_eq!(
serde_json::to_value(ArtifactKind::Makeself).unwrap(),
"makeself"
);
assert_eq!(
serde_json::to_value(ArtifactKind::DockerImageV2).unwrap(),
"docker_image_v2"
);
assert_eq!(
serde_json::to_value(ArtifactKind::PublishableDockerImage).unwrap(),
"publishable_docker_image"
);
assert_eq!(
serde_json::to_value(ArtifactKind::PublishableSnapcraft).unwrap(),
"publishable_snapcraft"
);
assert_eq!(
serde_json::to_value(ArtifactKind::SourceRpm).unwrap(),
"source_rpm"
);
assert_eq!(
serde_json::to_value(ArtifactKind::BrewFormula).unwrap(),
"brew_formula"
);
assert_eq!(
serde_json::to_value(ArtifactKind::BrewCask).unwrap(),
"brew_cask"
);
assert_eq!(
serde_json::to_value(ArtifactKind::Nixpkg).unwrap(),
"nixpkg"
);
assert_eq!(
serde_json::to_value(ArtifactKind::ScoopManifest).unwrap(),
"scoop_manifest"
);
assert_eq!(
serde_json::to_value(ArtifactKind::PublishableChocolatey).unwrap(),
"publishable_chocolatey"
);
assert_eq!(
serde_json::to_value(ArtifactKind::WingetInstaller).unwrap(),
"winget_installer"
);
assert_eq!(
serde_json::to_value(ArtifactKind::WingetDefaultLocale).unwrap(),
"winget_default_locale"
);
assert_eq!(
serde_json::to_value(ArtifactKind::WingetVersion).unwrap(),
"winget_version"
);
assert_eq!(
serde_json::to_value(ArtifactKind::PkgBuild).unwrap(),
"pkg_build"
);
assert_eq!(
serde_json::to_value(ArtifactKind::SrcInfo).unwrap(),
"src_info"
);
assert_eq!(
serde_json::to_value(ArtifactKind::SourcePkgBuild).unwrap(),
"source_pkg_build"
);
assert_eq!(
serde_json::to_value(ArtifactKind::SourceSrcInfo).unwrap(),
"source_src_info"
);
assert_eq!(
serde_json::to_value(ArtifactKind::KrewPluginManifest).unwrap(),
"krew_plugin_manifest"
);
assert_eq!(
serde_json::to_value(ArtifactKind::UploadableFile).unwrap(),
"uploadable_file"
);
}
#[test]
fn test_artifact_kind_library_and_wasm() {
let json = serde_json::to_value(ArtifactKind::Library).unwrap();
assert_eq!(json, "library");
let json = serde_json::to_value(ArtifactKind::Wasm).unwrap();
assert_eq!(json, "wasm");
}
#[test]
fn test_artifact_kind_as_str_library_wasm() {
assert_eq!(ArtifactKind::Library.as_str(), "library");
assert_eq!(ArtifactKind::Wasm.as_str(), "wasm");
}
#[test]
fn test_artifact_kind_parse_roundtrip_all_variants() {
let all_variants = [
ArtifactKind::Binary,
ArtifactKind::UploadableBinary,
ArtifactKind::UniversalBinary,
ArtifactKind::Library,
ArtifactKind::Header,
ArtifactKind::CArchive,
ArtifactKind::CShared,
ArtifactKind::Wasm,
ArtifactKind::Archive,
ArtifactKind::SourceArchive,
ArtifactKind::Makeself,
ArtifactKind::LinuxPackage,
ArtifactKind::Snap,
ArtifactKind::PublishableSnapcraft,
ArtifactKind::Flatpak,
ArtifactKind::SourceRpm,
ArtifactKind::DiskImage,
ArtifactKind::Installer,
ArtifactKind::MacOsPackage,
ArtifactKind::DockerImage,
ArtifactKind::DockerImageV2,
ArtifactKind::PublishableDockerImage,
ArtifactKind::DockerManifest,
ArtifactKind::BrewFormula,
ArtifactKind::BrewCask,
ArtifactKind::Nixpkg,
ArtifactKind::ScoopManifest,
ArtifactKind::PublishableChocolatey,
ArtifactKind::WingetInstaller,
ArtifactKind::WingetDefaultLocale,
ArtifactKind::WingetVersion,
ArtifactKind::PkgBuild,
ArtifactKind::SrcInfo,
ArtifactKind::SourcePkgBuild,
ArtifactKind::SourceSrcInfo,
ArtifactKind::KrewPluginManifest,
ArtifactKind::Checksum,
ArtifactKind::Signature,
ArtifactKind::Certificate,
ArtifactKind::Sbom,
ArtifactKind::Metadata,
ArtifactKind::UploadableFile,
];
for variant in &all_variants {
let s = variant.as_str();
let parsed =
ArtifactKind::parse(s).unwrap_or_else(|| panic!("parse({:?}) returned None", s));
assert_eq!(*variant, parsed, "roundtrip failed for {:?}", s);
}
assert_eq!(all_variants.len(), 42, "update test when adding variants");
}
#[test]
fn test_query_by_library_and_wasm_kinds() {
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Library,
name: String::new(),
path: PathBuf::from("target/libmylib.so"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "mylib".to_string(),
metadata: Default::default(),
size: None,
});
registry.add(Artifact {
kind: ArtifactKind::Wasm,
name: String::new(),
path: PathBuf::from("target/mylib.wasm"),
target: Some("wasm32-unknown-unknown".to_string()),
crate_name: "mylib".to_string(),
metadata: Default::default(),
size: None,
});
assert_eq!(registry.by_kind(ArtifactKind::Library).len(), 1);
assert_eq!(registry.by_kind(ArtifactKind::Wasm).len(), 1);
assert_eq!(
registry
.by_kind_and_crate(ArtifactKind::Wasm, "mylib")
.len(),
1
);
}
#[test]
fn test_size_reportable_kinds_includes_releasable_and_binaries() {
let kinds = size_reportable_kinds();
assert!(kinds.contains(&ArtifactKind::Archive));
assert!(kinds.contains(&ArtifactKind::SourceArchive));
assert!(kinds.contains(&ArtifactKind::UploadableFile));
assert!(kinds.contains(&ArtifactKind::Makeself));
assert!(kinds.contains(&ArtifactKind::LinuxPackage));
assert!(kinds.contains(&ArtifactKind::Flatpak));
assert!(kinds.contains(&ArtifactKind::SourceRpm));
assert!(kinds.contains(&ArtifactKind::Sbom));
assert!(kinds.contains(&ArtifactKind::Checksum));
assert!(kinds.contains(&ArtifactKind::Signature));
assert!(kinds.contains(&ArtifactKind::Certificate));
assert!(kinds.contains(&ArtifactKind::DiskImage));
assert!(kinds.contains(&ArtifactKind::Installer));
assert!(kinds.contains(&ArtifactKind::MacOsPackage));
assert!(kinds.contains(&ArtifactKind::Snap));
assert!(kinds.contains(&ArtifactKind::Binary));
assert!(kinds.contains(&ArtifactKind::UniversalBinary));
assert!(kinds.contains(&ArtifactKind::Library));
assert!(kinds.contains(&ArtifactKind::Header));
assert!(kinds.contains(&ArtifactKind::CArchive));
assert!(kinds.contains(&ArtifactKind::CShared));
assert!(kinds.contains(&ArtifactKind::Wasm));
}
#[test]
fn test_size_reportable_kinds_excludes_non_releasable() {
let kinds = size_reportable_kinds();
assert!(!kinds.contains(&ArtifactKind::DockerImage));
assert!(!kinds.contains(&ArtifactKind::DockerManifest));
assert!(!kinds.contains(&ArtifactKind::Metadata));
assert!(!kinds.contains(&ArtifactKind::BrewFormula));
assert!(!kinds.contains(&ArtifactKind::ScoopManifest));
}
#[test]
fn test_print_size_report_filters_and_stores_size() {
use std::io::Write;
let dir = std::env::temp_dir().join("anodizer_test_size_report");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let archive_path = dir.join("app.tar.gz");
let mut f = std::fs::File::create(&archive_path).unwrap();
f.write_all(&[0u8; 2048]).unwrap();
let binary_path = dir.join("app");
let mut f = std::fs::File::create(&binary_path).unwrap();
f.write_all(&[0u8; 4096]).unwrap();
let docker_path = dir.join("docker-image");
let mut f = std::fs::File::create(&docker_path).unwrap();
f.write_all(&[0u8; 8192]).unwrap();
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Archive,
name: String::new(),
path: archive_path.clone(),
target: None,
crate_name: "app".to_string(),
metadata: Default::default(),
size: None,
});
registry.add(Artifact {
kind: ArtifactKind::Binary,
name: String::new(),
path: binary_path.clone(),
target: None,
crate_name: "app".to_string(),
metadata: Default::default(),
size: None,
});
registry.add(Artifact {
kind: ArtifactKind::DockerImage,
name: String::new(),
path: docker_path.clone(),
target: None,
crate_name: "app".to_string(),
metadata: Default::default(),
size: None,
});
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
print_size_report(&mut registry, &log);
let archive = ®istry.all()[0];
assert_eq!(archive.kind, ArtifactKind::Archive);
assert_eq!(archive.size, Some(2048));
let binary = ®istry.all()[1];
assert_eq!(binary.kind, ArtifactKind::Binary);
assert_eq!(binary.size, Some(4096));
let docker = ®istry.all()[2];
assert_eq!(docker.kind, ArtifactKind::DockerImage);
assert_eq!(docker.size, None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_size_field_defaults_to_none() {
let registry = ArtifactRegistry::new();
let mut reg = ArtifactRegistry::new();
reg.add(Artifact {
kind: ArtifactKind::Binary,
name: String::new(),
path: PathBuf::from("/nonexistent/binary"),
target: None,
crate_name: "test".to_string(),
metadata: Default::default(),
size: None,
});
assert_eq!(reg.all()[0].size, None);
drop(registry);
}
#[test]
fn test_size_field_not_serialized_when_none() {
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Binary,
name: String::new(),
path: PathBuf::from("dist/myapp"),
target: None,
crate_name: "myapp".to_string(),
metadata: Default::default(),
size: None,
});
let json = registry.to_artifacts_json().unwrap();
let first = &json.as_array().unwrap()[0];
assert!(first.get("size").is_none());
}
#[test]
fn test_size_field_serialized_when_some() {
let mut registry = ArtifactRegistry::new();
registry.add(Artifact {
kind: ArtifactKind::Binary,
name: String::new(),
path: PathBuf::from("dist/myapp"),
target: None,
crate_name: "myapp".to_string(),
metadata: Default::default(),
size: Some(12345),
});
let json = registry.to_artifacts_json().unwrap();
let first = &json.as_array().unwrap()[0];
assert_eq!(first["size"], 12345);
}
#[test]
fn release_uploadable_kinds_matches_canonical_set() {
let kinds = release_uploadable_kinds();
let expected = [
ArtifactKind::Archive,
ArtifactKind::UploadableBinary,
ArtifactKind::UploadableFile,
ArtifactKind::SourceArchive,
ArtifactKind::Makeself,
ArtifactKind::LinuxPackage,
ArtifactKind::Flatpak,
ArtifactKind::SourceRpm,
ArtifactKind::Installer,
ArtifactKind::DiskImage,
ArtifactKind::MacOsPackage,
ArtifactKind::Sbom,
ArtifactKind::Checksum,
ArtifactKind::Signature,
ArtifactKind::Certificate,
];
assert_eq!(kinds, &expected);
}
#[test]
fn artifact_ext_prefers_metadata_when_present() {
let mut metadata = HashMap::new();
metadata.insert("ext".to_string(), ".src.rpm".to_string());
let art = Artifact {
kind: ArtifactKind::SourceRpm,
name: "myapp-1.0.0-1.fc42.src.rpm".to_string(),
path: PathBuf::from("dist/myapp-1.0.0-1.fc42.src.rpm"),
target: None,
crate_name: "myapp".to_string(),
metadata,
size: None,
};
assert_eq!(art.ext(), ".src.rpm");
}
#[test]
fn artifact_ext_falls_back_to_filename_when_metadata_missing() {
let art = Artifact {
kind: ArtifactKind::Archive,
name: "myapp-1.0.0-linux-amd64.tar.gz".to_string(),
path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
};
assert_eq!(art.ext(), ".tar.gz");
}
#[test]
fn artifact_ext_falls_back_when_metadata_ext_is_empty() {
let mut metadata = HashMap::new();
metadata.insert("ext".to_string(), String::new());
let art = Artifact {
kind: ArtifactKind::Archive,
name: "myapp.zip".to_string(),
path: PathBuf::from("dist/myapp.zip"),
target: None,
crate_name: "myapp".to_string(),
metadata,
size: None,
};
assert_eq!(art.ext(), ".zip");
}
#[test]
fn release_uploadable_kinds_excludes_snap_store_and_raw_build_outputs() {
let kinds = release_uploadable_kinds();
for excluded in [
ArtifactKind::Snap,
ArtifactKind::PublishableSnapcraft,
ArtifactKind::Binary,
ArtifactKind::UniversalBinary,
] {
assert!(
!kinds.contains(&excluded),
"{:?} must not be in release_uploadable_kinds()",
excluded
);
}
}
}