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)]
217pub struct ArchiveConfig {
218    /// Unique identifier for cross-referencing this archive from other configs.
219    /// Defaults to `"default"` so a parse->serialise->reparse round-trip is
220    /// stable (GoReleaser stores this verbatim, not as an Option).
221    pub id: Option<String>,
222    /// Archive filename template (supports templates, e.g., "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}").
223    pub name_template: Option<String>,
224    /// Archive formats: tar.gz, tar.xz, tar.zst, tar, zip, gz, xz, or binary.
225    /// `gz` and `xz` are single-file compressors — supplying multiple input
226    /// files errors. Plural list; one archive per format is produced for each
227    /// target.
228    pub formats: Option<Vec<String>>,
229    /// Per-OS format overrides for this archive config.
230    pub format_overrides: Option<Vec<FormatOverride>>,
231    /// Extra files to include in the archive (glob patterns or detailed src/dst specs).
232    pub files: Option<Vec<ArchiveFileSpec>>,
233    /// Binary names to include (defaults to all binaries from matched builds).
234    pub binaries: Option<Vec<String>>,
235    /// When set, wrap archive contents in a top-level directory.
236    /// Accepts `true` (use archive stem as directory name), `false` (no wrapping),
237    /// or a string template for a custom directory name.
238    pub wrap_in_directory: Option<WrapInDirectory>,
239    /// Build IDs filter: only include artifacts from builds whose `id` is in this list.
240    pub ids: Option<Vec<String>>,
241    /// When true, create archive with no binaries (metadata-only).
242    pub meta: Option<bool>,
243    /// File permissions applied to binaries in archives.
244    pub builds_info: Option<ArchiveFileInfo>,
245    /// Strip binary parent directory in archive (place binaries at archive root).
246    pub strip_binary_directory: Option<bool>,
247    /// Allow different binary counts across targets. Default false (warn on mismatch).
248    pub allow_different_binary_count: Option<bool>,
249    /// Pre/post archive hooks (`before`/`after`).
250    pub hooks: Option<ArchiveHooksConfig>,
251}
252
253// F3: custom Deserialize that accepts deprecated GR aliases:
254// - `format: tar.gz` (singular String) folded into `formats: [tar.gz]`
255//   (`internal/pipe/archive/archive.go:62-64`)
256// - `builds: [foo]` folded into `ids: [foo]`
257//   (`internal/pipe/archive/archive.go:79-82`)
258// Each alias hit emits a `tracing::warn!` deprecation notice.
259impl<'de> Deserialize<'de> for ArchiveConfig {
260    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
261    where
262        D: Deserializer<'de>,
263    {
264        #[derive(Deserialize, Default)]
265        #[serde(default)]
266        struct Raw {
267            id: Option<String>,
268            name_template: Option<String>,
269            formats: Option<Vec<String>>,
270            format: Option<String>,
271            format_overrides: Option<Vec<FormatOverride>>,
272            files: Option<Vec<ArchiveFileSpec>>,
273            binaries: Option<Vec<String>>,
274            wrap_in_directory: Option<WrapInDirectory>,
275            ids: Option<Vec<String>>,
276            builds: Option<Vec<String>>,
277            meta: Option<bool>,
278            builds_info: Option<ArchiveFileInfo>,
279            strip_binary_directory: Option<bool>,
280            allow_different_binary_count: Option<bool>,
281            hooks: Option<ArchiveHooksConfig>,
282        }
283
284        let raw = Raw::deserialize(deserializer)?;
285
286        let id_label = raw.id.clone().unwrap_or_else(|| "default".to_string());
287        let mut formats = raw.formats;
288        if let Some(legacy) = raw.format {
289            tracing::warn!(
290                "DEPRECATION: archives[id={}]: 'format: {}' is deprecated; \
291                 use 'formats: [{}]' instead.",
292                id_label,
293                legacy,
294                legacy
295            );
296            formats.get_or_insert_with(Vec::new).push(legacy);
297        }
298        let mut ids = raw.ids;
299        if let Some(legacy) = raw.builds {
300            tracing::warn!(
301                "DEPRECATION: archives[id={}]: 'builds: {:?}' is deprecated; \
302                 use 'ids: [...]' instead.",
303                id_label,
304                legacy
305            );
306            let target = ids.get_or_insert_with(Vec::new);
307            target.extend(legacy);
308        }
309
310        Ok(ArchiveConfig {
311            id: raw.id.or_else(|| Some("default".to_string())),
312            name_template: raw.name_template,
313            formats,
314            format_overrides: raw.format_overrides,
315            files: raw.files,
316            binaries: raw.binaries,
317            wrap_in_directory: raw.wrap_in_directory,
318            ids,
319            meta: raw.meta,
320            builds_info: raw.builds_info,
321            strip_binary_directory: raw.strip_binary_directory,
322            allow_different_binary_count: raw.allow_different_binary_count,
323            hooks: raw.hooks,
324        })
325    }
326}
327
328#[derive(Debug, Clone, Serialize, JsonSchema)]
329pub struct FormatOverride {
330    /// Operating system this override applies to (e.g., "windows", "darwin", "linux").
331    pub os: String,
332    /// Plural format overrides for this OS: tar.gz, tar.xz, tar.zst, tar, zip,
333    /// gz, xz, or binary.
334    pub formats: Option<Vec<String>>,
335}
336
337// F3: custom Deserialize that accepts both `formats: [tar.gz]` (canonical)
338// and the deprecated singular `format: tar.gz` (GR
339// `internal/pipe/archive/archive.go:71-74`). The legacy spelling is folded
340// into `formats` at parse time and a deprecation warning is emitted.
341impl<'de> Deserialize<'de> for FormatOverride {
342    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
343    where
344        D: Deserializer<'de>,
345    {
346        #[derive(Deserialize, Default)]
347        #[serde(default)]
348        struct Raw {
349            os: String,
350            formats: Option<Vec<String>>,
351            format: Option<String>,
352        }
353        let raw = Raw::deserialize(deserializer)?;
354        let mut formats = raw.formats;
355        if let Some(legacy) = raw.format {
356            tracing::warn!(
357                "DEPRECATION: archives.format_overrides[os={}]: 'format: {}' is deprecated; \
358                 use 'formats: [{}]' instead.",
359                raw.os,
360                legacy,
361                legacy
362            );
363            formats.get_or_insert_with(Vec::new).push(legacy);
364        }
365        Ok(FormatOverride {
366            os: raw.os,
367            formats,
368        })
369    }
370}
371
372/// Specifies a file to include in archives. Can be a simple glob string or a
373/// detailed object with src/dst/info fields for controlling archive placement
374/// and file metadata.
375///
376/// NOTE: This is intentionally a separate type from [`ExtraFileSpec`] (used for
377/// checksum/release extra_files). `ArchiveFileSpec` needs `src`/`dst`/`info`
378/// fields for archive placement and file metadata (owner, group, mode, mtime),
379/// while `ExtraFileSpec` needs `glob`/`name_template` for checksumming and
380/// upload renaming. The fields and semantics are different enough that a unified
381/// type would be confusing.
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
383#[serde(untagged)]
384pub enum ArchiveFileSpec {
385    Glob(String),
386    Detailed {
387        src: String,
388        dst: Option<String>,
389        info: Option<ArchiveFileInfo>,
390        /// When true, strip the parent directory from the file path in the archive.
391        strip_parent: Option<bool>,
392    },
393}
394
395impl PartialEq<&str> for ArchiveFileSpec {
396    fn eq(&self, other: &&str) -> bool {
397        match self {
398            ArchiveFileSpec::Glob(s) => s.as_str() == *other,
399            _ => false,
400        }
401    }
402}
403
404/// Shared file metadata (owner, group, mode, mtime) used by both archive entries
405/// and nFPM package contents. Previously duplicated as `ArchiveFileInfo` and
406/// `NfpmFileInfo`; now unified.
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
408#[serde(default)]
409pub struct FileInfo {
410    /// File owner name (e.g., "root").
411    pub owner: Option<String>,
412    /// File group name (e.g., "root").
413    pub group: Option<String>,
414    /// File permission mode. Accepts a YAML int (decimal, e.g. `420` for
415    /// `0o644`) or an octal-prefixed string (`"0o644"`, `"0644"`). This
416    /// matches GoReleaser's `uint32` type for `Mode` on archive/nfpm contents
417    /// while letting users spell octal naturally in YAML.
418    pub mode: Option<StringOrU32>,
419    /// File modification time in RFC3339 format (e.g., "2024-01-01T00:00:00Z").
420    pub mtime: Option<String>,
421}
422
423/// Backward-compatible alias for archive code.
424pub type ArchiveFileInfo = FileInfo;
425
426/// Parse an octal mode string into a `u32`, handling common YAML-friendly
427/// representations: `"0755"`, `"0o755"`, `"0O755"`, `"755"`, and `"0"`.
428pub fn parse_octal_mode(s: &str) -> Option<u32> {
429    let cleaned = s
430        .strip_prefix("0o")
431        .or_else(|| s.strip_prefix("0O"))
432        .unwrap_or(s);
433    let cleaned = if cleaned.is_empty() { "0" } else { cleaned };
434    u32::from_str_radix(cleaned, 8).ok()
435}
436
437/// The set of archive format strings recognised by the archive stage.
438/// Used for early validation so typos are caught at config load time rather
439/// than mid-pipeline.
440pub const VALID_ARCHIVE_FORMATS: &[&str] = &[
441    "tar.gz", "tgz", "tar.xz", "txz", "tar.zst", "tzst", "tar", "zip", "gz", "xz", "binary", "none",
442];
443
444// ---------------------------------------------------------------------------
445// ChecksumConfig
446// ---------------------------------------------------------------------------
447
448/// Specifies an extra file to include in checksums or release uploads. Can be a
449/// simple glob string or a detailed object with glob and name_template fields.
450///
451/// See [`ArchiveFileSpec`] doc comment for why this is a separate type.
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
453#[serde(untagged)]
454pub enum ExtraFileSpec {
455    Glob(String),
456    Detailed {
457        glob: String,
458        /// Optional override for the upload filename.
459        #[serde(default)]
460        name_template: Option<String>,
461        /// When true, treat a glob that matches zero files as a no-op
462        /// rather than a hard error. Useful for assets produced only in
463        /// CI (e.g. signing public keys derived from a secret) that
464        /// must not break local snapshot/dry-run flows. Defaults to
465        /// false, matching the prior fail-fast behavior.
466        #[serde(default)]
467        allow_empty: bool,
468    },
469}
470
471impl ExtraFileSpec {
472    /// Return the glob pattern for this spec.
473    pub fn glob(&self) -> &str {
474        match self {
475            ExtraFileSpec::Glob(s) => s,
476            ExtraFileSpec::Detailed { glob, .. } => glob,
477        }
478    }
479
480    /// Return the optional name_template (only present in Detailed variant).
481    pub fn name_template(&self) -> Option<&str> {
482        match self {
483            ExtraFileSpec::Glob(_) => None,
484            ExtraFileSpec::Detailed { name_template, .. } => name_template.as_deref(),
485        }
486    }
487
488    /// Return whether this spec allows a zero-match glob without erroring
489    /// (Detailed variant only; the bare string form is always fail-fast).
490    pub fn allow_empty(&self) -> bool {
491        match self {
492            ExtraFileSpec::Glob(_) => false,
493            ExtraFileSpec::Detailed { allow_empty, .. } => *allow_empty,
494        }
495    }
496}
497
498/// A file whose contents are rendered through the template engine before use.
499/// Used by `templated_extra_files` across multiple stages (GoReleaser Pro feature).
500#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
501#[serde(default)]
502pub struct TemplatedExtraFile {
503    /// Source template file path.
504    pub src: String,
505    /// Destination filename for the rendered output.
506    /// Supports template variables (e.g. `"{{ .ProjectName }}-NOTES.txt"`).
507    pub dst: Option<String>,
508    /// File permissions in octal notation as a string, e.g. `"0755"`.
509    /// Parsed at runtime via `parse_octal_mode()` to avoid YAML interpreting as decimal.
510    pub mode: Option<String>,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
514#[serde(default)]
515pub struct ChecksumConfig {
516    /// Checksum filename template (default: "{{ .ProjectName }}_{{ .Version }}_checksums.txt").
517    pub name_template: Option<String>,
518    /// Hash algorithm: sha256, sha512, sha1, md5, crc32 (default: sha256).
519    pub algorithm: Option<String>,
520    /// Disable checksums. Accepts bool or template string.
521    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
522    pub skip: Option<StringOrBool>,
523    /// Extra files to include in the checksum file (beyond build artifacts).
524    pub extra_files: Option<Vec<ExtraFileSpec>>,
525    /// Extra files whose contents are rendered through the template engine before inclusion.
526    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
527    /// GoReleaser Pro feature.
528    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
529    /// Build IDs filter: only checksum artifacts from builds whose `id` is in this list.
530    pub ids: Option<Vec<String>>,
531    /// When true, produce one checksum file per artifact instead of a combined file.
532    pub split: Option<bool>,
533}
534
535impl ChecksumConfig {
536    /// Default checksum filename template (combined mode). Mirrors
537    /// `internal/pipe/checksums/checksums.go:48` in GoReleaser.
538    pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ ProjectName }}_{{ Version }}_checksums.txt";
539
540    /// Default hash algorithm. Mirrors GoReleaser
541    /// (`internal/pipe/checksums/checksums.go:42`).
542    pub const DEFAULT_ALGORITHM: &'static str = "sha256";
543
544    /// Resolve the hash algorithm, falling back to the project default
545    /// when the user did not specify one. Stages MUST call this rather
546    /// than reading `self.algorithm` directly, so a future default change
547    /// (or user-facing override resolution) lands in one place. See the
548    /// lazy-vs-eager defaults policy in `.claude/audits/2026-04-config-gaps/`.
549    pub fn resolved_algorithm(&self) -> &str {
550        self.algorithm.as_deref().unwrap_or(Self::DEFAULT_ALGORITHM)
551    }
552
553    /// Whether split-mode (one sidecar per artifact) is requested.
554    /// Defaults to `false` (combined-file mode, matching GoReleaser).
555    pub fn resolved_split(&self) -> bool {
556        self.split.unwrap_or(false)
557    }
558
559    /// Resolve the combined-mode checksum filename template, falling back
560    /// to the GoReleaser-canonical default. Returns the raw template
561    /// string; the caller still renders it through Tera.
562    ///
563    /// Split mode constructs sidecar names per-artifact at the call site
564    /// (`<artifact>.<algo>` literal format) and intentionally does NOT
565    /// route through this accessor — that path needs no template rendering.
566    pub fn resolved_combined_name_template(&self) -> &str {
567        self.name_template
568            .as_deref()
569            .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
570    }
571}
572
573// ---------------------------------------------------------------------------
574// ContentSource — inline string, from_file, or from_url
575// ---------------------------------------------------------------------------
576
577/// A content source that can be an inline string, read from a file, or fetched
578/// from a URL. Used for release header/footer values.
579///
580/// YAML examples:
581///   header: "inline text"
582///   header:
583///     from_file: ./RELEASE_HEADER.md
584///   header:
585///     from_url: https://example.com/header.md
586///   header:
587///     from_url: https://example.com/header.md
588///     headers:
589///       X-API-Token: "{{ .Env.API_TOKEN }}"
590///       Accept: "text/markdown"
591///
592/// Both `from_file` path and `from_url` URL are template-rendered before use.
593/// Header values are template-rendered. (GoReleaser Pro parity.)
594#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
595#[serde(untagged)]
596pub enum ContentSource {
597    Inline(String),
598    FromFile {
599        from_file: String,
600    },
601    FromUrl {
602        from_url: String,
603        /// Optional HTTP headers (value templates allowed). Enables private
604        /// mirrors and authenticated endpoints.
605        #[serde(default, skip_serializing_if = "Option::is_none")]
606        headers: Option<HashMap<String, String>>,
607    },
608}
609
610impl PartialEq for ContentSource {
611    fn eq(&self, other: &Self) -> bool {
612        match (self, other) {
613            (Self::Inline(a), Self::Inline(b)) => a == b,
614            (Self::FromFile { from_file: a }, Self::FromFile { from_file: b }) => a == b,
615            (
616                Self::FromUrl {
617                    from_url: a,
618                    headers: ha,
619                },
620                Self::FromUrl {
621                    from_url: b,
622                    headers: hb,
623                },
624            ) => a == b && ha == hb,
625            _ => false,
626        }
627    }
628}