Skip to main content

anodizer_core/
artifact.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::Serialize;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
7#[serde(rename_all = "snake_case")]
8#[non_exhaustive]
9pub enum ArtifactKind {
10    // --- Build outputs ---
11    Binary,
12    /// Binary marked for upload (checksummed, signed, released).
13    /// Distinct from Binary which is a raw build output.
14    UploadableBinary,
15    UniversalBinary,
16    Library,
17    Header,
18    CArchive,
19    CShared,
20    Wasm,
21
22    // --- Packaged archives ---
23    Archive,
24    SourceArchive,
25    Makeself,
26
27    // --- Linux packages ---
28    LinuxPackage,
29    Snap,
30    PublishableSnapcraft,
31    Flatpak,
32    SourceRpm,
33
34    // --- macOS/Windows installers ---
35    DiskImage,
36    Installer,
37    MacOsPackage,
38
39    // --- Container images ---
40    DockerImage,
41    DockerImageV2,
42    PublishableDockerImage,
43    DockerManifest,
44    DockerDigest,
45
46    // --- Publisher manifests ---
47    BrewFormula,
48    BrewCask,
49    Nixpkg,
50    ScoopManifest,
51    PublishableChocolatey,
52    WingetInstaller,
53    WingetDefaultLocale,
54    WingetVersion,
55    PkgBuild,
56    SrcInfo,
57    SourcePkgBuild,
58    SourceSrcInfo,
59    KrewPluginManifest,
60
61    // --- Integrity/metadata ---
62    Checksum,
63    Signature,
64    Certificate,
65    Sbom,
66    Metadata,
67    UploadableFile,
68}
69
70impl std::fmt::Display for ArtifactKind {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str(self.as_str())
73    }
74}
75
76impl ArtifactKind {
77    /// Return the snake_case string representation (matching serde serialization).
78    pub fn as_str(&self) -> &'static str {
79        match self {
80            ArtifactKind::Binary => "binary",
81            ArtifactKind::UploadableBinary => "uploadable_binary",
82            ArtifactKind::UniversalBinary => "universal_binary",
83            ArtifactKind::Library => "library",
84            ArtifactKind::Header => "header",
85            ArtifactKind::CArchive => "c_archive",
86            ArtifactKind::CShared => "c_shared",
87            ArtifactKind::Wasm => "wasm",
88            ArtifactKind::Archive => "archive",
89            ArtifactKind::SourceArchive => "source_archive",
90            ArtifactKind::Makeself => "makeself",
91            ArtifactKind::LinuxPackage => "linux_package",
92            ArtifactKind::Snap => "snap",
93            ArtifactKind::PublishableSnapcraft => "publishable_snapcraft",
94            ArtifactKind::Flatpak => "flatpak",
95            ArtifactKind::SourceRpm => "source_rpm",
96            ArtifactKind::DiskImage => "disk_image",
97            ArtifactKind::Installer => "installer",
98            ArtifactKind::MacOsPackage => "macos_package",
99            ArtifactKind::DockerImage => "docker_image",
100            ArtifactKind::DockerImageV2 => "docker_image_v2",
101            ArtifactKind::PublishableDockerImage => "publishable_docker_image",
102            ArtifactKind::DockerManifest => "docker_manifest",
103            ArtifactKind::DockerDigest => "docker_digest",
104            ArtifactKind::BrewFormula => "brew_formula",
105            ArtifactKind::BrewCask => "brew_cask",
106            ArtifactKind::Nixpkg => "nixpkg",
107            ArtifactKind::ScoopManifest => "scoop_manifest",
108            ArtifactKind::PublishableChocolatey => "publishable_chocolatey",
109            ArtifactKind::WingetInstaller => "winget_installer",
110            ArtifactKind::WingetDefaultLocale => "winget_default_locale",
111            ArtifactKind::WingetVersion => "winget_version",
112            ArtifactKind::PkgBuild => "pkg_build",
113            ArtifactKind::SrcInfo => "src_info",
114            ArtifactKind::SourcePkgBuild => "source_pkg_build",
115            ArtifactKind::SourceSrcInfo => "source_src_info",
116            ArtifactKind::KrewPluginManifest => "krew_plugin_manifest",
117            ArtifactKind::Checksum => "checksum",
118            ArtifactKind::Signature => "signature",
119            ArtifactKind::Certificate => "certificate",
120            ArtifactKind::Sbom => "sbom",
121            ArtifactKind::Metadata => "metadata",
122            ArtifactKind::UploadableFile => "uploadable_file",
123        }
124    }
125
126    /// Parse a snake_case string into an ArtifactKind.
127    pub fn parse(s: &str) -> Option<Self> {
128        match s {
129            "binary" => Some(ArtifactKind::Binary),
130            "uploadable_binary" => Some(ArtifactKind::UploadableBinary),
131            "universal_binary" => Some(ArtifactKind::UniversalBinary),
132            "library" => Some(ArtifactKind::Library),
133            "header" => Some(ArtifactKind::Header),
134            "c_archive" => Some(ArtifactKind::CArchive),
135            "c_shared" => Some(ArtifactKind::CShared),
136            "wasm" => Some(ArtifactKind::Wasm),
137            "archive" => Some(ArtifactKind::Archive),
138            "source_archive" => Some(ArtifactKind::SourceArchive),
139            "makeself" => Some(ArtifactKind::Makeself),
140            "linux_package" => Some(ArtifactKind::LinuxPackage),
141            "snap" => Some(ArtifactKind::Snap),
142            "publishable_snapcraft" => Some(ArtifactKind::PublishableSnapcraft),
143            "flatpak" => Some(ArtifactKind::Flatpak),
144            "source_rpm" => Some(ArtifactKind::SourceRpm),
145            "disk_image" => Some(ArtifactKind::DiskImage),
146            "installer" => Some(ArtifactKind::Installer),
147            "macos_package" => Some(ArtifactKind::MacOsPackage),
148            "docker_image" => Some(ArtifactKind::DockerImage),
149            "docker_image_v2" => Some(ArtifactKind::DockerImageV2),
150            "publishable_docker_image" => Some(ArtifactKind::PublishableDockerImage),
151            "docker_manifest" => Some(ArtifactKind::DockerManifest),
152            "docker_digest" => Some(ArtifactKind::DockerDigest),
153            "brew_formula" => Some(ArtifactKind::BrewFormula),
154            "brew_cask" => Some(ArtifactKind::BrewCask),
155            "nixpkg" => Some(ArtifactKind::Nixpkg),
156            "scoop_manifest" => Some(ArtifactKind::ScoopManifest),
157            "publishable_chocolatey" => Some(ArtifactKind::PublishableChocolatey),
158            "winget_installer" => Some(ArtifactKind::WingetInstaller),
159            "winget_default_locale" => Some(ArtifactKind::WingetDefaultLocale),
160            "winget_version" => Some(ArtifactKind::WingetVersion),
161            "pkg_build" => Some(ArtifactKind::PkgBuild),
162            "src_info" => Some(ArtifactKind::SrcInfo),
163            "source_pkg_build" => Some(ArtifactKind::SourcePkgBuild),
164            "source_src_info" => Some(ArtifactKind::SourceSrcInfo),
165            "krew_plugin_manifest" => Some(ArtifactKind::KrewPluginManifest),
166            "checksum" => Some(ArtifactKind::Checksum),
167            "signature" => Some(ArtifactKind::Signature),
168            "certificate" => Some(ArtifactKind::Certificate),
169            "sbom" => Some(ArtifactKind::Sbom),
170            "metadata" => Some(ArtifactKind::Metadata),
171            "uploadable_file" => Some(ArtifactKind::UploadableFile),
172            _ => None,
173        }
174    }
175}
176
177#[derive(Debug, Clone, Serialize)]
178pub struct Artifact {
179    pub kind: ArtifactKind,
180    pub path: PathBuf,
181    /// Canonical artifact name, set at add-time from the path's filename (trimmed).
182    pub name: String,
183    pub target: Option<String>,
184    pub crate_name: String,
185    #[serde(serialize_with = "serialize_metadata_sorted")]
186    pub metadata: HashMap<String, String>,
187    /// File size in bytes, populated by report_sizes.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub size: Option<u64>,
190}
191
192/// Keys whose values are CONTENT hashes — derived from artifact bytes
193/// and therefore non-deterministic when the artifact itself is
194/// non-deterministic (e.g. `.deb` / `.rpm` whose packagers embed
195/// their own timestamps). Stage-checksum writes these into each
196/// artifact's metadata for in-process consumers (chocolatey, scoop,
197/// winget — which read `ctx.artifacts` directly, not `artifacts.json`),
198/// but emitting them in `artifacts.json` makes the manifest's bytes
199/// shadow whatever non-determinism the underlying artifact has.
200/// The `.sha256` sidecar files on disk remain the canonical hash
201/// surface for external tooling.
202const METADATA_HASH_KEYS: &[&str] = &[
203    "Checksum", "sha256", "sha512", "sha384", "sha224", "sha1", "md5", "blake2b", "blake3", "crc32",
204];
205
206/// Serialize the metadata map as a sorted-key JSON object, dropping
207/// content-hash keys. Sorted order kills HashMap iteration drift;
208/// dropping hashes kills the non-deterministic-content shadow.
209fn serialize_metadata_sorted<S>(map: &HashMap<String, String>, ser: S) -> Result<S::Ok, S::Error>
210where
211    S: serde::Serializer,
212{
213    use serde::ser::SerializeMap as _;
214    let sorted: std::collections::BTreeMap<&String, &String> = map
215        .iter()
216        .filter(|(k, _)| !METADATA_HASH_KEYS.contains(&k.as_str()))
217        .collect();
218    let mut m = ser.serialize_map(Some(sorted.len()))?;
219    for (k, v) in sorted {
220        m.serialize_entry(k, v)?;
221    }
222    m.end()
223}
224
225impl Artifact {
226    /// Return the artifact filename.
227    pub fn name(&self) -> &str {
228        &self.name
229    }
230
231    /// Return the OS component of the target (e.g., "linux", "darwin", "windows").
232    pub fn goos(&self) -> Option<String> {
233        self.target.as_ref().map(|t| crate::target::map_target(t).0)
234    }
235
236    /// Return the arch component of the target (e.g., "amd64", "arm64").
237    pub fn goarch(&self) -> Option<String> {
238        self.target.as_ref().map(|t| crate::target::map_target(t).1)
239    }
240
241    /// Check if this artifact replaces single-arch variants (universal binary dedup).
242    /// `OnlyReplacingUnibins` — when a universal binary has
243    /// `replaces=true`, it supersedes the per-arch binaries for publisher consumption.
244    /// Artifacts without the `replaces` metadata key default to `true` (included).
245    pub fn only_replacing_unibins(&self) -> bool {
246        self.metadata.get("replaces").is_none_or(|v| v != "false")
247    }
248
249    /// Return the list of extra binary names bundled in this archive artifact.
250    pub fn extra_binaries(&self) -> Vec<String> {
251        self.metadata
252            .get("extra_binaries")
253            .map(|v| {
254                v.split(',')
255                    .filter(|s| !s.is_empty())
256                    .map(|s| s.to_string())
257                    .collect()
258            })
259            .unwrap_or_default()
260    }
261
262    /// Return the single binary name for an uploadable binary artifact.
263    pub fn extra_binary(&self) -> Option<String> {
264        self.metadata.get("binary").cloned()
265    }
266
267    /// Resolve the artifact's canonical file extension (including the leading
268    /// dot), mirroring GoReleaser's `Artifact.Ext()` at
269    /// `internal/artifact/artifact.go:442`: prefer the `ext` metadata extra
270    /// when present and non-empty, fall back to parsing the filename.
271    ///
272    /// Stages that know their canonical extension better than filename
273    /// parsing can (e.g. `srpm` knowing `.src.rpm` rather than `.rpm`)
274    /// populate `metadata["ext"]` so downstream `{{ .ArtifactExt }}`
275    /// renders the canonical value.
276    pub fn ext(&self) -> String {
277        if let Some(ext) = self.metadata.get("ext")
278            && !ext.is_empty()
279        {
280            return ext.clone();
281        }
282        crate::template::extract_artifact_ext(&self.name).to_string()
283    }
284}
285
286#[derive(Debug, Default)]
287pub struct ArtifactRegistry {
288    artifacts: Vec<Artifact>,
289}
290
291impl ArtifactRegistry {
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    pub fn add(&mut self, mut artifact: Artifact) {
297        // Set canonical name from path filename if the caller hasn't provided one.
298        let name = if artifact.name.is_empty() {
299            let derived = artifact
300                .path
301                .file_name()
302                .and_then(|n| n.to_str())
303                .unwrap_or("artifact")
304                .trim()
305                .to_string();
306            artifact.name = derived.clone();
307            derived
308        } else {
309            artifact.name.clone()
310        };
311
312        // Relativize absolute paths to the current working directory so the
313        // determinism harness produces byte-identical `artifacts.json` across
314        // runs that operate in different worktrees. Mirrors GoReleaser's
315        // `shouldRelPath` / `relPath` in `internal/artifact/artifact.go:529-547`.
316        //
317        // Without this, raw cargo binaries register paths like
318        // `/tmp/anodize-determinism-12345-0/.det-tmp/target/<triple>/release/<bin>`
319        // — the leading `/tmp/anodize-determinism-<pid>-<idx>` prefix differs
320        // every run and drifts `dist/artifacts.json` even when the bytes of
321        // every other artifact match.
322        //
323        // Guard against cwd being the filesystem root (`/` on Unix, `C:\` on
324        // Windows): in that degenerate case every absolute path "starts with
325        // cwd" but stripping the leading separator yields a path that no
326        // longer resolves under the original cwd. We detect root via
327        // `parent().is_none()` (works cross-platform) and skip the
328        // relativization — production never runs from `/`, but a small
329        // number of unit tests do (e.g. `stage-source`'s
330        // `test_stage_run_does_not_depend_on_cwd`).
331        if should_relativize_path(artifact.kind)
332            && artifact.path.is_absolute()
333            && let Ok(cwd) = std::env::current_dir()
334            && cwd.parent().is_some()
335            && let Ok(rel) = artifact.path.strip_prefix(&cwd)
336        {
337            artifact.path = rel.to_path_buf();
338        }
339
340        // Normalize path: convert to forward slashes for cross-platform consistency.
341        let path_str = crate::util::normalize_path_separators(&artifact.path.to_string_lossy());
342        artifact.path = PathBuf::from(path_str);
343
344        // Warn on duplicate names for uploadable artifact types.
345        if is_uploadable(artifact.kind)
346            && let Some(existing) = self
347                .artifacts
348                .iter()
349                .find(|a| is_uploadable(a.kind) && a.name == name)
350        {
351            // Route through `tracing::warn!` so the subscriber-level redaction
352            // layer applies and the warning is intercept-friendly for tests.
353            tracing::warn!(
354                artifact = %name,
355                existing = %existing.path.display(),
356                new = %artifact.path.display(),
357                "artifact already registered; upload may fail with duplicate error",
358            );
359        }
360
361        self.artifacts.push(artifact);
362    }
363
364    /// Drop later duplicate-path entries that carry `target: None`.
365    ///
366    /// Used by the publish-only multi-shard rehydration: cross-target
367    /// artifacts (source archive, install.sh, release-level metadata)
368    /// have `target: None` and are produced identically by every shard's
369    /// harness run. After per-shard manifests are merged into a single
370    /// registry, those entries duplicate by path. `download-artifact
371    /// merge-multiple` collapses the on-disk copies to one file, so the
372    /// registry must follow suit or SignStage / ReleaseStage emits each
373    /// entry separately and races on the same on-disk path.
374    ///
375    /// Per-target duplicates (`target: Some(_)`) are left untouched —
376    /// those indicate a real shard-overlap bug and downstream
377    /// validators must surface them.
378    pub fn dedupe_targetless_duplicates(&mut self) {
379        let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
380        self.artifacts.retain(|a| {
381            if a.target.is_some() {
382                return true;
383            }
384            seen.insert(a.path.clone())
385        });
386    }
387
388    pub fn by_kind(&self, kind: ArtifactKind) -> Vec<&Artifact> {
389        self.artifacts.iter().filter(|a| a.kind == kind).collect()
390    }
391
392    pub fn by_kind_and_crate(&self, kind: ArtifactKind, crate_name: &str) -> Vec<&Artifact> {
393        self.artifacts
394            .iter()
395            .filter(|a| a.kind == kind && a.crate_name == crate_name)
396            .collect()
397    }
398
399    pub fn by_kinds_and_crate(&self, kinds: &[ArtifactKind], crate_name: &str) -> Vec<&Artifact> {
400        self.artifacts
401            .iter()
402            .filter(|a| kinds.contains(&a.kind) && a.crate_name == crate_name)
403            .collect()
404    }
405
406    /// Return one artifact per `path` from the (Binary | UploadableBinary |
407    /// UniversalBinary) set, preferring `UploadableBinary` when both kinds
408    /// register the same path. UniversalBinary paths differ from their
409    /// component binaries so they pass through untouched.
410    ///
411    /// Mirrors GoReleaser's `artifact.ByBinaryLikeArtifacts`
412    /// (`internal/artifact/artifact.go:733-761`). Used by stage-sbom to
413    /// avoid generating duplicate SBOMs at the same path; would be silently
414    /// hand-rolled in any future stage that walks binary kinds.
415    pub fn binary_like_dedup(&self) -> Vec<&Artifact> {
416        let uploadable_paths: std::collections::HashSet<&std::path::Path> = self
417            .artifacts
418            .iter()
419            .filter(|a| a.kind == ArtifactKind::UploadableBinary)
420            .map(|a| a.path.as_path())
421            .collect();
422        self.artifacts
423            .iter()
424            .filter(|a| {
425                matches!(
426                    a.kind,
427                    ArtifactKind::Binary
428                        | ArtifactKind::UploadableBinary
429                        | ArtifactKind::UniversalBinary
430                )
431            })
432            .filter(|a| {
433                a.kind == ArtifactKind::UploadableBinary
434                    || !uploadable_paths.contains(a.path.as_path())
435            })
436            .collect()
437    }
438
439    pub fn all(&self) -> &[Artifact] {
440        &self.artifacts
441    }
442
443    pub fn all_mut(&mut self) -> &mut [Artifact] {
444        &mut self.artifacts
445    }
446
447    /// Filter artifacts by a predicate, returning matching references.
448    pub fn filter<F: Fn(&Artifact) -> bool>(&self, predicate: F) -> Vec<&Artifact> {
449        self.artifacts.iter().filter(|a| predicate(a)).collect()
450    }
451
452    /// Remove all artifacts whose path matches one of the given paths.
453    pub fn remove_by_paths(&mut self, paths: &[std::path::PathBuf]) {
454        self.artifacts.retain(|a| !paths.contains(&a.path));
455    }
456
457    /// Serialize all artifacts to a JSON value suitable for writing to artifacts.json.
458    /// Normalizes all artifact paths to use forward slashes for cross-platform
459    /// consistency (GoReleaser always writes forward slashes).
460    ///
461    /// **Determinism**: artifacts are emitted in a stable sort order keyed on
462    /// `(kind, target, crate_name, name, path)` regardless of registration
463    /// order. The harness caught a regression where two runs registered the
464    /// same archive set in opposite orders (`linux-amd64` first vs
465    /// `linux-arm64` first) because the upstream `stage-archive` grouping
466    /// used `HashMap` iteration. Even with that root cause fixed in
467    /// `stage-archive/src/run.rs`, every other stage that registers
468    /// artifacts via `ArtifactRegistry::add` is a future regression risk;
469    /// sorting here forecloses the failure mode entirely. Cost is O(N log N)
470    /// on a small N (~tens of artifacts per release).
471    pub fn to_artifacts_json(&self) -> anyhow::Result<serde_json::Value> {
472        let mut sorted: Vec<&Artifact> = self.artifacts.iter().collect();
473        sorted.sort_by(|a, b| {
474            (
475                a.kind.as_str(),
476                a.target.as_deref().unwrap_or(""),
477                a.crate_name.as_str(),
478                a.name.as_str(),
479                a.path.as_path(),
480            )
481                .cmp(&(
482                    b.kind.as_str(),
483                    b.target.as_deref().unwrap_or(""),
484                    b.crate_name.as_str(),
485                    b.name.as_str(),
486                    b.path.as_path(),
487                ))
488        });
489        let mut val = serde_json::to_value(&sorted)?;
490        // Normalize backslashes in path fields to forward slashes.
491        if let Some(arr) = val.as_array_mut() {
492            for entry in arr {
493                if let Some(path) = entry
494                    .get("path")
495                    .and_then(|p| p.as_str())
496                    .map(crate::util::normalize_path_separators)
497                {
498                    entry["path"] = serde_json::Value::String(path);
499                }
500            }
501        }
502        Ok(val)
503    }
504}
505
506/// Artifact kinds that should be included in size reporting.
507pub fn size_reportable_kinds() -> &'static [ArtifactKind] {
508    &[
509        // Uploadable types (all appear in releases)
510        ArtifactKind::Archive,
511        ArtifactKind::SourceArchive,
512        ArtifactKind::UploadableFile,
513        ArtifactKind::Makeself,
514        ArtifactKind::LinuxPackage,
515        ArtifactKind::Flatpak,
516        ArtifactKind::SourceRpm,
517        ArtifactKind::Sbom,
518        ArtifactKind::Checksum,
519        ArtifactKind::Signature,
520        ArtifactKind::Certificate,
521        ArtifactKind::DiskImage,
522        ArtifactKind::Installer,
523        ArtifactKind::MacOsPackage,
524        ArtifactKind::Snap,
525        ArtifactKind::PublishableSnapcraft,
526        // Build outputs
527        ArtifactKind::Binary,
528        ArtifactKind::UploadableBinary,
529        ArtifactKind::UniversalBinary,
530        ArtifactKind::Library,
531        ArtifactKind::Header,
532        ArtifactKind::CArchive,
533        ArtifactKind::CShared,
534        ArtifactKind::Wasm,
535    ]
536}
537
538/// Artifact kinds that are uploadable to releases/blob storage — the canonical
539/// list of types that should be uploaded, checksummed, signed, and distributed.
540pub fn uploadable_kinds() -> &'static [ArtifactKind] {
541    &[
542        ArtifactKind::Archive,
543        ArtifactKind::UploadableBinary,
544        ArtifactKind::SourceArchive,
545        ArtifactKind::UploadableFile,
546        ArtifactKind::Makeself,
547        ArtifactKind::LinuxPackage,
548        ArtifactKind::PublishableSnapcraft,
549        ArtifactKind::Flatpak,
550        ArtifactKind::SourceRpm,
551        ArtifactKind::Sbom,
552        ArtifactKind::Checksum,
553        ArtifactKind::Signature,
554        ArtifactKind::Certificate,
555        ArtifactKind::DiskImage,
556        ArtifactKind::Installer,
557        ArtifactKind::MacOsPackage,
558    ]
559}
560
561/// Artifact kinds eligible for release upload. Canonical list used by the
562/// GitHub release publisher, blob storage, stage-checksum, and the stage-sign
563/// "all" filter.
564///
565/// Mirrors GoReleaser's `artifact.ReleaseUploadableTypes()` plus the four
566/// installer kinds that are GR Pro features (MSI/NSIS as `Installer`, DMG as
567/// `DiskImage`, PKG as `MacOsPackage`) — anodizer ships these as OSS so they
568/// are first-class release artifacts here.
569///
570/// Kept narrower than [`uploadable_kinds`]: snap-store-bound kinds
571/// ([`ArtifactKind::Snap`], [`ArtifactKind::PublishableSnapcraft`]) and raw
572/// build outputs ([`ArtifactKind::Binary`], [`ArtifactKind::UniversalBinary`])
573/// don't end up in the GitHub release, so they don't appear here either.
574pub fn release_uploadable_kinds() -> &'static [ArtifactKind] {
575    &[
576        ArtifactKind::Archive,
577        ArtifactKind::UploadableBinary,
578        ArtifactKind::UploadableFile,
579        ArtifactKind::SourceArchive,
580        ArtifactKind::Makeself,
581        ArtifactKind::LinuxPackage,
582        ArtifactKind::Flatpak,
583        ArtifactKind::SourceRpm,
584        ArtifactKind::Installer,
585        ArtifactKind::DiskImage,
586        ArtifactKind::MacOsPackage,
587        ArtifactKind::Sbom,
588        ArtifactKind::Checksum,
589        ArtifactKind::Signature,
590        ArtifactKind::Certificate,
591    ]
592}
593
594/// Check if an artifact kind is uploadable.
595fn is_uploadable(kind: ArtifactKind) -> bool {
596    uploadable_kinds().contains(&kind)
597}
598
599/// Should the `add()` path normaliser convert an absolute path into a path
600/// relative to the current working directory? Mirrors GoReleaser's
601/// `shouldRelPath` in `internal/artifact/artifact.go:540-547`: Docker image
602/// "paths" are actually image refs (e.g. `repo/name:tag`) and must pass
603/// through untouched. Every other kind is a real on-disk file whose absolute
604/// path would otherwise leak the (per-run) worktree prefix into
605/// `dist/artifacts.json`.
606fn should_relativize_path(kind: ArtifactKind) -> bool {
607    !matches!(
608        kind,
609        ArtifactKind::DockerImage
610            | ArtifactKind::DockerImageV2
611            | ArtifactKind::PublishableDockerImage
612            | ArtifactKind::DockerManifest
613            | ArtifactKind::DockerDigest
614    )
615}
616
617/// Return `true` for signature/certificate artifacts produced by the
618/// `binary_signs:` stage.  These are intermediate per-binary outputs
619/// (e.g. `anodizer_linux_amd64` without a `.sig` extension) that must not
620/// appear as GitHub release assets.
621pub fn is_binary_sign_output(artifact: &Artifact) -> bool {
622    artifact
623        .metadata
624        .get("binary_sign")
625        .is_some_and(|v| v == "true")
626}
627
628/// Filter an artifact by the `id` metadata field.
629///
630/// Matches GoReleaser's `artifact.ByID` semantic:
631/// - When `ids` is `None` or empty, every artifact passes.
632/// - Artifact kinds `Checksum`, `SourceArchive`, `UploadableFile`, `Metadata`
633///   always pass regardless of filter (these are emitted for every release).
634/// - For all other kinds, the artifact's `metadata["id"]` must match one of
635///   the supplied ids. An artifact missing an `id` metadata value does not
636///   match a non-empty filter.
637///
638/// Upstream reference: `goreleaser/internal/artifact/artifact.go::ByID`.
639pub fn matches_id_filter(artifact: &Artifact, ids: Option<&[String]>) -> bool {
640    let Some(id_list) = ids else { return true };
641    if id_list.is_empty() {
642        return true;
643    }
644    if matches!(
645        artifact.kind,
646        ArtifactKind::Checksum
647            | ArtifactKind::SourceArchive
648            | ArtifactKind::UploadableFile
649            | ArtifactKind::Metadata
650    ) {
651        return true;
652    }
653    let artifact_id = artifact
654        .metadata
655        .get("id")
656        .map(|s| s.as_str())
657        .unwrap_or("");
658    id_list.iter().any(|id| id == artifact_id)
659}
660
661/// Format a byte count into a human-readable string (e.g. "4.2 MB").
662pub fn format_size(bytes: u64) -> String {
663    const KB: f64 = 1024.0;
664    const MB: f64 = KB * 1024.0;
665    const GB: f64 = MB * 1024.0;
666
667    let b = bytes as f64;
668    if b >= GB {
669        format!("{:.1} GB", b / GB)
670    } else if b >= MB {
671        format!("{:.1} MB", b / MB)
672    } else if b >= KB {
673        format!("{:.1} KB", b / KB)
674    } else {
675        format!("{} B", bytes)
676    }
677}
678
679/// Populate artifact sizes and print a formatted size table.
680///
681/// Filters artifacts to [`size_reportable_kinds`] (matching GoReleaser's
682/// `reportsizes` pipe), stores the file size in each artifact's `size` field,
683/// and prints a human-readable table.
684pub fn print_size_report(registry: &mut ArtifactRegistry, log: &crate::log::StageLogger) {
685    let reportable = size_reportable_kinds();
686    let mut entries: Vec<(String, u64)> = Vec::new();
687    let mut total: u64 = 0;
688
689    for artifact in registry.all_mut() {
690        if !reportable.contains(&artifact.kind) {
691            continue;
692        }
693        if let Ok(meta) = std::fs::metadata(&artifact.path) {
694            let size = meta.len();
695            artifact.size = Some(size);
696            let name = artifact
697                .path
698                .file_name()
699                .map(|n| n.to_string_lossy().to_string())
700                .unwrap_or_else(|| artifact.path.display().to_string());
701            entries.push((name, size));
702            total += size;
703        }
704    }
705
706    if entries.is_empty() {
707        return;
708    }
709
710    let max_name_len = entries.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
711
712    log.status("");
713    log.status("Artifact Sizes:");
714    for (name, size) in &entries {
715        log.status(&format!(
716            "  {:<width$}  {}",
717            name,
718            format_size(*size),
719            width = max_name_len
720        ));
721    }
722    log.status(&format!(
723        "  {:<width$}  {}",
724        "Total:",
725        format_size(total),
726        width = max_name_len
727    ));
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use std::path::PathBuf;
734
735    #[test]
736    fn test_add_and_query_artifacts() {
737        let mut registry = ArtifactRegistry::new();
738        registry.add(Artifact {
739            kind: ArtifactKind::Binary,
740            name: String::new(),
741            path: PathBuf::from("dist/cfgd"),
742            target: Some("x86_64-unknown-linux-gnu".to_string()),
743            crate_name: "cfgd".to_string(),
744            metadata: Default::default(),
745            size: None,
746        });
747        registry.add(Artifact {
748            kind: ArtifactKind::Archive,
749            name: String::new(),
750            path: PathBuf::from("dist/cfgd.tar.gz"),
751            target: Some("x86_64-unknown-linux-gnu".to_string()),
752            crate_name: "cfgd".to_string(),
753            metadata: Default::default(),
754            size: None,
755        });
756
757        let binaries = registry.by_kind(ArtifactKind::Binary);
758        assert_eq!(binaries.len(), 1);
759
760        let archives = registry.by_kind_and_crate(ArtifactKind::Archive, "cfgd");
761        assert_eq!(archives.len(), 1);
762    }
763
764    #[test]
765    fn test_empty_query() {
766        let registry = ArtifactRegistry::new();
767        assert!(registry.by_kind(ArtifactKind::Binary).is_empty());
768    }
769
770    /// Multi-shard rehydration appends each shard's artifacts manifest
771    /// into one registry. Cross-target artifacts (source archive,
772    /// install.sh, metadata.json — `target: None`) appear N times
773    /// (once per shard). `dedupe_targetless_duplicates` must collapse
774    /// them to one entry per path while leaving per-target entries
775    /// intact.
776    #[test]
777    fn dedupe_targetless_duplicates_collapses_cross_shard_dups() {
778        let mut registry = ArtifactRegistry::new();
779        // Three shards each register the same cross-target source archive.
780        for _ in 0..3 {
781            registry.add(Artifact {
782                kind: ArtifactKind::SourceArchive,
783                name: "anodizer-0.3.0-source.tar.gz".to_string(),
784                path: PathBuf::from("dist/anodizer-0.3.0-source.tar.gz"),
785                target: None,
786                crate_name: "anodizer".to_string(),
787                metadata: HashMap::new(),
788                size: None,
789            });
790        }
791        // Plus a couple of per-target archives that are NOT duplicates
792        // (same crate, different target → different path expected, but
793        // we use the same path here to exercise the negative case:
794        // dedupe must leave target-Some duplicates alone for the
795        // downstream overlap-detection check).
796        for triple in &["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] {
797            registry.add(Artifact {
798                kind: ArtifactKind::Archive,
799                name: format!("anodizer-0.3.0-{}.tar.gz", triple),
800                path: PathBuf::from(format!("dist/anodizer-0.3.0-{}.tar.gz", triple)),
801                target: Some((*triple).to_string()),
802                crate_name: "anodizer".to_string(),
803                metadata: HashMap::new(),
804                size: None,
805            });
806        }
807
808        registry.dedupe_targetless_duplicates();
809
810        // Source archive collapsed from 3 → 1 entry.
811        let sources: Vec<_> = registry.by_kind(ArtifactKind::SourceArchive);
812        assert_eq!(
813            sources.len(),
814            1,
815            "cross-shard target-None duplicates must collapse to 1 entry"
816        );
817        // Per-target archives untouched.
818        assert_eq!(registry.by_kind(ArtifactKind::Archive).len(), 2);
819    }
820
821    /// Companion: dedupe must NOT touch per-target duplicates (target:
822    /// Some) since those signal real matrix overlap and must be caught
823    /// by the downstream `detect_duplicate_artifact_paths` validator.
824    #[test]
825    fn dedupe_targetless_duplicates_leaves_per_target_duplicates_intact() {
826        let mut registry = ArtifactRegistry::new();
827        for _ in 0..3 {
828            registry.add(Artifact {
829                kind: ArtifactKind::Archive,
830                name: "anodizer-x86_64.tar.gz".to_string(),
831                path: PathBuf::from("dist/anodizer-x86_64.tar.gz"),
832                target: Some("x86_64-unknown-linux-gnu".to_string()),
833                crate_name: "anodizer".to_string(),
834                metadata: HashMap::new(),
835                size: None,
836            });
837        }
838
839        registry.dedupe_targetless_duplicates();
840
841        assert_eq!(
842            registry.by_kind(ArtifactKind::Archive).len(),
843            3,
844            "per-target duplicates must remain so detect_duplicate_artifact_paths can flag them"
845        );
846    }
847
848    #[test]
849    fn test_by_kinds_and_crate() {
850        let mut registry = ArtifactRegistry::new();
851        registry.add(Artifact {
852            kind: ArtifactKind::Binary,
853            name: "bin".to_string(),
854            path: PathBuf::from("bin"),
855            target: None,
856            crate_name: "app".to_string(),
857            metadata: HashMap::new(),
858            size: None,
859        });
860        registry.add(Artifact {
861            kind: ArtifactKind::UniversalBinary,
862            name: "ubin".to_string(),
863            path: PathBuf::from("ubin"),
864            target: None,
865            crate_name: "app".to_string(),
866            metadata: HashMap::new(),
867            size: None,
868        });
869        registry.add(Artifact {
870            kind: ArtifactKind::Header,
871            name: "hdr".to_string(),
872            path: PathBuf::from("hdr"),
873            target: None,
874            crate_name: "other".to_string(),
875            metadata: HashMap::new(),
876            size: None,
877        });
878
879        let results = registry.by_kinds_and_crate(
880            &[ArtifactKind::Binary, ArtifactKind::UniversalBinary],
881            "app",
882        );
883        assert_eq!(results.len(), 2);
884
885        // Header belongs to "other" crate, not "app"
886        let results = registry.by_kinds_and_crate(&[ArtifactKind::Header], "app");
887        assert_eq!(results.len(), 0);
888    }
889
890    #[test]
891    fn test_to_artifacts_json_empty() {
892        let registry = ArtifactRegistry::new();
893        let json = registry.to_artifacts_json().unwrap();
894        assert!(json.is_array());
895        assert_eq!(json.as_array().unwrap().len(), 0);
896    }
897
898    #[test]
899    fn test_to_artifacts_json_with_artifacts() {
900        let mut registry = ArtifactRegistry::new();
901        let mut meta = HashMap::new();
902        meta.insert("format".to_string(), "tar.gz".to_string());
903        registry.add(Artifact {
904            kind: ArtifactKind::Archive,
905            name: String::new(),
906            path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
907            target: Some("x86_64-unknown-linux-gnu".to_string()),
908            crate_name: "myapp".to_string(),
909            metadata: meta,
910            size: None,
911        });
912        registry.add(Artifact {
913            kind: ArtifactKind::Checksum,
914            name: String::new(),
915            path: PathBuf::from("dist/myapp_1.0.0_checksums.txt"),
916            target: None,
917            crate_name: "myapp".to_string(),
918            metadata: Default::default(),
919            size: None,
920        });
921
922        let json = registry.to_artifacts_json().unwrap();
923        let arr = json.as_array().unwrap();
924        assert_eq!(arr.len(), 2);
925
926        // First artifact
927        let first = &arr[0];
928        assert_eq!(first["kind"], "archive");
929        assert_eq!(first["path"], "dist/myapp-1.0.0-linux-amd64.tar.gz");
930        assert_eq!(first["target"], "x86_64-unknown-linux-gnu");
931        assert_eq!(first["crate_name"], "myapp");
932        assert_eq!(first["metadata"]["format"], "tar.gz");
933
934        // Second artifact
935        let second = &arr[1];
936        assert_eq!(second["kind"], "checksum");
937        assert!(second["target"].is_null());
938    }
939
940    /// Regression for the determinism harness drift on `dist/artifacts.json`.
941    /// Two harness runs use different worktrees (e.g.
942    /// `/tmp/anodize-determinism-11193-0` vs `…-22847-0`) and CARGO_TARGET_DIR
943    /// is an absolute per-worktree path; `Artifact.path` for raw cargo binaries
944    /// is therefore absolute. Without the `add()`-time relativization, the
945    /// worktree prefix would land in `artifacts.json` and the two runs would
946    /// disagree on that byte sequence even when every other artifact matches.
947    /// Mirrors GoReleaser's `shouldRelPath` / `relPath`
948    /// (`internal/artifact/artifact.go:529-560`).
949    #[test]
950    #[serial_test::serial(cwd)]
951    fn to_artifacts_json_strips_absolute_worktree_prefix() {
952        let cwd_guard = tempfile::tempdir().unwrap();
953        let original_cwd = std::env::current_dir().unwrap();
954        std::env::set_current_dir(cwd_guard.path()).unwrap();
955        // current_dir() returns a canonicalized path on most platforms; mirror
956        // that so strip_prefix matches what add() will compute internally.
957        let canonical_cwd = std::env::current_dir().unwrap();
958        let abs = canonical_cwd
959            .join("dist")
960            .join("anodize-1.0.0-linux-amd64.tar.gz");
961
962        let mut registry = ArtifactRegistry::new();
963        registry.add(Artifact {
964            kind: ArtifactKind::Archive,
965            name: String::new(),
966            path: abs,
967            target: Some("x86_64-unknown-linux-gnu".to_string()),
968            crate_name: "anodize".to_string(),
969            metadata: Default::default(),
970            size: None,
971        });
972
973        let json = registry.to_artifacts_json().unwrap();
974        let arr = json.as_array().unwrap();
975        assert_eq!(
976            arr[0]["path"], "dist/anodize-1.0.0-linux-amd64.tar.gz",
977            "absolute worktree prefix must be stripped at add() time so two \
978             determinism-harness runs at different worktree paths produce \
979             byte-identical artifacts.json"
980        );
981
982        std::env::set_current_dir(original_cwd).unwrap();
983    }
984
985    /// Regression for determinism drift on `dist/artifacts.json`: two
986    /// runs produced byte-different `artifacts.json` even though the set
987    /// of artifacts was identical — the upstream `stage-archive`
988    /// registered per-target archives in `HashMap` iteration order, which
989    /// is randomised per process. The diff was archive entries in
990    /// opposite positions (`linux-arm64` before `linux-amd64` vs. the
991    /// reverse).
992    ///
993    /// `to_artifacts_json` now sorts on (kind, target, crate_name, name,
994    /// path) before emitting, so even if a future stage registers artifacts
995    /// in non-deterministic order the JSON output is byte-identical.
996    #[test]
997    fn to_artifacts_json_output_is_order_insensitive() {
998        // Build registry A: arm64 archive first, then amd64.
999        let mut reg_a = ArtifactRegistry::new();
1000        reg_a.add(Artifact {
1001            kind: ArtifactKind::Archive,
1002            name: String::new(),
1003            path: PathBuf::from("dist/anodize-1.0.0-linux-arm64.tar.gz"),
1004            target: Some("aarch64-unknown-linux-gnu".to_string()),
1005            crate_name: "anodize".to_string(),
1006            metadata: Default::default(),
1007            size: Some(15_000_000),
1008        });
1009        reg_a.add(Artifact {
1010            kind: ArtifactKind::Archive,
1011            name: String::new(),
1012            path: PathBuf::from("dist/anodize-1.0.0-linux-amd64.tar.gz"),
1013            target: Some("x86_64-unknown-linux-gnu".to_string()),
1014            crate_name: "anodize".to_string(),
1015            metadata: Default::default(),
1016            size: Some(18_000_000),
1017        });
1018
1019        // Build registry B: amd64 archive first, then arm64 (opposite order).
1020        let mut reg_b = ArtifactRegistry::new();
1021        reg_b.add(Artifact {
1022            kind: ArtifactKind::Archive,
1023            name: String::new(),
1024            path: PathBuf::from("dist/anodize-1.0.0-linux-amd64.tar.gz"),
1025            target: Some("x86_64-unknown-linux-gnu".to_string()),
1026            crate_name: "anodize".to_string(),
1027            metadata: Default::default(),
1028            size: Some(18_000_000),
1029        });
1030        reg_b.add(Artifact {
1031            kind: ArtifactKind::Archive,
1032            name: String::new(),
1033            path: PathBuf::from("dist/anodize-1.0.0-linux-arm64.tar.gz"),
1034            target: Some("aarch64-unknown-linux-gnu".to_string()),
1035            crate_name: "anodize".to_string(),
1036            metadata: Default::default(),
1037            size: Some(15_000_000),
1038        });
1039
1040        let json_a = serde_json::to_string_pretty(&reg_a.to_artifacts_json().unwrap()).unwrap();
1041        let json_b = serde_json::to_string_pretty(&reg_b.to_artifacts_json().unwrap()).unwrap();
1042
1043        assert_eq!(
1044            json_a, json_b,
1045            "two registries with the same artifacts in different insertion \
1046             orders must produce byte-identical artifacts.json — otherwise \
1047             the determinism harness will surface per-run drift in dist/"
1048        );
1049    }
1050
1051    /// Docker image "paths" are image refs (`repo/name:tag`), not on-disk
1052    /// files. The `add()` path normaliser must NOT touch them — stripping a
1053    /// `/` prefix off `repo/name:tag` would corrupt downstream stages that
1054    /// `docker push` the value verbatim. Mirrors `shouldRelPath`'s
1055    /// docker-kind carve-out.
1056    #[test]
1057    #[serial_test::serial(cwd)]
1058    fn to_artifacts_json_preserves_docker_image_refs() {
1059        let mut registry = ArtifactRegistry::new();
1060        registry.add(Artifact {
1061            kind: ArtifactKind::DockerImage,
1062            name: "myorg/myimage:v1.2.3".to_string(),
1063            path: PathBuf::from("/myorg/myimage:v1.2.3"),
1064            target: None,
1065            crate_name: "myapp".to_string(),
1066            metadata: Default::default(),
1067            size: None,
1068        });
1069
1070        let json = registry.to_artifacts_json().unwrap();
1071        let arr = json.as_array().unwrap();
1072        assert_eq!(
1073            arr[0]["path"], "/myorg/myimage:v1.2.3",
1074            "docker image refs are pass-through and must not be relativized"
1075        );
1076    }
1077
1078    #[test]
1079    fn to_artifacts_json_drops_content_hash_keys() {
1080        let mut metadata = HashMap::new();
1081        metadata.insert("format".into(), "deb".into());
1082        metadata.insert("id".into(), "default".into());
1083        // Content hashes vary between runs for non-deterministic
1084        // artifacts (.deb / .rpm / .msi ...); they belong in the
1085        // `.sha256` sidecar, not in this manifest.
1086        metadata.insert("Checksum".into(), "sha256:abc".into());
1087        metadata.insert("sha256".into(), "abc".into());
1088        metadata.insert("blake3".into(), "xyz".into());
1089
1090        let mut registry = ArtifactRegistry::new();
1091        registry.add(Artifact {
1092            kind: ArtifactKind::LinuxPackage,
1093            name: "pkg.deb".to_string(),
1094            path: PathBuf::from("dist/pkg.deb"),
1095            target: Some("x86_64-unknown-linux-gnu".to_string()),
1096            crate_name: "myapp".to_string(),
1097            metadata,
1098            size: None,
1099        });
1100
1101        let json = registry.to_artifacts_json().unwrap();
1102        let meta = &json.as_array().unwrap()[0]["metadata"];
1103        assert_eq!(meta["format"], "deb");
1104        assert_eq!(meta["id"], "default");
1105        assert!(
1106            meta.get("Checksum").is_none(),
1107            "Checksum (content-hash) must be filtered from artifacts.json: {meta:?}"
1108        );
1109        assert!(meta.get("sha256").is_none(), "sha256 must be filtered");
1110        assert!(meta.get("blake3").is_none(), "blake3 must be filtered");
1111    }
1112
1113    #[test]
1114    fn test_metadata_json_is_valid_json_string() {
1115        let mut registry = ArtifactRegistry::new();
1116        registry.add(Artifact {
1117            kind: ArtifactKind::Binary,
1118            name: String::new(),
1119            path: PathBuf::from("dist/myapp"),
1120            target: Some("x86_64-unknown-linux-gnu".to_string()),
1121            crate_name: "myapp".to_string(),
1122            metadata: Default::default(),
1123            size: None,
1124        });
1125
1126        let json = registry.to_artifacts_json().unwrap();
1127        let serialized = serde_json::to_string_pretty(&json).unwrap();
1128        // Should be parseable back
1129        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
1130        assert_eq!(parsed, json);
1131    }
1132
1133    #[test]
1134    fn test_format_size_bytes() {
1135        assert_eq!(format_size(0), "0 B");
1136        assert_eq!(format_size(512), "512 B");
1137        assert_eq!(format_size(1023), "1023 B");
1138    }
1139
1140    #[test]
1141    fn test_format_size_kilobytes() {
1142        assert_eq!(format_size(1024), "1.0 KB");
1143        assert_eq!(format_size(1536), "1.5 KB");
1144        assert_eq!(format_size(10240), "10.0 KB");
1145    }
1146
1147    #[test]
1148    fn test_format_size_megabytes() {
1149        assert_eq!(format_size(1048576), "1.0 MB");
1150        assert_eq!(format_size(4404019), "4.2 MB");
1151    }
1152
1153    #[test]
1154    fn test_format_size_gigabytes() {
1155        assert_eq!(format_size(1073741824), "1.0 GB");
1156        assert_eq!(format_size(2147483648), "2.0 GB");
1157    }
1158
1159    #[test]
1160    fn test_artifact_kind_serializes_to_snake_case() {
1161        let json = serde_json::to_value(ArtifactKind::DockerImage).unwrap();
1162        assert_eq!(json, "docker_image");
1163        let json = serde_json::to_value(ArtifactKind::LinuxPackage).unwrap();
1164        assert_eq!(json, "linux_package");
1165        let json = serde_json::to_value(ArtifactKind::Binary).unwrap();
1166        assert_eq!(json, "binary");
1167    }
1168
1169    #[test]
1170    fn test_artifact_kind_new_variants_serialize() {
1171        assert_eq!(
1172            serde_json::to_value(ArtifactKind::UploadableBinary).unwrap(),
1173            "uploadable_binary"
1174        );
1175        assert_eq!(
1176            serde_json::to_value(ArtifactKind::UniversalBinary).unwrap(),
1177            "universal_binary"
1178        );
1179        assert_eq!(
1180            serde_json::to_value(ArtifactKind::Header).unwrap(),
1181            "header"
1182        );
1183        assert_eq!(
1184            serde_json::to_value(ArtifactKind::CArchive).unwrap(),
1185            "c_archive"
1186        );
1187        assert_eq!(
1188            serde_json::to_value(ArtifactKind::CShared).unwrap(),
1189            "c_shared"
1190        );
1191        assert_eq!(
1192            serde_json::to_value(ArtifactKind::Makeself).unwrap(),
1193            "makeself"
1194        );
1195        assert_eq!(
1196            serde_json::to_value(ArtifactKind::DockerImageV2).unwrap(),
1197            "docker_image_v2"
1198        );
1199        assert_eq!(
1200            serde_json::to_value(ArtifactKind::PublishableDockerImage).unwrap(),
1201            "publishable_docker_image"
1202        );
1203        assert_eq!(
1204            serde_json::to_value(ArtifactKind::PublishableSnapcraft).unwrap(),
1205            "publishable_snapcraft"
1206        );
1207        assert_eq!(
1208            serde_json::to_value(ArtifactKind::SourceRpm).unwrap(),
1209            "source_rpm"
1210        );
1211        assert_eq!(
1212            serde_json::to_value(ArtifactKind::BrewFormula).unwrap(),
1213            "brew_formula"
1214        );
1215        assert_eq!(
1216            serde_json::to_value(ArtifactKind::BrewCask).unwrap(),
1217            "brew_cask"
1218        );
1219        assert_eq!(
1220            serde_json::to_value(ArtifactKind::Nixpkg).unwrap(),
1221            "nixpkg"
1222        );
1223        assert_eq!(
1224            serde_json::to_value(ArtifactKind::ScoopManifest).unwrap(),
1225            "scoop_manifest"
1226        );
1227        assert_eq!(
1228            serde_json::to_value(ArtifactKind::PublishableChocolatey).unwrap(),
1229            "publishable_chocolatey"
1230        );
1231        assert_eq!(
1232            serde_json::to_value(ArtifactKind::WingetInstaller).unwrap(),
1233            "winget_installer"
1234        );
1235        assert_eq!(
1236            serde_json::to_value(ArtifactKind::WingetDefaultLocale).unwrap(),
1237            "winget_default_locale"
1238        );
1239        assert_eq!(
1240            serde_json::to_value(ArtifactKind::WingetVersion).unwrap(),
1241            "winget_version"
1242        );
1243        assert_eq!(
1244            serde_json::to_value(ArtifactKind::PkgBuild).unwrap(),
1245            "pkg_build"
1246        );
1247        assert_eq!(
1248            serde_json::to_value(ArtifactKind::SrcInfo).unwrap(),
1249            "src_info"
1250        );
1251        assert_eq!(
1252            serde_json::to_value(ArtifactKind::SourcePkgBuild).unwrap(),
1253            "source_pkg_build"
1254        );
1255        assert_eq!(
1256            serde_json::to_value(ArtifactKind::SourceSrcInfo).unwrap(),
1257            "source_src_info"
1258        );
1259        assert_eq!(
1260            serde_json::to_value(ArtifactKind::KrewPluginManifest).unwrap(),
1261            "krew_plugin_manifest"
1262        );
1263        assert_eq!(
1264            serde_json::to_value(ArtifactKind::UploadableFile).unwrap(),
1265            "uploadable_file"
1266        );
1267    }
1268
1269    #[test]
1270    fn test_artifact_kind_library_and_wasm() {
1271        let json = serde_json::to_value(ArtifactKind::Library).unwrap();
1272        assert_eq!(json, "library");
1273        let json = serde_json::to_value(ArtifactKind::Wasm).unwrap();
1274        assert_eq!(json, "wasm");
1275    }
1276
1277    #[test]
1278    fn test_artifact_kind_as_str_library_wasm() {
1279        assert_eq!(ArtifactKind::Library.as_str(), "library");
1280        assert_eq!(ArtifactKind::Wasm.as_str(), "wasm");
1281    }
1282
1283    #[test]
1284    fn test_artifact_kind_parse_roundtrip_all_variants() {
1285        let all_variants = [
1286            ArtifactKind::Binary,
1287            ArtifactKind::UploadableBinary,
1288            ArtifactKind::UniversalBinary,
1289            ArtifactKind::Library,
1290            ArtifactKind::Header,
1291            ArtifactKind::CArchive,
1292            ArtifactKind::CShared,
1293            ArtifactKind::Wasm,
1294            ArtifactKind::Archive,
1295            ArtifactKind::SourceArchive,
1296            ArtifactKind::Makeself,
1297            ArtifactKind::LinuxPackage,
1298            ArtifactKind::Snap,
1299            ArtifactKind::PublishableSnapcraft,
1300            ArtifactKind::Flatpak,
1301            ArtifactKind::SourceRpm,
1302            ArtifactKind::DiskImage,
1303            ArtifactKind::Installer,
1304            ArtifactKind::MacOsPackage,
1305            ArtifactKind::DockerImage,
1306            ArtifactKind::DockerImageV2,
1307            ArtifactKind::PublishableDockerImage,
1308            ArtifactKind::DockerManifest,
1309            ArtifactKind::BrewFormula,
1310            ArtifactKind::BrewCask,
1311            ArtifactKind::Nixpkg,
1312            ArtifactKind::ScoopManifest,
1313            ArtifactKind::PublishableChocolatey,
1314            ArtifactKind::WingetInstaller,
1315            ArtifactKind::WingetDefaultLocale,
1316            ArtifactKind::WingetVersion,
1317            ArtifactKind::PkgBuild,
1318            ArtifactKind::SrcInfo,
1319            ArtifactKind::SourcePkgBuild,
1320            ArtifactKind::SourceSrcInfo,
1321            ArtifactKind::KrewPluginManifest,
1322            ArtifactKind::Checksum,
1323            ArtifactKind::Signature,
1324            ArtifactKind::Certificate,
1325            ArtifactKind::Sbom,
1326            ArtifactKind::Metadata,
1327            ArtifactKind::UploadableFile,
1328        ];
1329        for variant in &all_variants {
1330            let s = variant.as_str();
1331            let parsed =
1332                ArtifactKind::parse(s).unwrap_or_else(|| panic!("parse({:?}) returned None", s));
1333            assert_eq!(*variant, parsed, "roundtrip failed for {:?}", s);
1334        }
1335        assert_eq!(all_variants.len(), 42, "update test when adding variants");
1336    }
1337
1338    #[test]
1339    fn test_query_by_library_and_wasm_kinds() {
1340        let mut registry = ArtifactRegistry::new();
1341        registry.add(Artifact {
1342            kind: ArtifactKind::Library,
1343            name: String::new(),
1344            path: PathBuf::from("target/libmylib.so"),
1345            target: Some("x86_64-unknown-linux-gnu".to_string()),
1346            crate_name: "mylib".to_string(),
1347            metadata: Default::default(),
1348            size: None,
1349        });
1350        registry.add(Artifact {
1351            kind: ArtifactKind::Wasm,
1352            name: String::new(),
1353            path: PathBuf::from("target/mylib.wasm"),
1354            target: Some("wasm32-unknown-unknown".to_string()),
1355            crate_name: "mylib".to_string(),
1356            metadata: Default::default(),
1357            size: None,
1358        });
1359
1360        assert_eq!(registry.by_kind(ArtifactKind::Library).len(), 1);
1361        assert_eq!(registry.by_kind(ArtifactKind::Wasm).len(), 1);
1362        assert_eq!(
1363            registry
1364                .by_kind_and_crate(ArtifactKind::Wasm, "mylib")
1365                .len(),
1366            1
1367        );
1368    }
1369
1370    #[test]
1371    fn test_size_reportable_kinds_includes_releasable_and_binaries() {
1372        let kinds = size_reportable_kinds();
1373        // Uploadable types
1374        assert!(kinds.contains(&ArtifactKind::Archive));
1375        assert!(kinds.contains(&ArtifactKind::SourceArchive));
1376        assert!(kinds.contains(&ArtifactKind::UploadableFile));
1377        assert!(kinds.contains(&ArtifactKind::Makeself));
1378        assert!(kinds.contains(&ArtifactKind::LinuxPackage));
1379        assert!(kinds.contains(&ArtifactKind::Flatpak));
1380        assert!(kinds.contains(&ArtifactKind::SourceRpm));
1381        assert!(kinds.contains(&ArtifactKind::Sbom));
1382        assert!(kinds.contains(&ArtifactKind::Checksum));
1383        assert!(kinds.contains(&ArtifactKind::Signature));
1384        assert!(kinds.contains(&ArtifactKind::Certificate));
1385        assert!(kinds.contains(&ArtifactKind::DiskImage));
1386        assert!(kinds.contains(&ArtifactKind::Installer));
1387        assert!(kinds.contains(&ArtifactKind::MacOsPackage));
1388        assert!(kinds.contains(&ArtifactKind::Snap));
1389        // Build outputs
1390        assert!(kinds.contains(&ArtifactKind::Binary));
1391        assert!(kinds.contains(&ArtifactKind::UniversalBinary));
1392        assert!(kinds.contains(&ArtifactKind::Library));
1393        assert!(kinds.contains(&ArtifactKind::Header));
1394        assert!(kinds.contains(&ArtifactKind::CArchive));
1395        assert!(kinds.contains(&ArtifactKind::CShared));
1396        assert!(kinds.contains(&ArtifactKind::Wasm));
1397    }
1398
1399    #[test]
1400    fn test_size_reportable_kinds_excludes_non_releasable() {
1401        let kinds = size_reportable_kinds();
1402        assert!(!kinds.contains(&ArtifactKind::DockerImage));
1403        assert!(!kinds.contains(&ArtifactKind::DockerManifest));
1404        assert!(!kinds.contains(&ArtifactKind::Metadata));
1405        assert!(!kinds.contains(&ArtifactKind::BrewFormula));
1406        assert!(!kinds.contains(&ArtifactKind::ScoopManifest));
1407    }
1408
1409    #[test]
1410    fn test_print_size_report_filters_and_stores_size() {
1411        use std::io::Write;
1412
1413        let dir = std::env::temp_dir().join("anodizer_test_size_report");
1414        let _ = std::fs::remove_dir_all(&dir);
1415        std::fs::create_dir_all(&dir).unwrap();
1416
1417        // Create real files with known sizes
1418        let archive_path = dir.join("app.tar.gz");
1419        let mut f = std::fs::File::create(&archive_path).unwrap();
1420        f.write_all(&[0u8; 2048]).unwrap();
1421
1422        let binary_path = dir.join("app");
1423        let mut f = std::fs::File::create(&binary_path).unwrap();
1424        f.write_all(&[0u8; 4096]).unwrap();
1425
1426        let docker_path = dir.join("docker-image");
1427        let mut f = std::fs::File::create(&docker_path).unwrap();
1428        f.write_all(&[0u8; 8192]).unwrap();
1429
1430        let mut registry = ArtifactRegistry::new();
1431        registry.add(Artifact {
1432            kind: ArtifactKind::Archive,
1433            name: String::new(),
1434            path: archive_path.clone(),
1435            target: None,
1436            crate_name: "app".to_string(),
1437            metadata: Default::default(),
1438            size: None,
1439        });
1440        registry.add(Artifact {
1441            kind: ArtifactKind::Binary,
1442            name: String::new(),
1443            path: binary_path.clone(),
1444            target: None,
1445            crate_name: "app".to_string(),
1446            metadata: Default::default(),
1447            size: None,
1448        });
1449        // DockerImage should be excluded from size reporting
1450        registry.add(Artifact {
1451            kind: ArtifactKind::DockerImage,
1452            name: String::new(),
1453            path: docker_path.clone(),
1454            target: None,
1455            crate_name: "app".to_string(),
1456            metadata: Default::default(),
1457            size: None,
1458        });
1459
1460        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
1461        print_size_report(&mut registry, &log);
1462
1463        // Archive and Binary should have size populated
1464        let archive = &registry.all()[0];
1465        assert_eq!(archive.kind, ArtifactKind::Archive);
1466        assert_eq!(archive.size, Some(2048));
1467
1468        let binary = &registry.all()[1];
1469        assert_eq!(binary.kind, ArtifactKind::Binary);
1470        assert_eq!(binary.size, Some(4096));
1471
1472        // DockerImage should NOT have size populated
1473        let docker = &registry.all()[2];
1474        assert_eq!(docker.kind, ArtifactKind::DockerImage);
1475        assert_eq!(docker.size, None);
1476
1477        let _ = std::fs::remove_dir_all(&dir);
1478    }
1479
1480    #[test]
1481    fn test_size_field_defaults_to_none() {
1482        let registry = ArtifactRegistry::new();
1483        // Artifact's size is None when freshly constructed
1484        let mut reg = ArtifactRegistry::new();
1485        reg.add(Artifact {
1486            kind: ArtifactKind::Binary,
1487            name: String::new(),
1488            path: PathBuf::from("/nonexistent/binary"),
1489            target: None,
1490            crate_name: "test".to_string(),
1491            metadata: Default::default(),
1492            size: None,
1493        });
1494        assert_eq!(reg.all()[0].size, None);
1495        drop(registry);
1496    }
1497
1498    #[test]
1499    fn test_size_field_not_serialized_when_none() {
1500        let mut registry = ArtifactRegistry::new();
1501        registry.add(Artifact {
1502            kind: ArtifactKind::Binary,
1503            name: String::new(),
1504            path: PathBuf::from("dist/myapp"),
1505            target: None,
1506            crate_name: "myapp".to_string(),
1507            metadata: Default::default(),
1508            size: None,
1509        });
1510        let json = registry.to_artifacts_json().unwrap();
1511        let first = &json.as_array().unwrap()[0];
1512        // size should not appear in JSON when None
1513        assert!(first.get("size").is_none());
1514    }
1515
1516    #[test]
1517    fn test_size_field_serialized_when_some() {
1518        let mut registry = ArtifactRegistry::new();
1519        registry.add(Artifact {
1520            kind: ArtifactKind::Binary,
1521            name: String::new(),
1522            path: PathBuf::from("dist/myapp"),
1523            target: None,
1524            crate_name: "myapp".to_string(),
1525            metadata: Default::default(),
1526            size: Some(12345),
1527        });
1528        let json = registry.to_artifacts_json().unwrap();
1529        let first = &json.as_array().unwrap()[0];
1530        assert_eq!(first["size"], 12345);
1531    }
1532
1533    #[test]
1534    fn release_uploadable_kinds_matches_canonical_set() {
1535        // Pins the cross-linked artifact set used by stage-checksum,
1536        // stage-release upload, blob storage, and stage-sign "all" filter.
1537        // Mirrors GoReleaser's `artifact.ReleaseUploadableTypes()` plus the
1538        // four installer kinds anodizer ships as OSS:
1539        //   - Installer       <- GR Pro: MSI / NSIS
1540        //   - DiskImage       <- GR Pro: DMG
1541        //   - MacOsPackage    <- GR Pro: PKG
1542        // A regression that drops any of these silently breaks downstream
1543        // upload/checksum/sign behavior.
1544        let kinds = release_uploadable_kinds();
1545        let expected = [
1546            ArtifactKind::Archive,
1547            ArtifactKind::UploadableBinary,
1548            ArtifactKind::UploadableFile,
1549            ArtifactKind::SourceArchive,
1550            ArtifactKind::Makeself,
1551            ArtifactKind::LinuxPackage,
1552            ArtifactKind::Flatpak,
1553            ArtifactKind::SourceRpm,
1554            ArtifactKind::Installer,
1555            ArtifactKind::DiskImage,
1556            ArtifactKind::MacOsPackage,
1557            ArtifactKind::Sbom,
1558            ArtifactKind::Checksum,
1559            ArtifactKind::Signature,
1560            ArtifactKind::Certificate,
1561        ];
1562        assert_eq!(kinds, &expected);
1563    }
1564
1565    #[test]
1566    fn artifact_ext_prefers_metadata_when_present() {
1567        // GoReleaser parity: `Artifact.Ext()` reads `ExtraExt` from extras
1568        // (`internal/artifact/artifact.go:442`), not the filename. An SRPM
1569        // artifact registers `metadata["ext"] = ".src.rpm"` so downstream
1570        // `{{ .ArtifactExt }}` resolves to `.src.rpm`, not the
1571        // last-dot-suffix `.rpm` the filename would produce.
1572        let mut metadata = HashMap::new();
1573        metadata.insert("ext".to_string(), ".src.rpm".to_string());
1574        let art = Artifact {
1575            kind: ArtifactKind::SourceRpm,
1576            name: "myapp-1.0.0-1.fc42.src.rpm".to_string(),
1577            path: PathBuf::from("dist/myapp-1.0.0-1.fc42.src.rpm"),
1578            target: None,
1579            crate_name: "myapp".to_string(),
1580            metadata,
1581            size: None,
1582        };
1583        assert_eq!(art.ext(), ".src.rpm");
1584    }
1585
1586    #[test]
1587    fn artifact_ext_falls_back_to_filename_when_metadata_missing() {
1588        let art = Artifact {
1589            kind: ArtifactKind::Archive,
1590            name: "myapp-1.0.0-linux-amd64.tar.gz".to_string(),
1591            path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
1592            target: Some("x86_64-unknown-linux-gnu".to_string()),
1593            crate_name: "myapp".to_string(),
1594            metadata: HashMap::new(),
1595            size: None,
1596        };
1597        assert_eq!(art.ext(), ".tar.gz");
1598    }
1599
1600    #[test]
1601    fn artifact_ext_falls_back_when_metadata_ext_is_empty() {
1602        let mut metadata = HashMap::new();
1603        metadata.insert("ext".to_string(), String::new());
1604        let art = Artifact {
1605            kind: ArtifactKind::Archive,
1606            name: "myapp.zip".to_string(),
1607            path: PathBuf::from("dist/myapp.zip"),
1608            target: None,
1609            crate_name: "myapp".to_string(),
1610            metadata,
1611            size: None,
1612        };
1613        assert_eq!(art.ext(), ".zip");
1614    }
1615
1616    #[test]
1617    fn release_uploadable_kinds_excludes_snap_store_and_raw_build_outputs() {
1618        // Negative pin: snap-store-bound kinds and raw build outputs must
1619        // never appear in the release-upload set. Snap files are pushed to
1620        // the snap store (not GitHub releases); raw Binary / UniversalBinary
1621        // are wrapped as UploadableBinary or bundled into Archive before
1622        // upload. A regression that adds any of these would put files in
1623        // checksums.txt that aren't in the GitHub release.
1624        let kinds = release_uploadable_kinds();
1625        for excluded in [
1626            ArtifactKind::Snap,
1627            ArtifactKind::PublishableSnapcraft,
1628            ArtifactKind::Binary,
1629            ArtifactKind::UniversalBinary,
1630        ] {
1631            assert!(
1632                !kinds.contains(&excluded),
1633                "{:?} must not be in release_uploadable_kinds()",
1634                excluded
1635            );
1636        }
1637    }
1638}