Skip to main content

anodizer_core/config/
archives.rs

1use std::collections::HashMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Deserializer, Serialize};
5
6use super::{
7    ArchiveHooksConfig, SignConfig, StringOrBool, StringOrU32, deserialize_string_or_bool_opt,
8};
9
10// ---------------------------------------------------------------------------
11// ArchivesConfig — untagged enum: false => Disabled, array => Configs
12// ---------------------------------------------------------------------------
13
14#[derive(Debug, Clone, JsonSchema)]
15pub enum ArchivesConfig {
16    Disabled,
17    Configs(Vec<ArchiveConfig>),
18}
19
20impl Serialize for ArchivesConfig {
21    fn serialize<S: serde::Serializer>(
22        &self,
23        serializer: S,
24    ) -> std::result::Result<S::Ok, S::Error> {
25        match self {
26            ArchivesConfig::Disabled => serializer.serialize_bool(false),
27            ArchivesConfig::Configs(configs) => configs.serialize(serializer),
28        }
29    }
30}
31
32impl Default for ArchivesConfig {
33    fn default() -> Self {
34        ArchivesConfig::Configs(vec![])
35    }
36}
37
38/// Custom deserializer for ArchivesConfig.
39/// Accepts:
40///   - boolean `false`  → Disabled
41///   - array            → Configs(...)
42///   - missing/null     → Configs([])  (via serde default)
43pub(super) fn deserialize_archives_config<'de, D>(
44    deserializer: D,
45) -> Result<ArchivesConfig, D::Error>
46where
47    D: Deserializer<'de>,
48{
49    use serde::de::{self, Visitor};
50
51    struct ArchivesVisitor;
52
53    impl<'de> Visitor<'de> for ArchivesVisitor {
54        type Value = ArchivesConfig;
55
56        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57            f.write_str("false or a list of archive configs")
58        }
59
60        fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
61            if !v {
62                Ok(ArchivesConfig::Disabled)
63            } else {
64                Err(E::custom(
65                    "archives: true is not valid; use false or a list",
66                ))
67            }
68        }
69
70        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
71            let mut configs = Vec::new();
72            while let Some(item) = seq.next_element::<ArchiveConfig>()? {
73                configs.push(item);
74            }
75            Ok(ArchivesConfig::Configs(configs))
76        }
77
78        // Handle YAML null / missing when serde calls the deserializer explicitly.
79        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
80            Ok(ArchivesConfig::Configs(vec![]))
81        }
82
83        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
84            Ok(ArchivesConfig::Configs(vec![]))
85        }
86    }
87
88    deserializer.deserialize_any(ArchivesVisitor)
89}
90
91/// Custom deserializer for the `signs` / `sign` field.
92/// Accepts:
93///   - null/missing → empty vec (via serde default)
94///   - a single object → vec of one SignConfig
95///   - an array → vec of SignConfig
96pub(super) fn deserialize_signs<'de, D>(deserializer: D) -> Result<Vec<SignConfig>, D::Error>
97where
98    D: Deserializer<'de>,
99{
100    use serde::de::{self, Visitor};
101
102    struct SignsVisitor;
103
104    impl<'de> Visitor<'de> for SignsVisitor {
105        type Value = Vec<SignConfig>;
106
107        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108            f.write_str("a sign config object or an array of sign config objects")
109        }
110
111        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
112            let mut configs = Vec::new();
113            while let Some(item) = seq.next_element::<SignConfig>()? {
114                configs.push(item);
115            }
116            Ok(configs)
117        }
118
119        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
120            let config = SignConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
121            Ok(vec![config])
122        }
123
124        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
125            Ok(Vec::new())
126        }
127
128        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
129            Ok(Vec::new())
130        }
131    }
132
133    deserializer.deserialize_any(SignsVisitor)
134}
135
136// `binary_signs[].artifacts` is constrained at deserialize time (not as a
137// serde-typed enum) because `SignConfig` is shared with the top-level `signs:`
138// field, which legitimately accepts a wider set (`all`, `archive`, `binary`,
139// `checksum`, `package`, `sbom`, `none`). Promoting `artifacts` to an enum
140// would either narrow that surface or require a parallel `BinarySignConfig`
141// type duplicating every `SignConfig` field — the runtime check below keeps
142// `SignConfig` a single shared shape while still rejecting misconfigured
143// `binary_signs` entries at config-load time.
144//
145// The JSON schema for `binary_signs[]` therefore inherits `SignConfig`'s
146// unconstrained `artifacts: Option<String>` — the constraint lives in the
147// custom deserializer below and is exercised by the parse-time tests
148// `test_binary_signs_artifacts_*` further down this file.
149
150/// Wraps [`deserialize_signs`] and enforces that each entry's `artifacts`
151/// is one of the binary-only allowed values (`binary`, `none`, or omitted).
152/// Catches misconfiguration at load time instead of producing a silent
153/// no-op signing pipe.
154pub(super) fn deserialize_binary_signs<'de, D>(deserializer: D) -> Result<Vec<SignConfig>, D::Error>
155where
156    D: Deserializer<'de>,
157{
158    let configs = deserialize_signs(deserializer)?;
159    for (idx, cfg) in configs.iter().enumerate() {
160        if let Some(art) = cfg.artifacts.as_deref()
161            && art != "binary"
162            && art != "none"
163        {
164            return Err(serde::de::Error::custom(format!(
165                "binary_signs[{idx}].artifacts: '{art}' is not allowed; \
166                 binary_signs accepts only 'binary' or 'none' (use top-level \
167                 `signs:` for broader artifact filters)"
168            )));
169        }
170    }
171    Ok(configs)
172}
173
174// ---------------------------------------------------------------------------
175// WrapInDirectory – accepts bool (true = default dir name) or string
176// ---------------------------------------------------------------------------
177
178#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
179#[serde(untagged)]
180pub enum WrapInDirectory {
181    Bool(bool),
182    Name(String),
183}
184
185impl<'de> serde::Deserialize<'de> for WrapInDirectory {
186    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
187        let value = serde_yaml_ng::Value::deserialize(deserializer)?;
188        match value {
189            serde_yaml_ng::Value::Bool(b) => Ok(WrapInDirectory::Bool(b)),
190            serde_yaml_ng::Value::String(s) => Ok(WrapInDirectory::Name(s)),
191            _ => Err(serde::de::Error::custom("expected bool or string")),
192        }
193    }
194}
195
196impl WrapInDirectory {
197    /// Resolve the directory name to wrap archive contents in.
198    ///
199    /// When `true`, uses `default_name` (typically the archive stem).
200    /// When `false` or an empty string, returns `None` (no wrapping).
201    /// Otherwise returns the custom name.
202    pub fn directory_name(&self, default_name: &str) -> Option<String> {
203        match self {
204            WrapInDirectory::Bool(true) => Some(default_name.to_string()),
205            WrapInDirectory::Bool(false) => None,
206            WrapInDirectory::Name(s) if s.is_empty() => None,
207            WrapInDirectory::Name(s) => Some(s.clone()),
208        }
209    }
210}
211
212// ---------------------------------------------------------------------------
213// ArchiveConfig
214// ---------------------------------------------------------------------------
215
216#[derive(Debug, Clone, Serialize, Default, JsonSchema)]
217#[serde(deny_unknown_fields)]
218pub struct ArchiveConfig {
219    /// Unique identifier for cross-referencing this archive from other configs.
220    /// Defaults to `"default"` so a parse->serialise->reparse round-trip is
221    /// stable (stored verbatim, not as an Option).
222    pub id: Option<String>,
223    /// Archive filename template (supports templates, e.g., "{{ ProjectName }}_{{ Version }}_{{ Os }}_{{ Arch }}").
224    pub name_template: Option<String>,
225    /// Archive formats: tar.gz, tar.xz, tar.zst, tar, zip, gz, xz, or binary.
226    /// `gz` and `xz` are single-file compressors — supplying multiple input
227    /// files errors. Plural list; one archive per format is produced for each
228    /// target.
229    pub formats: Option<Vec<String>>,
230    /// Per-OS format overrides for this archive config.
231    pub format_overrides: Option<Vec<FormatOverride>>,
232    /// Extra files to include in the archive (glob patterns or detailed src/dst specs).
233    pub files: Option<Vec<ArchiveFileSpec>>,
234    /// Binary names to include (defaults to all binaries from matched builds).
235    pub binaries: Option<Vec<String>>,
236    /// When set, wrap archive contents in a top-level directory.
237    /// Accepts `true` (use archive stem as directory name), `false` (no wrapping),
238    /// or a string template for a custom directory name.
239    pub wrap_in_directory: Option<WrapInDirectory>,
240    /// Build IDs filter: only include artifacts from builds whose `id` is in this list.
241    pub ids: Option<Vec<String>>,
242    /// When true, create archive with no binaries (metadata-only).
243    pub meta: Option<bool>,
244    /// File permissions applied to binaries in archives.
245    pub builds_info: Option<ArchiveFileInfo>,
246    /// Strip binary parent directory in archive (place binaries at archive root).
247    pub strip_binary_directory: Option<bool>,
248    /// Allow different binary counts across targets. Default false (warn on mismatch).
249    pub allow_different_binary_count: Option<bool>,
250    /// Pre/post archive hooks (`before`/`after`).
251    pub hooks: Option<ArchiveHooksConfig>,
252    /// Templated files scoped to this archive entry. Rendered per-archive
253    /// (so each entry's `dst:` and contents see `.Os`, `.Arch`, `.Target`,
254    /// `.Format`, etc.) and packed into the archive at the rendered `dst:`
255    /// path. The `archives[].templated_files:` field.
256    pub templated_files: Option<Vec<super::TemplateFileConfig>>,
257    /// Template-conditional gate: when the rendered result is falsy
258    /// (`"false"` / `"0"` / `"no"` / empty), the archive entry is skipped
259    /// entirely (no archives produced for this `id`). Render failure
260    /// hard-errors. "Filter artifacts with `if` statements" is listed as a
261    /// blanket promise — anodizer surfaces it explicitly to keep imported
262    /// configs portable).
263    /// An absent, empty or blank `if:` imposes no gate and always runs; the
264    /// falsy test applies to what a non-blank gate renders.
265    #[serde(rename = "if")]
266    pub if_condition: Option<String>,
267    /// Turnkey shell-completion generation: auto-generate (or harvest, or
268    /// copy) completion files and bundle them into every archive produced by
269    /// this entry. See `CompletionsConfig` for the three generation modes.
270    pub completions: Option<super::CompletionsConfig>,
271    /// Turnkey man-page generation: auto-generate (or harvest, or copy) man
272    /// pages and bundle them into every archive produced by this entry. See
273    /// `ManpagesConfig` for the three generation modes.
274    pub manpages: Option<super::ManpagesConfig>,
275}
276
277/// Fold a deprecated singular `format: tar.gz` into the canonical
278/// `formats: [tar.gz]` list, emitting a `tracing::warn!` deprecation notice
279/// keyed by `context_label` (the archive id or override `os=` so the user
280/// can locate the offending entry). Returns the folded list (creating one
281/// if `formats` was `None` and `legacy` is `Some`).
282///
283/// Shared by `ArchiveConfig` and `FormatOverride` to keep the deprecation
284/// message + fold semantics in one place.
285fn fold_format_into_formats(
286    context_label: &str,
287    context_kind: &str,
288    formats: Option<Vec<String>>,
289    legacy: Option<String>,
290) -> Option<Vec<String>> {
291    let mut formats = formats;
292    if let Some(legacy) = legacy {
293        tracing::warn!(
294            "DEPRECATION: {}[{}]: 'format: {}' is deprecated; \
295             use 'formats: [{}]' instead.",
296            context_kind,
297            context_label,
298            legacy,
299            legacy
300        );
301        formats.get_or_insert_with(Vec::new).push(legacy);
302    }
303    formats
304}
305
306// Custom Deserialize that accepts deprecated aliases:
307// - `format: tar.gz` (singular String) folded into `formats: [tar.gz]`
308//
309// - `builds: [foo]` folded into `ids: [foo]`
310//
311// Each alias hit emits a `tracing::warn!` deprecation notice.
312impl<'de> Deserialize<'de> for ArchiveConfig {
313    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
314    where
315        D: Deserializer<'de>,
316    {
317        #[derive(Deserialize, Default)]
318        #[serde(default, deny_unknown_fields)]
319        struct Raw {
320            id: Option<String>,
321            name_template: Option<String>,
322            formats: Option<Vec<String>>,
323            format: Option<String>,
324            format_overrides: Option<Vec<FormatOverride>>,
325            files: Option<Vec<ArchiveFileSpec>>,
326            binaries: Option<Vec<String>>,
327            wrap_in_directory: Option<WrapInDirectory>,
328            ids: Option<Vec<String>>,
329            builds: Option<Vec<String>>,
330            meta: Option<bool>,
331            builds_info: Option<ArchiveFileInfo>,
332            strip_binary_directory: Option<bool>,
333            allow_different_binary_count: Option<bool>,
334            hooks: Option<ArchiveHooksConfig>,
335            templated_files: Option<Vec<super::TemplateFileConfig>>,
336            #[serde(rename = "if")]
337            if_condition: Option<String>,
338            completions: Option<super::CompletionsConfig>,
339            manpages: Option<super::ManpagesConfig>,
340        }
341
342        let raw = Raw::deserialize(deserializer)?;
343
344        let id_label = raw.id.clone().unwrap_or_else(|| "default".to_string());
345        let formats = fold_format_into_formats(
346            &format!("id={}", id_label),
347            "archives",
348            raw.formats,
349            raw.format,
350        );
351        let mut ids = raw.ids;
352        if let Some(legacy) = raw.builds {
353            tracing::warn!(
354                "DEPRECATION: archives[id={}]: 'builds: {:?}' is deprecated; \
355                 use 'ids: [...]' instead.",
356                id_label,
357                legacy
358            );
359            let target = ids.get_or_insert_with(Vec::new);
360            target.extend(legacy);
361        }
362
363        Ok(ArchiveConfig {
364            id: raw.id.or_else(|| Some("default".to_string())),
365            name_template: raw.name_template,
366            formats,
367            format_overrides: raw.format_overrides,
368            files: raw.files,
369            binaries: raw.binaries,
370            wrap_in_directory: raw.wrap_in_directory,
371            ids,
372            meta: raw.meta,
373            builds_info: raw.builds_info,
374            strip_binary_directory: raw.strip_binary_directory,
375            allow_different_binary_count: raw.allow_different_binary_count,
376            hooks: raw.hooks,
377            templated_files: raw.templated_files,
378            if_condition: raw.if_condition,
379            completions: raw.completions,
380            manpages: raw.manpages,
381        })
382    }
383}
384
385#[derive(Debug, Clone, Serialize, JsonSchema)]
386#[serde(deny_unknown_fields)]
387pub struct FormatOverride {
388    /// Operating system this override applies to (e.g., "windows", "darwin", "linux").
389    pub os: String,
390    /// Plural format overrides for this OS: tar.gz, tar.xz, tar.zst, tar, zip,
391    /// gz, xz, or binary.
392    pub formats: Option<Vec<String>>,
393}
394
395// Custom Deserialize that accepts both `formats: [tar.gz]` (canonical) and
396// the deprecated singular `format: tar.gz`. The legacy spelling is folded
397// into `formats` at parse time via the shared `fold_format_into_formats`
398// helper, which also emits the deprecation warning.
399impl<'de> Deserialize<'de> for FormatOverride {
400    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
401    where
402        D: Deserializer<'de>,
403    {
404        #[derive(Deserialize, Default)]
405        #[serde(default, deny_unknown_fields)]
406        struct Raw {
407            os: String,
408            formats: Option<Vec<String>>,
409            format: Option<String>,
410        }
411        let raw = Raw::deserialize(deserializer)?;
412        let formats = fold_format_into_formats(
413            &format!("os={}", raw.os),
414            "archives.format_overrides",
415            raw.formats,
416            raw.format,
417        );
418        Ok(FormatOverride {
419            os: raw.os,
420            formats,
421        })
422    }
423}
424
425/// Specifies a file to include in archives. Can be a simple glob string or a
426/// detailed object with src/dst/info fields for controlling archive placement
427/// and file metadata.
428///
429/// NOTE: This is intentionally a separate type from [`ExtraFileSpec`] (used for
430/// checksum/release extra_files). `ArchiveFileSpec` needs `src`/`dst`/`info`
431/// fields for archive placement and file metadata (owner, group, mode, mtime),
432/// while `ExtraFileSpec` needs `glob`/`name_template` for checksumming and
433/// upload renaming. The fields and semantics are different enough that a unified
434/// type would be confusing.
435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
436#[serde(untagged)]
437pub enum ArchiveFileSpec {
438    Glob(String),
439    Detailed {
440        src: String,
441        dst: Option<String>,
442        info: Option<ArchiveFileInfo>,
443        /// When true, strip the parent directory from the file path in the archive.
444        strip_parent: Option<bool>,
445    },
446}
447
448impl PartialEq<&str> for ArchiveFileSpec {
449    fn eq(&self, other: &&str) -> bool {
450        match self {
451            ArchiveFileSpec::Glob(s) => s.as_str() == *other,
452            _ => false,
453        }
454    }
455}
456
457/// Shared file metadata (owner, group, mode, mtime) used by both archive entries
458/// and nFPM package contents. Previously duplicated as `ArchiveFileInfo` and
459/// `NfpmFileInfo`; now unified.
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
461#[serde(default, deny_unknown_fields)]
462pub struct FileInfo {
463    /// File owner name (e.g., "root").
464    pub owner: Option<String>,
465    /// File group name (e.g., "root").
466    pub group: Option<String>,
467    /// File permission mode. Accepts a YAML int (decimal, e.g. `420` for
468    /// `0o644`) or an octal-prefixed string (`"0o644"`, `"0644"`). This
469    /// a `uint32` type for `Mode` on archive/nfpm contents
470    /// while letting users spell octal naturally in YAML.
471    pub mode: Option<StringOrU32>,
472    /// File modification time in RFC3339 format (e.g., "2024-01-01T00:00:00Z").
473    pub mtime: Option<String>,
474}
475
476/// Backward-compatible alias for archive code.
477pub type ArchiveFileInfo = FileInfo;
478
479/// Parse an octal mode string into a `u32`, handling common YAML-friendly
480/// representations: `"0755"`, `"0o755"`, `"0O755"`, `"755"`, and `"0"`.
481pub fn parse_octal_mode(s: &str) -> Option<u32> {
482    let cleaned = s
483        .strip_prefix("0o")
484        .or_else(|| s.strip_prefix("0O"))
485        .unwrap_or(s);
486    let cleaned = if cleaned.is_empty() { "0" } else { cleaned };
487    u32::from_str_radix(cleaned, 8).ok()
488}
489
490/// The set of archive format strings recognised by the archive stage.
491/// Used for early validation so typos are caught at config load time rather
492/// than mid-pipeline.
493pub const VALID_ARCHIVE_FORMATS: &[&str] = &[
494    "tar.gz",
495    "tgz",
496    "tar.xz",
497    "txz",
498    "tar.zst",
499    "tzst",
500    "tar",
501    "zip",
502    "gz",
503    "xz",
504    crate::artifact::FORMAT_BINARY,
505    "none",
506];
507
508// ---------------------------------------------------------------------------
509// ChecksumConfig
510// ---------------------------------------------------------------------------
511
512/// Specifies an extra file to include in checksums or release uploads. Can be a
513/// simple glob string or a detailed object with glob and name_template fields.
514///
515/// See [`ArchiveFileSpec`] doc comment for why this is a separate type.
516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
517#[serde(untagged)]
518pub enum ExtraFileSpec {
519    Glob(String),
520    Detailed {
521        glob: String,
522        /// Optional override for the upload filename.
523        #[serde(default)]
524        name_template: Option<String>,
525        /// When true, treat a glob that matches zero files as a no-op
526        /// rather than a hard error. Useful for assets produced only in
527        /// CI (e.g. signing public keys derived from a secret) that
528        /// must not break local snapshot/dry-run flows. Defaults to
529        /// false, matching the prior fail-fast behavior.
530        #[serde(default)]
531        allow_empty: bool,
532    },
533}
534
535impl ExtraFileSpec {
536    /// Return the glob pattern for this spec.
537    pub fn glob(&self) -> &str {
538        match self {
539            ExtraFileSpec::Glob(s) => s,
540            ExtraFileSpec::Detailed { glob, .. } => glob,
541        }
542    }
543
544    /// Return the optional name_template (only present in Detailed variant).
545    pub fn name_template(&self) -> Option<&str> {
546        match self {
547            ExtraFileSpec::Glob(_) => None,
548            ExtraFileSpec::Detailed { name_template, .. } => name_template.as_deref(),
549        }
550    }
551
552    /// Return whether this spec allows a zero-match glob without erroring
553    /// (Detailed variant only; the bare string form is always fail-fast).
554    pub fn allow_empty(&self) -> bool {
555        match self {
556            ExtraFileSpec::Glob(_) => false,
557            ExtraFileSpec::Detailed { allow_empty, .. } => *allow_empty,
558        }
559    }
560}
561
562/// A file whose contents are rendered through the template engine before use.
563/// Used by `templated_extra_files` across multiple stages.
564#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
565#[serde(default, deny_unknown_fields)]
566pub struct TemplatedExtraFile {
567    /// Source template file path.
568    pub src: String,
569    /// Destination filename for the rendered output.
570    /// Supports template variables (e.g. `"{{ ProjectName }}-NOTES.txt"`).
571    pub dst: Option<String>,
572    /// File permissions in octal notation as a string, e.g. `"0755"`.
573    /// Parsed at runtime via `parse_octal_mode()` to avoid YAML interpreting as decimal.
574    pub mode: Option<String>,
575}
576
577/// Content format for per-artifact sidecars written in `split` mode.
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
579#[serde(rename_all = "lowercase")]
580pub enum ChecksumSplitFormat {
581    /// Only the raw hex hash, no filename, no trailing newline. Matches
582    /// GoReleaser's split-checksum output. Default.
583    #[default]
584    Bare,
585    /// `<hash>  <filename>` with a trailing newline — the coreutils / BSD
586    /// digest format, so the sidecar verifies directly with
587    /// `shasum -c` / `sha256sum -c` from the directory holding the artifact.
588    Coreutils,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
592#[serde(default, deny_unknown_fields)]
593pub struct ChecksumConfig {
594    /// Checksum filename template (default: "{{ ProjectName }}_{{ Version }}_checksums.txt").
595    pub name_template: Option<String>,
596    /// Hash algorithm (default: `sha256`). Accepted values: `sha1`, `sha224`,
597    /// `sha256`, `sha384`, `sha512`, `sha3-224`, `sha3-256`, `sha3-384`,
598    /// `sha3-512`, `blake2b`, `blake2s`, `blake3`, `crc32`, `md5`. An
599    /// unrecognized value is rejected at checksum-stage entry. The authoritative
600    /// set is [`ChecksumConfig::SUPPORTED_ALGORITHMS`].
601    pub algorithm: Option<String>,
602    /// Disable checksums. Accepts bool or template string.
603    /// Accepts the legacy `disable:` spelling via serde alias for back-compat.
604    #[serde(
605        alias = "disable",
606        deserialize_with = "deserialize_string_or_bool_opt",
607        default
608    )]
609    pub skip: Option<StringOrBool>,
610    /// Extra files to include in the checksum file (beyond build artifacts).
611    pub extra_files: Option<Vec<ExtraFileSpec>>,
612    /// Extra files whose contents are rendered through the template engine before inclusion.
613    /// Unlike `extra_files` which copy as-is, template variables like `{{ Tag }}` are expanded.
614    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
615    /// Build IDs filter: only checksum artifacts from builds whose `id` is in this list.
616    pub ids: Option<Vec<String>>,
617    /// When true, produce one checksum file per artifact instead of a combined file.
618    pub split: Option<bool>,
619    /// Sidecar content format when `split: true` (default: `bare`). Set to
620    /// `coreutils` to write `<hash>  <filename>` so each sidecar verifies with
621    /// `shasum -c`. Ignored in combined mode (the combined file is always
622    /// coreutils-format).
623    pub split_format: Option<ChecksumSplitFormat>,
624}
625
626impl ChecksumConfig {
627    /// Default checksum filename template (combined mode). Mirrors
628    /// the checksums config.
629    pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ ProjectName }}_{{ Version }}_checksums.txt";
630
631    /// Default hash algorithm (`sha256`).
632    pub const DEFAULT_ALGORITHM: &'static str = "sha256";
633
634    /// The closed set of accepted [`Self::algorithm`] values. This is the
635    /// authoritative list the checksum stage's hash dispatch and
636    /// `validate_algorithm` are kept in sync with (a `stage-checksum`
637    /// drift-guard test asserts the two never diverge), so the config rustdoc
638    /// can name the full set without hand-copying a list that rots.
639    pub const SUPPORTED_ALGORITHMS: &'static [&'static str] = &[
640        "sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256", "sha3-384",
641        "sha3-512", "blake2b", "blake2s", "blake3", "crc32", "md5",
642    ];
643
644    /// Resolve the hash algorithm, falling back to the project default
645    /// when the user did not specify one. Stages MUST call this rather
646    /// than reading `self.algorithm` directly, so a future default change
647    /// (or user-facing override resolution) ends up in one place.
648    pub fn resolved_algorithm(&self) -> &str {
649        self.algorithm.as_deref().unwrap_or(Self::DEFAULT_ALGORITHM)
650    }
651
652    /// Whether split-mode (one sidecar per artifact) is requested.
653    /// Defaults to `false` (combined-file mode).
654    pub fn resolved_split(&self) -> bool {
655        self.split.unwrap_or(false)
656    }
657
658    /// Resolve the combined-mode checksum filename template, falling back
659    /// to the canonical default. Returns the raw template
660    /// string; the caller still renders it through Tera.
661    ///
662    /// Split mode constructs sidecar names per-artifact at the call site
663    /// (`<artifact>.<algo>` literal format) and intentionally does NOT
664    /// route through this accessor — that path needs no template rendering.
665    pub fn resolved_combined_name_template(&self) -> &str {
666        self.name_template
667            .as_deref()
668            .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
669    }
670
671    /// Resolve the combined-checksums `name_template` for a crate, applying the
672    /// canonical precedence — the crate's own `checksum.name_template`, then the
673    /// global `defaults.checksum.name_template`, then [`Self::DEFAULT_NAME_TEMPLATE`].
674    ///
675    /// The single source of truth shared by the checksum stage (which writes the
676    /// file) and the install-script stage (which references it in the generated
677    /// `install.sh`), so the two can never derive different names.
678    pub fn resolve_combined_name_template<'a>(
679        crate_checksum: Option<&'a ChecksumConfig>,
680        global_checksum: Option<&'a ChecksumConfig>,
681    ) -> &'a str {
682        crate_checksum
683            .and_then(|c| c.name_template.as_deref())
684            .or_else(|| global_checksum.and_then(|c| c.name_template.as_deref()))
685            .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
686    }
687}
688
689// ---------------------------------------------------------------------------
690// ContentSource — inline string, from_file, or from_url
691// ---------------------------------------------------------------------------
692
693/// A content source that can be an inline string, read from a file, or fetched
694/// from a URL. Used for release header/footer values.
695///
696/// YAML examples:
697///
698/// ```yaml
699/// header: "inline text"
700/// header:
701///   from_file: ./RELEASE_HEADER.md
702/// header:
703///   from_url: https://example.com/header.md
704/// header:
705///   from_url: https://example.com/header.md
706///   headers:
707///     X-API-Token: "{{ Env.API_TOKEN }}"
708///     Accept: "text/markdown"
709/// ```
710///
711/// Both `from_file` path and `from_url` URL are template-rendered before use.
712/// Header values are template-rendered.
713#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
714#[serde(untagged)]
715pub enum ContentSource {
716    Inline(String),
717    FromFile {
718        from_file: String,
719    },
720    FromUrl {
721        from_url: String,
722        /// Optional HTTP headers (value templates allowed). Enables private
723        /// mirrors and authenticated endpoints.
724        #[serde(default, skip_serializing_if = "Option::is_none")]
725        headers: Option<HashMap<String, String>>,
726    },
727}
728
729impl PartialEq for ContentSource {
730    fn eq(&self, other: &Self) -> bool {
731        match (self, other) {
732            (Self::Inline(a), Self::Inline(b)) => a == b,
733            (Self::FromFile { from_file: a }, Self::FromFile { from_file: b }) => a == b,
734            (
735                Self::FromUrl {
736                    from_url: a,
737                    headers: ha,
738                },
739                Self::FromUrl {
740                    from_url: b,
741                    headers: hb,
742                },
743            ) => a == b && ha == hb,
744            _ => false,
745        }
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    // The `archives`/`signs`/`binary_signs` fields use hand-written
754    // deserializers (untagged shapes serde can't derive). Each visitor arm —
755    // bool, sequence, single-map, null — is driven here through a wrapper
756    // struct that mirrors the real field attributes.
757
758    #[derive(Deserialize)]
759    struct ArchivesWrapper {
760        #[serde(default, deserialize_with = "deserialize_archives_config")]
761        archives: ArchivesConfig,
762    }
763
764    #[test]
765    fn archives_false_is_disabled() {
766        let w: ArchivesWrapper = serde_yaml_ng::from_str("archives: false").unwrap();
767        assert!(matches!(w.archives, ArchivesConfig::Disabled));
768    }
769
770    #[test]
771    fn archives_true_is_rejected() {
772        // `true` is meaningless for archives — only `false` (disable) or a list.
773        let r: Result<ArchivesWrapper, _> = serde_yaml_ng::from_str("archives: true");
774        assert!(r.is_err(), "archives: true must be rejected");
775    }
776
777    #[test]
778    fn archives_list_becomes_configs() {
779        let w: ArchivesWrapper =
780            serde_yaml_ng::from_str("archives:\n  - id: a\n  - id: b\n").unwrap();
781        match w.archives {
782            ArchivesConfig::Configs(c) => assert_eq!(c.len(), 2),
783            other => panic!("expected Configs, got {other:?}"),
784        }
785    }
786
787    #[test]
788    fn archives_null_defaults_to_empty_configs() {
789        let w: ArchivesWrapper = serde_yaml_ng::from_str("archives: null").unwrap();
790        match w.archives {
791            ArchivesConfig::Configs(c) => assert!(c.is_empty()),
792            other => panic!("expected empty Configs, got {other:?}"),
793        }
794    }
795
796    #[derive(Deserialize)]
797    struct SignsWrapper {
798        #[serde(default, deserialize_with = "deserialize_signs")]
799        signs: Vec<SignConfig>,
800    }
801
802    #[test]
803    fn signs_single_object_becomes_one_element_vec() {
804        // A single sign-config map (not wrapped in a list) is accepted.
805        let w: SignsWrapper = serde_yaml_ng::from_str("signs:\n  artifacts: all\n").unwrap();
806        assert_eq!(w.signs.len(), 1);
807        assert_eq!(w.signs[0].artifacts.as_deref(), Some("all"));
808    }
809
810    #[test]
811    fn signs_sequence_collects_all() {
812        let w: SignsWrapper =
813            serde_yaml_ng::from_str("signs:\n  - artifacts: all\n  - artifacts: checksum\n")
814                .unwrap();
815        assert_eq!(w.signs.len(), 2);
816    }
817
818    #[test]
819    fn signs_null_is_empty_vec() {
820        let w: SignsWrapper = serde_yaml_ng::from_str("signs: null").unwrap();
821        assert!(w.signs.is_empty());
822    }
823
824    #[derive(Deserialize)]
825    struct BinarySignsWrapper {
826        #[serde(default, deserialize_with = "deserialize_binary_signs")]
827        binary_signs: Vec<SignConfig>,
828    }
829
830    #[test]
831    fn binary_signs_accepts_binary_and_none() {
832        let w: BinarySignsWrapper =
833            serde_yaml_ng::from_str("binary_signs:\n  - artifacts: binary\n").unwrap();
834        assert_eq!(w.binary_signs.len(), 1);
835        let w2: BinarySignsWrapper =
836            serde_yaml_ng::from_str("binary_signs:\n  - artifacts: none\n").unwrap();
837        assert_eq!(w2.binary_signs.len(), 1);
838    }
839
840    #[test]
841    fn binary_signs_rejects_broad_artifact_filter() {
842        // `all` is valid for top-level `signs:` but not the binary-only field.
843        let r: Result<BinarySignsWrapper, _> =
844            serde_yaml_ng::from_str("binary_signs:\n  - artifacts: all\n");
845        assert!(
846            r.is_err(),
847            "binary_signs must reject a non-binary artifact filter"
848        );
849    }
850
851    // --- WrapInDirectory ---------------------------------------------------
852
853    #[test]
854    fn wrap_in_directory_bool_true_uses_default_name() {
855        assert_eq!(
856            WrapInDirectory::Bool(true).directory_name("myapp_1.0"),
857            Some("myapp_1.0".to_string())
858        );
859    }
860
861    #[test]
862    fn wrap_in_directory_bool_false_disables_wrapping() {
863        assert_eq!(
864            WrapInDirectory::Bool(false).directory_name("myapp_1.0"),
865            None
866        );
867    }
868
869    #[test]
870    fn wrap_in_directory_empty_string_disables_wrapping() {
871        // An empty custom name is treated as "no wrapping", not a dir named "".
872        assert_eq!(
873            WrapInDirectory::Name(String::new()).directory_name("fallback"),
874            None
875        );
876    }
877
878    #[test]
879    fn wrap_in_directory_custom_name_overrides_default() {
880        assert_eq!(
881            WrapInDirectory::Name("custom".into()).directory_name("fallback"),
882            Some("custom".to_string())
883        );
884    }
885
886    #[derive(Deserialize)]
887    struct WrapWrapper {
888        wrap_in_directory: WrapInDirectory,
889    }
890
891    #[test]
892    fn wrap_in_directory_deserializes_bool_and_string() {
893        let b: WrapWrapper = serde_yaml_ng::from_str("wrap_in_directory: true").unwrap();
894        assert_eq!(b.wrap_in_directory, WrapInDirectory::Bool(true));
895        let s: WrapWrapper = serde_yaml_ng::from_str("wrap_in_directory: dist").unwrap();
896        assert_eq!(s.wrap_in_directory, WrapInDirectory::Name("dist".into()));
897    }
898
899    #[test]
900    fn wrap_in_directory_rejects_non_scalar() {
901        // A list is neither a bool nor a string — the hand-written deserializer
902        // must error rather than coerce.
903        let r: Result<WrapWrapper, _> = serde_yaml_ng::from_str("wrap_in_directory:\n  - a\n");
904        assert!(r.is_err());
905    }
906
907    // --- parse_octal_mode --------------------------------------------------
908
909    #[test]
910    fn parse_octal_mode_accepts_common_forms() {
911        assert_eq!(parse_octal_mode("0755"), Some(0o755));
912        assert_eq!(parse_octal_mode("0o755"), Some(0o755));
913        assert_eq!(parse_octal_mode("0O755"), Some(0o755));
914        assert_eq!(parse_octal_mode("755"), Some(0o755));
915        // Bare "0o"/"0O" with nothing after → the cleaned string is empty and
916        // is normalized to "0".
917        assert_eq!(parse_octal_mode("0o"), Some(0));
918        assert_eq!(parse_octal_mode("0"), Some(0));
919    }
920
921    #[test]
922    fn parse_octal_mode_rejects_non_octal() {
923        // 8 and 9 are not octal digits.
924        assert_eq!(parse_octal_mode("0o899"), None);
925        assert_eq!(parse_octal_mode("garbage"), None);
926    }
927
928    // --- ChecksumConfig::resolve_combined_name_template (static precedence) --
929
930    #[test]
931    fn resolve_combined_name_template_prefers_crate_then_global_then_default() {
932        let crate_cfg = ChecksumConfig {
933            name_template: Some("crate.txt".into()),
934            ..Default::default()
935        };
936        let global_cfg = ChecksumConfig {
937            name_template: Some("global.txt".into()),
938            ..Default::default()
939        };
940        // Crate value wins over global and default.
941        assert_eq!(
942            ChecksumConfig::resolve_combined_name_template(Some(&crate_cfg), Some(&global_cfg)),
943            "crate.txt"
944        );
945        // With no crate override, global wins.
946        let bare = ChecksumConfig::default();
947        assert_eq!(
948            ChecksumConfig::resolve_combined_name_template(Some(&bare), Some(&global_cfg)),
949            "global.txt"
950        );
951        // Neither set → the canonical default.
952        assert_eq!(
953            ChecksumConfig::resolve_combined_name_template(None, None),
954            ChecksumConfig::DEFAULT_NAME_TEMPLATE
955        );
956    }
957
958    // --- ChecksumSplitFormat ----------------------------------------------
959
960    #[test]
961    fn checksum_split_format_defaults_to_bare() {
962        assert_eq!(ChecksumSplitFormat::default(), ChecksumSplitFormat::Bare);
963    }
964
965    #[test]
966    fn checksum_split_format_deserializes_lowercase() {
967        assert_eq!(
968            serde_yaml_ng::from_str::<ChecksumSplitFormat>("bare").unwrap(),
969            ChecksumSplitFormat::Bare
970        );
971        assert_eq!(
972            serde_yaml_ng::from_str::<ChecksumSplitFormat>("coreutils").unwrap(),
973            ChecksumSplitFormat::Coreutils
974        );
975        assert!(serde_yaml_ng::from_str::<ChecksumSplitFormat>("Coreutils").is_err());
976    }
977
978    // --- ExtraFileSpec::allow_empty ---------------------------------------
979
980    #[test]
981    fn extra_file_spec_allow_empty_only_true_for_detailed_opt_in() {
982        // Bare glob form is always fail-fast (allow_empty == false).
983        let bare: ExtraFileSpec = serde_yaml_ng::from_str("dist/*.sig").unwrap();
984        assert!(!bare.allow_empty());
985        // Detailed with allow_empty: true opts in.
986        let opt_in: ExtraFileSpec =
987            serde_yaml_ng::from_str("glob: keys/*.pub\nallow_empty: true").unwrap();
988        assert!(opt_in.allow_empty());
989        assert_eq!(opt_in.glob(), "keys/*.pub");
990        // Detailed defaulting allow_empty stays false.
991        let default_off: ExtraFileSpec = serde_yaml_ng::from_str("glob: docs/*.pdf").unwrap();
992        assert!(!default_off.allow_empty());
993    }
994
995    // --- ArchiveFileSpec PartialEq<&str> ----------------------------------
996
997    #[test]
998    fn archive_file_spec_str_eq_matches_glob_only() {
999        assert!(ArchiveFileSpec::Glob("README.md".into()) == "README.md");
1000        assert!(ArchiveFileSpec::Glob("README.md".into()) != "other");
1001        // The Detailed variant never equals a bare string.
1002        let detailed = ArchiveFileSpec::Detailed {
1003            src: "README.md".into(),
1004            dst: None,
1005            info: None,
1006            strip_parent: None,
1007        };
1008        assert!(detailed != "README.md");
1009    }
1010
1011    // --- ArchiveConfig deprecation folds ----------------------------------
1012
1013    #[test]
1014    fn archive_config_folds_singular_format_into_formats() {
1015        // The deprecated `format: tar.gz` singular folds into `formats`.
1016        let c: ArchiveConfig = serde_yaml_ng::from_str("format: tar.gz").unwrap();
1017        assert_eq!(c.formats.as_deref().unwrap(), ["tar.gz"]);
1018    }
1019
1020    #[test]
1021    fn archive_config_folds_deprecated_builds_into_ids() {
1022        let c: ArchiveConfig = serde_yaml_ng::from_str("ids: [keep]\nbuilds: [legacy]").unwrap();
1023        let ids = c.ids.unwrap();
1024        assert!(ids.contains(&"keep".to_string()));
1025        assert!(ids.contains(&"legacy".to_string()));
1026    }
1027
1028    #[test]
1029    fn archive_config_defaults_id_to_default() {
1030        // A parse->serialise round-trip must be stable, so `id` materializes
1031        // to "default" when omitted.
1032        let c: ArchiveConfig = serde_yaml_ng::from_str("name_template: x").unwrap();
1033        assert_eq!(c.id.as_deref(), Some("default"));
1034        // An explicit id is preserved verbatim.
1035        let named: ArchiveConfig = serde_yaml_ng::from_str("id: bins").unwrap();
1036        assert_eq!(named.id.as_deref(), Some("bins"));
1037    }
1038
1039    #[test]
1040    fn format_override_folds_singular_format() {
1041        let o: FormatOverride = serde_yaml_ng::from_str("os: windows\nformat: zip").unwrap();
1042        assert_eq!(o.os, "windows");
1043        assert_eq!(o.formats.as_deref().unwrap(), ["zip"]);
1044    }
1045
1046    // --- ContentSource PartialEq ------------------------------------------
1047
1048    #[test]
1049    fn content_source_partial_eq_by_variant_and_payload() {
1050        assert_eq!(
1051            ContentSource::Inline("a".into()),
1052            ContentSource::Inline("a".into())
1053        );
1054        assert_ne!(
1055            ContentSource::Inline("a".into()),
1056            ContentSource::Inline("b".into())
1057        );
1058        // Same string but different variant must not compare equal.
1059        assert_ne!(
1060            ContentSource::Inline("a".into()),
1061            ContentSource::FromFile {
1062                from_file: "a".into()
1063            }
1064        );
1065        // FromUrl equality includes the headers map.
1066        let mut h = HashMap::new();
1067        h.insert("Accept".to_string(), "text/plain".to_string());
1068        let with_headers = ContentSource::FromUrl {
1069            from_url: "u".into(),
1070            headers: Some(h.clone()),
1071        };
1072        assert_eq!(
1073            with_headers,
1074            ContentSource::FromUrl {
1075                from_url: "u".into(),
1076                headers: Some(h),
1077            }
1078        );
1079        assert_ne!(
1080            with_headers,
1081            ContentSource::FromUrl {
1082                from_url: "u".into(),
1083                headers: None,
1084            }
1085        );
1086    }
1087
1088    #[test]
1089    fn content_source_from_url_deserializes_headers() {
1090        let cs: ContentSource = serde_yaml_ng::from_str(
1091            "from_url: https://example.com/h.md\nheaders:\n  X-Token: abc\n",
1092        )
1093        .unwrap();
1094        match cs {
1095            ContentSource::FromUrl { from_url, headers } => {
1096                assert_eq!(from_url, "https://example.com/h.md");
1097                assert_eq!(headers.unwrap().get("X-Token").unwrap(), "abc");
1098            }
1099            other => panic!("expected FromUrl, got {other:?}"),
1100        }
1101    }
1102
1103    // --- TemplatedExtraFile -----------------------------------------------
1104
1105    #[test]
1106    fn templated_extra_file_parses_and_defaults() {
1107        let full: TemplatedExtraFile = serde_yaml_ng::from_str(
1108            "src: NOTES.tera\ndst: \"{{ ProjectName }}-NOTES.txt\"\nmode: \"0644\"",
1109        )
1110        .unwrap();
1111        assert_eq!(full.src, "NOTES.tera");
1112        assert_eq!(full.dst.as_deref(), Some("{{ ProjectName }}-NOTES.txt"));
1113        assert_eq!(full.mode.as_deref(), Some("0644"));
1114        // Only `src` is required; dst/mode default to None.
1115        let minimal: TemplatedExtraFile = serde_yaml_ng::from_str("src: NOTES.tera").unwrap();
1116        assert!(minimal.dst.is_none());
1117        assert!(minimal.mode.is_none());
1118        // Unknown fields are rejected.
1119        assert!(serde_yaml_ng::from_str::<TemplatedExtraFile>("src: x\nbogus: y").is_err());
1120    }
1121}