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