Skip to main content

anodizer_core/config/
hooks.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use crate::artifact::ArtifactKind;
5
6// ---------------------------------------------------------------------------
7// HooksConfig
8// ---------------------------------------------------------------------------
9
10/// Top-level lifecycle hooks for `before` and `after` blocks.
11/// Each block carries a list of hook commands that run around the
12/// entire pipeline (not individual stages).
13///
14/// The canonical key is `hooks:` for both `before:` and `after:` to
15/// the conventional spelling. The `post:` spelling is accepted
16/// as a serde alias on `hooks` for back-compat with the previous
17/// anodizer spelling; users with `after: { post: [...] }` keep working
18/// and a deprecation warning is logged when both spellings appear in
19/// the same block (see [`HooksConfig::merge_hook_aliases`]).
20#[derive(Debug, Clone, PartialEq, Default, JsonSchema)]
21#[serde(deny_unknown_fields)]
22pub struct HooksConfig {
23    /// Commands to run when the block fires. The wire format accepts
24    /// either `hooks:` (canonical) or the legacy
25    /// `post:` spelling; both fold into this field at parse time.
26    pub hooks: Option<Vec<HookEntry>>,
27    /// Legacy alias for `hooks:` (anodizer pre-v0.4). Always `None`
28    /// after parsing — `merge_hook_aliases` collapses it into `hooks`.
29    /// Present on the struct only because `Deserialize` writes through
30    /// it before the fold step.
31    #[doc(hidden)]
32    pub post: Option<Vec<HookEntry>>,
33}
34
35impl HooksConfig {
36    /// Fold the deprecated `post:` spelling into `hooks:` so downstream
37    /// readers consult one field. Emits a `tracing::warn!` with one of two
38    /// suffixes depending on whether both spellings appeared or only the
39    /// legacy one — both messages carry the same "switch to 'hooks:'"
40    /// guidance, with the conflict case adding the "and ignoring 'post:'"
41    /// note so the user knows which side won.
42    fn merge_hook_aliases(&mut self) {
43        let has_hooks = self.hooks.as_ref().is_some_and(|v| !v.is_empty());
44        let has_post = self.post.as_ref().is_some_and(|v| !v.is_empty());
45        match (has_hooks, has_post) {
46            (true, true) => {
47                tracing::warn!(
48                    "DEPRECATION: top-level hooks block has both 'hooks:' and 'post:' \
49                     — using 'hooks:' and ignoring 'post:'. The 'post:' spelling is \
50                     renamed to 'hooks:' for GoReleaser parity; remove the 'post:' \
51                     key from your config."
52                );
53                self.post = None;
54            }
55            (false, true) => {
56                tracing::warn!(
57                    "DEPRECATION: top-level 'after.post:' / 'before.post:' is renamed to \
58                     'hooks:' for GoReleaser parity. The 'post:' spelling still works \
59                     but will be removed in a future release; switch to 'hooks:'."
60                );
61                self.hooks = self.post.take();
62            }
63            // (true, false): canonical shape, nothing to do.
64            // (false, false): empty block, nothing to do.
65            _ => {}
66        }
67    }
68}
69
70impl Serialize for HooksConfig {
71    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
72        use serde::ser::SerializeStruct;
73        let count = self.hooks.is_some() as usize + self.post.is_some() as usize;
74        let mut state = serializer.serialize_struct("HooksConfig", count)?;
75        if let Some(ref h) = self.hooks {
76            state.serialize_field("hooks", h)?;
77        }
78        if let Some(ref p) = self.post {
79            state.serialize_field("post", p)?;
80        }
81        state.end()
82    }
83}
84
85impl<'de> Deserialize<'de> for HooksConfig {
86    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
87    where
88        D: Deserializer<'de>,
89    {
90        #[derive(Deserialize, Default)]
91        #[serde(default, deny_unknown_fields)]
92        struct Raw {
93            hooks: Option<Vec<HookEntry>>,
94            post: Option<Vec<HookEntry>>,
95        }
96        let raw = Raw::deserialize(deserializer)?;
97        let mut out = HooksConfig {
98            hooks: raw.hooks,
99            post: raw.post,
100        };
101        out.merge_hook_aliases();
102        Ok(out)
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
107#[serde(default, deny_unknown_fields)]
108pub struct StructuredHook {
109    /// Command to run.
110    ///
111    /// The entire string is interpreted by `sh -c`, so shell metacharacters
112    /// (`|`, `;`, `&&`, backticks, `$()`, redirects, globs) are honoured —
113    /// any templated values folded into `cmd` become part of the shell
114    /// command and are subject to word-splitting and metacharacter expansion.
115    /// Keep templated user-config values out of `cmd` when possible, or quote
116    /// them defensively (e.g. `'{{ Env.FOO }}'`). Hooks already run with
117    /// `env_clear()` plus an allow-list, so secrets in `$ENV` are not
118    /// inherited unless explicitly listed in `env`.
119    pub cmd: String,
120    /// Working directory for the command (defaults to project root).
121    pub dir: Option<String>,
122    /// Environment variables for the command.
123    #[serde(default)]
124    pub env: Option<Vec<String>>,
125    /// When true, capture and log stdout/stderr of the command.
126    pub output: Option<bool>,
127    /// Template-conditional: when set, the hook only runs if the rendered
128    /// result is truthy (not `"false"` / `"0"` / `"no"` / empty). Render
129    /// failure hard-errors (not silent-skip).
130    /// `before.hooks[].if:` / per-build / per-archive hook `if:` surface.
131    #[serde(rename = "if")]
132    pub if_condition: Option<String>,
133    /// Artifact-id allow-list (`before_publish:` only). When `Some`, the
134    /// per-artifact iteration only fires for artifacts whose
135    /// `metadata["id"]` matches one of these strings. `None` (the default)
136    /// imposes no id constraint. Ignored by lifecycle hook sites
137    /// (`before:` / `after:` / per-build / per-archive) — those run once
138    /// per pipeline tick, not per artifact.
139    pub ids: Option<Vec<String>>,
140    /// Artifact-kind filter (`before_publish:` only). When `Some`, the
141    /// per-artifact iteration only fires for artifacts whose
142    /// [`ArtifactKind`] matches the filter. `None` is equivalent to
143    /// [`BeforePublishArtifactFilter::All`] (every registered artifact).
144    /// Ignored by lifecycle hook sites for the same reason as `ids`.
145    pub artifacts: Option<BeforePublishArtifactFilter>,
146}
147
148/// Artifact-type filter for `before_publish[*].artifacts`.
149///
150/// The `before_publish[*].artifacts` enum
151/// (`checksum` / `source` / `package` / `installer` / `diskimage` /
152/// `archive` / `binary` / `sbom` / `image` / `all`). Maps each variant
153/// to a predicate over [`ArtifactKind`]:
154///
155/// | Variant | Matched [`ArtifactKind`] values |
156/// |---|---|
157/// | `Checksum` | `Checksum` |
158/// | `Source` | `SourceArchive`, `SourcePkgBuild`, `SourceSrcInfo`, `SourceRpm` |
159/// | `Package` | `LinuxPackage`, `Snap`, `PublishableSnapcraft`, `Flatpak` |
160/// | `Installer` | `Installer`, `MacOsPackage` |
161/// | `DiskImage` | `DiskImage` |
162/// | `Archive` | `Archive`, `Makeself` |
163/// | `Binary` | `Binary`, `UploadableBinary`, `UniversalBinary` |
164/// | `Sbom` | `Sbom` |
165/// | `Image` | `DockerImage`, `DockerImageV2`, `PublishableDockerImage` (multi-arch `DockerManifest` excluded — covers individual images only) |
166/// | `All` | every kind |
167///
168/// Mapping notes:
169/// - `Source` includes RPM-source variants since all source-derived
170///   artifacts under one bucket.
171/// - `Installer` covers macOS `.pkg` (`MacOsPackage`) alongside Windows
172///   MSI/NSIS — "installer" is used without OS qualification.
173/// - `Image` deliberately excludes [`ArtifactKind::DockerManifest`] /
174///   [`ArtifactKind::DockerDigest`]; those are multi-arch index entries
175///   and don't correspond to a scannable image blob. The
176///   per-image hook semantics (the multi-arch manifest is published
177///   separately and isn't a vulnerability scan target).
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
179#[serde(rename_all = "snake_case")]
180pub enum BeforePublishArtifactFilter {
181    Checksum,
182    Source,
183    Package,
184    Installer,
185    #[serde(alias = "diskimage")]
186    DiskImage,
187    Archive,
188    Binary,
189    Sbom,
190    Image,
191    #[default]
192    All,
193}
194
195impl BeforePublishArtifactFilter {
196    /// Returns `true` when `kind` should run the hook under this filter.
197    pub fn matches(self, kind: ArtifactKind) -> bool {
198        match self {
199            Self::All => true,
200            Self::Checksum => matches!(kind, ArtifactKind::Checksum),
201            Self::Source => matches!(
202                kind,
203                ArtifactKind::SourceArchive
204                    | ArtifactKind::SourcePkgBuild
205                    | ArtifactKind::SourceSrcInfo
206                    | ArtifactKind::SourceRpm
207            ),
208            Self::Package => matches!(
209                kind,
210                ArtifactKind::LinuxPackage
211                    | ArtifactKind::Snap
212                    | ArtifactKind::PublishableSnapcraft
213                    | ArtifactKind::Flatpak
214            ),
215            Self::Installer => matches!(kind, ArtifactKind::Installer | ArtifactKind::MacOsPackage),
216            Self::DiskImage => matches!(kind, ArtifactKind::DiskImage),
217            Self::Archive => matches!(kind, ArtifactKind::Archive | ArtifactKind::Makeself),
218            Self::Binary => matches!(
219                kind,
220                ArtifactKind::Binary
221                    | ArtifactKind::UploadableBinary
222                    | ArtifactKind::UniversalBinary
223            ),
224            Self::Sbom => matches!(kind, ArtifactKind::Sbom),
225            Self::Image => matches!(
226                kind,
227                ArtifactKind::DockerImage
228                    | ArtifactKind::DockerImageV2
229                    | ArtifactKind::PublishableDockerImage
230            ),
231        }
232    }
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
236#[serde(untagged)]
237pub enum HookEntry {
238    Simple(String),
239    Structured(StructuredHook),
240}
241
242impl PartialEq<&str> for HookEntry {
243    fn eq(&self, other: &&str) -> bool {
244        match self {
245            HookEntry::Simple(s) => s.as_str() == *other,
246            HookEntry::Structured(h) => h.cmd.as_str() == *other,
247        }
248    }
249}
250
251impl<'de> Deserialize<'de> for HookEntry {
252    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
253    where
254        D: Deserializer<'de>,
255    {
256        let value = serde_json::Value::deserialize(deserializer)?;
257        match &value {
258            serde_json::Value::String(s) => Ok(HookEntry::Simple(s.clone())),
259            serde_json::Value::Object(_) => {
260                let hook: StructuredHook =
261                    serde_json::from_value(value).map_err(serde::de::Error::custom)?;
262                Ok(HookEntry::Structured(hook))
263            }
264            _ => Err(serde::de::Error::custom(
265                "hook entry must be a string or an object with cmd/dir/env/output",
266            )),
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use std::io;
275    use std::sync::{Arc, Mutex, MutexGuard};
276    use tracing::subscriber::with_default;
277    use tracing_subscriber::fmt::MakeWriter;
278
279    /// Shared buffer writer that captures `tracing` output into a `Vec<u8>`.
280    #[derive(Clone, Default)]
281    struct BufferWriter(Arc<Mutex<Vec<u8>>>);
282
283    impl BufferWriter {
284        fn captured(&self) -> String {
285            String::from_utf8_lossy(&self.0.lock().unwrap()).to_string()
286        }
287    }
288
289    impl io::Write for BufferWriter {
290        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
291            self.0.lock().unwrap().extend_from_slice(buf);
292            Ok(buf.len())
293        }
294        fn flush(&mut self) -> io::Result<()> {
295            Ok(())
296        }
297    }
298
299    /// `BufferWriterHandle` matches the `MakeWriter` contract: each
300    /// `make_writer` call returns a cheap clone that writes into the
301    /// same `Arc<Mutex<Vec<u8>>>` as every other clone.
302    impl<'a> MakeWriter<'a> for BufferWriter {
303        type Writer = BufferWriterGuard<'a>;
304        fn make_writer(&'a self) -> Self::Writer {
305            BufferWriterGuard(self.0.lock().unwrap())
306        }
307    }
308
309    struct BufferWriterGuard<'a>(MutexGuard<'a, Vec<u8>>);
310    impl io::Write for BufferWriterGuard<'_> {
311        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
312            self.0.extend_from_slice(buf);
313            Ok(buf.len())
314        }
315        fn flush(&mut self) -> io::Result<()> {
316            Ok(())
317        }
318    }
319
320    /// Run `body` with a tracing subscriber that captures every event into
321    /// the returned buffer; assertions then inspect the captured text.
322    fn capture_warnings<F: FnOnce()>(body: F) -> String {
323        let buf = BufferWriter::default();
324        let subscriber = tracing_subscriber::fmt()
325            .with_writer(buf.clone())
326            .with_max_level(tracing::Level::WARN)
327            .without_time()
328            .with_ansi(false)
329            .finish();
330        with_default(subscriber, body);
331        buf.captured()
332    }
333
334    /// Legacy-only spelling (post: set, hooks: unset) folds into `hooks`
335    /// and emits a DEPRECATION warning that points at the canonical name.
336    #[test]
337    fn legacy_post_only_folds_and_warns() {
338        let captured = capture_warnings(|| {
339            let mut cfg = HooksConfig {
340                hooks: None,
341                post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
342            };
343            cfg.merge_hook_aliases();
344            assert_eq!(
345                cfg.hooks.as_deref().map(|v| v.len()),
346                Some(1),
347                "post: should have moved into hooks:"
348            );
349            assert!(cfg.post.is_none(), "post: must be cleared after merge");
350        });
351        assert!(
352            captured.contains("DEPRECATION"),
353            "expected DEPRECATION marker in warning: {captured}"
354        );
355        assert!(
356            captured.contains("renamed to 'hooks:'"),
357            "legacy-only warning must guide to 'hooks:' rename: {captured}"
358        );
359    }
360
361    /// Both spellings set: `hooks:` wins, `post:` is dropped, and the
362    /// emitted warning explicitly calls out the conflict.
363    #[test]
364    fn both_present_keeps_hooks_drops_post_and_warns() {
365        let captured = capture_warnings(|| {
366            let mut cfg = HooksConfig {
367                hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
368                post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
369            };
370            cfg.merge_hook_aliases();
371            assert!(cfg.post.is_none(), "post: must be cleared on conflict");
372            let names: Vec<&str> = cfg
373                .hooks
374                .as_deref()
375                .unwrap()
376                .iter()
377                .map(|h| match h {
378                    HookEntry::Simple(s) => s.as_str(),
379                    HookEntry::Structured(s) => s.cmd.as_str(),
380                })
381                .collect();
382            assert_eq!(names, vec!["modern.sh"], "hooks: must win on conflict");
383        });
384        assert!(
385            captured.contains("DEPRECATION"),
386            "expected DEPRECATION marker: {captured}"
387        );
388        assert!(
389            captured.contains("ignoring 'post:'"),
390            "conflict warning must mention 'ignoring post': {captured}"
391        );
392    }
393
394    /// Canonical `hooks:`-only block emits no warning and stays as-is.
395    #[test]
396    fn canonical_hooks_only_emits_no_warning() {
397        let captured = capture_warnings(|| {
398            let mut cfg = HooksConfig {
399                hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
400                post: None,
401            };
402            cfg.merge_hook_aliases();
403            assert!(cfg.post.is_none());
404            assert_eq!(cfg.hooks.as_deref().map(|v| v.len()), Some(1));
405        });
406        assert!(
407            !captured.contains("DEPRECATION"),
408            "canonical hooks-only must not warn: {captured}"
409        );
410    }
411
412    #[test]
413    fn filter_all_matches_every_kind() {
414        let f = BeforePublishArtifactFilter::All;
415        assert!(f.matches(ArtifactKind::Checksum));
416        assert!(f.matches(ArtifactKind::Binary));
417        assert!(f.matches(ArtifactKind::DockerManifest));
418        assert!(f.matches(ArtifactKind::Sbom));
419    }
420
421    #[test]
422    fn filter_default_is_all() {
423        assert_eq!(
424            BeforePublishArtifactFilter::default(),
425            BeforePublishArtifactFilter::All
426        );
427    }
428
429    #[test]
430    fn filter_source_buckets_all_source_kinds() {
431        let f = BeforePublishArtifactFilter::Source;
432        assert!(f.matches(ArtifactKind::SourceArchive));
433        assert!(f.matches(ArtifactKind::SourcePkgBuild));
434        assert!(f.matches(ArtifactKind::SourceSrcInfo));
435        assert!(f.matches(ArtifactKind::SourceRpm));
436        assert!(!f.matches(ArtifactKind::Archive));
437        assert!(!f.matches(ArtifactKind::Binary));
438    }
439
440    #[test]
441    fn filter_package_excludes_archives_and_binaries() {
442        let f = BeforePublishArtifactFilter::Package;
443        assert!(f.matches(ArtifactKind::LinuxPackage));
444        assert!(f.matches(ArtifactKind::Snap));
445        assert!(f.matches(ArtifactKind::PublishableSnapcraft));
446        assert!(f.matches(ArtifactKind::Flatpak));
447        assert!(!f.matches(ArtifactKind::Archive));
448        assert!(!f.matches(ArtifactKind::SourceRpm));
449    }
450
451    #[test]
452    fn filter_installer_covers_msi_and_macos_pkg() {
453        let f = BeforePublishArtifactFilter::Installer;
454        assert!(f.matches(ArtifactKind::Installer));
455        assert!(f.matches(ArtifactKind::MacOsPackage));
456        assert!(!f.matches(ArtifactKind::DiskImage));
457    }
458
459    #[test]
460    fn filter_archive_includes_makeself_but_not_source_archive() {
461        let f = BeforePublishArtifactFilter::Archive;
462        assert!(f.matches(ArtifactKind::Archive));
463        assert!(f.matches(ArtifactKind::Makeself));
464        assert!(!f.matches(ArtifactKind::SourceArchive));
465    }
466
467    #[test]
468    fn filter_binary_covers_three_binary_kinds() {
469        let f = BeforePublishArtifactFilter::Binary;
470        assert!(f.matches(ArtifactKind::Binary));
471        assert!(f.matches(ArtifactKind::UploadableBinary));
472        assert!(f.matches(ArtifactKind::UniversalBinary));
473        assert!(!f.matches(ArtifactKind::Library));
474    }
475
476    #[test]
477    fn filter_image_excludes_multiarch_manifest() {
478        let f = BeforePublishArtifactFilter::Image;
479        assert!(f.matches(ArtifactKind::DockerImage));
480        assert!(f.matches(ArtifactKind::DockerImageV2));
481        assert!(f.matches(ArtifactKind::PublishableDockerImage));
482        // multi-arch index entries are not scannable image blobs
483        assert!(!f.matches(ArtifactKind::DockerManifest));
484        assert!(!f.matches(ArtifactKind::DockerDigest));
485    }
486
487    #[test]
488    fn filter_narrow_variants_match_only_themselves() {
489        assert!(BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Checksum));
490        assert!(!BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Sbom));
491        assert!(BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::DiskImage));
492        assert!(!BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::Installer));
493        assert!(BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Sbom));
494        assert!(!BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Checksum));
495    }
496
497    #[test]
498    fn filter_deserializes_snake_case_and_diskimage_alias() {
499        let f: BeforePublishArtifactFilter = serde_yaml_ng::from_str("disk_image").unwrap();
500        assert_eq!(f, BeforePublishArtifactFilter::DiskImage);
501        // legacy single-word `diskimage` alias parses to the same variant
502        let aliased: BeforePublishArtifactFilter = serde_yaml_ng::from_str("diskimage").unwrap();
503        assert_eq!(aliased, BeforePublishArtifactFilter::DiskImage);
504    }
505
506    #[test]
507    fn hook_entry_string_deserializes_as_simple() {
508        let h: HookEntry = serde_yaml_ng::from_str("\"echo hi\"").unwrap();
509        assert!(matches!(h, HookEntry::Simple(ref s) if s == "echo hi"));
510    }
511
512    #[test]
513    fn hook_entry_object_deserializes_as_structured() {
514        let h: HookEntry = serde_yaml_ng::from_str("cmd: build.sh\ndir: subdir").unwrap();
515        match h {
516            HookEntry::Structured(s) => {
517                assert_eq!(s.cmd, "build.sh");
518                assert_eq!(s.dir.as_deref(), Some("subdir"));
519            }
520            HookEntry::Simple(_) => panic!("expected structured hook"),
521        }
522    }
523
524    #[test]
525    fn hook_entry_rejects_non_string_non_object() {
526        // a bare list is neither a command string nor a structured hook
527        let err = serde_yaml_ng::from_str::<HookEntry>("- a\n- b");
528        assert!(err.is_err());
529    }
530
531    #[test]
532    fn hook_entry_if_alias_maps_to_if_condition() {
533        let h: HookEntry = serde_yaml_ng::from_str("cmd: x\nif: \"{{ .IsSnapshot }}\"").unwrap();
534        match h {
535            HookEntry::Structured(s) => {
536                assert_eq!(s.if_condition.as_deref(), Some("{{ .IsSnapshot }}"));
537            }
538            HookEntry::Simple(_) => panic!("expected structured hook"),
539        }
540    }
541
542    #[test]
543    fn hook_entry_partial_eq_str_matches_both_variants() {
544        assert!(HookEntry::Simple("go test".to_string()) == "go test");
545        assert!(HookEntry::Simple("go test".to_string()) != "go vet");
546        let structured = HookEntry::Structured(StructuredHook {
547            cmd: "make lint".to_string(),
548            ..Default::default()
549        });
550        assert!(structured == "make lint");
551        assert!(structured != "make build");
552    }
553
554    #[test]
555    fn deserialize_then_serialize_drops_post_field() {
556        // post-only input folds into hooks: and serialization shows no post:
557        let cfg: HooksConfig = serde_yaml_ng::from_str("post:\n  - legacy.sh").unwrap();
558        assert!(cfg.post.is_none());
559        let out = serde_yaml_ng::to_string(&cfg).unwrap();
560        assert!(out.contains("hooks"), "serialized: {out}");
561        assert!(
562            !out.contains("post"),
563            "serialized must not carry post: {out}"
564        );
565    }
566
567    #[test]
568    fn empty_block_neither_spelling_stays_empty_and_silent() {
569        // (false, false) arm: empty block, nothing folds and nothing warns.
570        let captured = capture_warnings(|| {
571            let mut cfg = HooksConfig {
572                hooks: None,
573                post: None,
574            };
575            cfg.merge_hook_aliases();
576            assert!(cfg.hooks.is_none());
577            assert!(cfg.post.is_none());
578        });
579        assert!(
580            !captured.contains("DEPRECATION"),
581            "empty block must not warn: {captured}"
582        );
583    }
584
585    #[test]
586    fn empty_post_vec_does_not_trigger_fold_or_warn() {
587        // is_some_and(|v| !v.is_empty()) means an empty post vec is treated as
588        // absent — it must not fold into hooks nor warn.
589        let captured = capture_warnings(|| {
590            let mut cfg = HooksConfig {
591                hooks: None,
592                post: Some(vec![]),
593            };
594            cfg.merge_hook_aliases();
595            // empty post was NOT folded; it stays as the (now still-empty) post
596            assert!(cfg.hooks.is_none(), "empty post must not become hooks");
597        });
598        assert!(
599            !captured.contains("DEPRECATION"),
600            "empty post must not warn: {captured}"
601        );
602    }
603
604    #[test]
605    fn default_hooks_config_is_all_none() {
606        let cfg = HooksConfig::default();
607        assert!(cfg.hooks.is_none());
608        assert!(cfg.post.is_none());
609    }
610
611    #[test]
612    fn raw_deserialize_rejects_unknown_key() {
613        let err = serde_yaml_ng::from_str::<HooksConfig>("befor:\n  - x");
614        assert!(err.is_err(), "deny_unknown_fields on Raw must reject typos");
615    }
616
617    #[test]
618    fn structured_hook_full_fields_parse() {
619        let h: HookEntry = serde_yaml_ng::from_str(
620            "cmd: build.sh\nenv: [FOO=1, BAR=2]\noutput: true\nids: [linux-bin]\nartifacts: binary\n",
621        )
622        .unwrap();
623        match h {
624            HookEntry::Structured(s) => {
625                assert_eq!(s.cmd, "build.sh");
626                assert_eq!(
627                    s.env.as_deref(),
628                    Some(&["FOO=1".to_string(), "BAR=2".to_string()][..])
629                );
630                assert_eq!(s.output, Some(true));
631                assert_eq!(s.ids.as_deref(), Some(&["linux-bin".to_string()][..]));
632                assert_eq!(s.artifacts, Some(BeforePublishArtifactFilter::Binary));
633            }
634            HookEntry::Simple(_) => panic!("expected structured hook"),
635        }
636    }
637
638    #[test]
639    fn structured_hook_rejects_unknown_field() {
640        // StructuredHook is deny_unknown_fields; deserialize routes objects here.
641        let err = serde_yaml_ng::from_str::<HookEntry>("cmd: x\nbogus: 1");
642        assert!(
643            err.is_err(),
644            "unknown structured-hook field must be rejected"
645        );
646    }
647
648    #[test]
649    fn hook_entry_simple_serializes_untagged_as_bare_string() {
650        let h = HookEntry::Simple("echo hi".to_string());
651        let out = serde_yaml_ng::to_string(&h).unwrap();
652        // untagged: a Simple serializes to a bare scalar, not a tagged map
653        assert_eq!(out.trim(), "echo hi");
654    }
655
656    #[test]
657    fn hook_entry_structured_serializes_to_mapping() {
658        let h = HookEntry::Structured(StructuredHook {
659            cmd: "make".to_string(),
660            output: Some(true),
661            ..Default::default()
662        });
663        let out = serde_yaml_ng::to_string(&h).unwrap();
664        assert!(out.contains("cmd: make"), "serialized: {out}");
665        assert!(out.contains("output: true"), "serialized: {out}");
666    }
667
668    #[test]
669    fn filter_installer_distinct_from_package_and_diskimage() {
670        // Installer must NOT swallow package/diskimage kinds — guards the
671        // most error-prone overlap in the matches() table.
672        let f = BeforePublishArtifactFilter::Installer;
673        assert!(!f.matches(ArtifactKind::LinuxPackage));
674        assert!(!f.matches(ArtifactKind::DiskImage));
675        assert!(!f.matches(ArtifactKind::Archive));
676    }
677}