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    /// Run the hook exactly once before the publish stage instead of once
147    /// per matching artifact (`before_publish:` only). With the default
148    /// `false`, the hook keeps the per-artifact semantics: it fires once for
149    /// each artifact passing the `ids` / `artifacts` filters, with the
150    /// per-artifact template vars (`ArtifactName`, `ArtifactPath`, ...) and
151    /// the `$ANODIZER_ARTIFACT` channel bound. With `true`, the hook runs a
152    /// single time with the run-level template vars (`Version`, `Tag`, ...);
153    /// the `ids` / `artifacts` filters and the per-artifact vars do not apply,
154    /// so the command is expected to iterate the dist directory itself. A
155    /// Rust-additive extension with no GoReleaser counterpart.
156    #[serde(default)]
157    pub run_once: bool,
158}
159
160/// Artifact-type filter for `before_publish[*].artifacts`.
161///
162/// The `before_publish[*].artifacts` enum
163/// (`checksum` / `source` / `package` / `installer` / `diskimage` /
164/// `archive` / `binary` / `sbom` / `image` / `all`). Maps each variant
165/// to a predicate over [`ArtifactKind`]:
166///
167/// | Variant | Matched [`ArtifactKind`] values |
168/// |---|---|
169/// | `Checksum` | `Checksum` |
170/// | `Source` | `SourceArchive`, `SourcePkgBuild`, `SourceSrcInfo`, `SourceRpm` |
171/// | `Package` | `LinuxPackage`, `Snap`, `PublishableSnapcraft`, `Flatpak` |
172/// | `Installer` | `Installer`, `MacOsPackage` |
173/// | `DiskImage` | `DiskImage` |
174/// | `Archive` | `Archive`, `Makeself` |
175/// | `Binary` | `Binary`, `UploadableBinary`, `UniversalBinary` |
176/// | `Sbom` | `Sbom` |
177/// | `Image` | `DockerImage`, `DockerImageV2`, `PublishableDockerImage` (multi-arch `DockerManifest` excluded — covers individual images only) |
178/// | `All` | every kind |
179///
180/// Mapping notes:
181/// - `Source` includes RPM-source variants since all source-derived
182///   artifacts under one bucket.
183/// - `Installer` covers macOS `.pkg` (`MacOsPackage`) alongside Windows
184///   MSI/NSIS — "installer" is used without OS qualification.
185/// - `Image` deliberately excludes [`ArtifactKind::DockerManifest`] /
186///   [`ArtifactKind::DockerDigest`]; those are multi-arch index entries
187///   and don't correspond to a scannable image blob. The
188///   per-image hook semantics (the multi-arch manifest is published
189///   separately and isn't a vulnerability scan target).
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
191#[serde(rename_all = "snake_case")]
192pub enum BeforePublishArtifactFilter {
193    Checksum,
194    Source,
195    Package,
196    Installer,
197    #[serde(alias = "diskimage")]
198    DiskImage,
199    Archive,
200    Binary,
201    Sbom,
202    Image,
203    #[default]
204    All,
205}
206
207impl BeforePublishArtifactFilter {
208    /// Returns `true` when `kind` should run the hook under this filter.
209    pub fn matches(self, kind: ArtifactKind) -> bool {
210        match self {
211            Self::All => true,
212            Self::Checksum => matches!(kind, ArtifactKind::Checksum),
213            Self::Source => matches!(
214                kind,
215                ArtifactKind::SourceArchive
216                    | ArtifactKind::SourcePkgBuild
217                    | ArtifactKind::SourceSrcInfo
218                    | ArtifactKind::SourceRpm
219            ),
220            Self::Package => matches!(
221                kind,
222                ArtifactKind::LinuxPackage
223                    | ArtifactKind::Snap
224                    | ArtifactKind::PublishableSnapcraft
225                    | ArtifactKind::Flatpak
226            ),
227            Self::Installer => matches!(kind, ArtifactKind::Installer | ArtifactKind::MacOsPackage),
228            Self::DiskImage => matches!(kind, ArtifactKind::DiskImage),
229            Self::Archive => matches!(kind, ArtifactKind::Archive | ArtifactKind::Makeself),
230            Self::Binary => matches!(
231                kind,
232                ArtifactKind::Binary
233                    | ArtifactKind::UploadableBinary
234                    | ArtifactKind::UniversalBinary
235            ),
236            Self::Sbom => matches!(kind, ArtifactKind::Sbom),
237            Self::Image => matches!(
238                kind,
239                ArtifactKind::DockerImage
240                    | ArtifactKind::DockerImageV2
241                    | ArtifactKind::PublishableDockerImage
242            ),
243        }
244    }
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
248#[serde(untagged)]
249pub enum HookEntry {
250    Simple(String),
251    Structured(StructuredHook),
252}
253
254impl PartialEq<&str> for HookEntry {
255    fn eq(&self, other: &&str) -> bool {
256        match self {
257            HookEntry::Simple(s) => s.as_str() == *other,
258            HookEntry::Structured(h) => h.cmd.as_str() == *other,
259        }
260    }
261}
262
263impl<'de> Deserialize<'de> for HookEntry {
264    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
265    where
266        D: Deserializer<'de>,
267    {
268        let value = serde_json::Value::deserialize(deserializer)?;
269        match &value {
270            serde_json::Value::String(s) => Ok(HookEntry::Simple(s.clone())),
271            serde_json::Value::Object(_) => {
272                let hook: StructuredHook =
273                    serde_json::from_value(value).map_err(serde::de::Error::custom)?;
274                Ok(HookEntry::Structured(hook))
275            }
276            _ => Err(serde::de::Error::custom(
277                "hook entry must be a string or an object with cmd/dir/env/output",
278            )),
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use std::io;
287    use std::sync::{Arc, Mutex, MutexGuard};
288    use tracing::subscriber::with_default;
289    use tracing_subscriber::fmt::MakeWriter;
290
291    /// Shared buffer writer that captures `tracing` output into a `Vec<u8>`.
292    #[derive(Clone, Default)]
293    struct BufferWriter(Arc<Mutex<Vec<u8>>>);
294
295    impl BufferWriter {
296        fn captured(&self) -> String {
297            String::from_utf8_lossy(&self.0.lock().unwrap()).to_string()
298        }
299    }
300
301    impl io::Write for BufferWriter {
302        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
303            self.0.lock().unwrap().extend_from_slice(buf);
304            Ok(buf.len())
305        }
306        fn flush(&mut self) -> io::Result<()> {
307            Ok(())
308        }
309    }
310
311    /// `BufferWriterHandle` matches the `MakeWriter` contract: each
312    /// `make_writer` call returns a cheap clone that writes into the
313    /// same `Arc<Mutex<Vec<u8>>>` as every other clone.
314    impl<'a> MakeWriter<'a> for BufferWriter {
315        type Writer = BufferWriterGuard<'a>;
316        fn make_writer(&'a self) -> Self::Writer {
317            BufferWriterGuard(self.0.lock().unwrap())
318        }
319    }
320
321    struct BufferWriterGuard<'a>(MutexGuard<'a, Vec<u8>>);
322    impl io::Write for BufferWriterGuard<'_> {
323        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
324            self.0.extend_from_slice(buf);
325            Ok(buf.len())
326        }
327        fn flush(&mut self) -> io::Result<()> {
328            Ok(())
329        }
330    }
331
332    /// Run `body` with a tracing subscriber that captures every event into
333    /// the returned buffer; assertions then inspect the captured text.
334    fn capture_warnings<F: FnOnce()>(body: F) -> String {
335        let buf = BufferWriter::default();
336        let subscriber = tracing_subscriber::fmt()
337            .with_writer(buf.clone())
338            .with_max_level(tracing::Level::WARN)
339            .without_time()
340            .with_ansi(false)
341            .finish();
342        with_default(subscriber, body);
343        buf.captured()
344    }
345
346    /// Legacy-only spelling (post: set, hooks: unset) folds into `hooks`
347    /// and emits a DEPRECATION warning that points at the canonical name.
348    #[test]
349    fn legacy_post_only_folds_and_warns() {
350        let captured = capture_warnings(|| {
351            let mut cfg = HooksConfig {
352                hooks: None,
353                post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
354            };
355            cfg.merge_hook_aliases();
356            assert_eq!(
357                cfg.hooks.as_deref().map(|v| v.len()),
358                Some(1),
359                "post: should have moved into hooks:"
360            );
361            assert!(cfg.post.is_none(), "post: must be cleared after merge");
362        });
363        assert!(
364            captured.contains("DEPRECATION"),
365            "expected DEPRECATION marker in warning: {captured}"
366        );
367        assert!(
368            captured.contains("renamed to 'hooks:'"),
369            "legacy-only warning must guide to 'hooks:' rename: {captured}"
370        );
371    }
372
373    /// Both spellings set: `hooks:` wins, `post:` is dropped, and the
374    /// emitted warning explicitly calls out the conflict.
375    #[test]
376    fn both_present_keeps_hooks_drops_post_and_warns() {
377        let captured = capture_warnings(|| {
378            let mut cfg = HooksConfig {
379                hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
380                post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
381            };
382            cfg.merge_hook_aliases();
383            assert!(cfg.post.is_none(), "post: must be cleared on conflict");
384            let names: Vec<&str> = cfg
385                .hooks
386                .as_deref()
387                .unwrap()
388                .iter()
389                .map(|h| match h {
390                    HookEntry::Simple(s) => s.as_str(),
391                    HookEntry::Structured(s) => s.cmd.as_str(),
392                })
393                .collect();
394            assert_eq!(names, vec!["modern.sh"], "hooks: must win on conflict");
395        });
396        assert!(
397            captured.contains("DEPRECATION"),
398            "expected DEPRECATION marker: {captured}"
399        );
400        assert!(
401            captured.contains("ignoring 'post:'"),
402            "conflict warning must mention 'ignoring post': {captured}"
403        );
404    }
405
406    /// Canonical `hooks:`-only block emits no warning and stays as-is.
407    #[test]
408    fn canonical_hooks_only_emits_no_warning() {
409        let captured = capture_warnings(|| {
410            let mut cfg = HooksConfig {
411                hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
412                post: None,
413            };
414            cfg.merge_hook_aliases();
415            assert!(cfg.post.is_none());
416            assert_eq!(cfg.hooks.as_deref().map(|v| v.len()), Some(1));
417        });
418        assert!(
419            !captured.contains("DEPRECATION"),
420            "canonical hooks-only must not warn: {captured}"
421        );
422    }
423
424    #[test]
425    fn filter_all_matches_every_kind() {
426        let f = BeforePublishArtifactFilter::All;
427        assert!(f.matches(ArtifactKind::Checksum));
428        assert!(f.matches(ArtifactKind::Binary));
429        assert!(f.matches(ArtifactKind::DockerManifest));
430        assert!(f.matches(ArtifactKind::Sbom));
431    }
432
433    #[test]
434    fn filter_default_is_all() {
435        assert_eq!(
436            BeforePublishArtifactFilter::default(),
437            BeforePublishArtifactFilter::All
438        );
439    }
440
441    #[test]
442    fn filter_source_buckets_all_source_kinds() {
443        let f = BeforePublishArtifactFilter::Source;
444        assert!(f.matches(ArtifactKind::SourceArchive));
445        assert!(f.matches(ArtifactKind::SourcePkgBuild));
446        assert!(f.matches(ArtifactKind::SourceSrcInfo));
447        assert!(f.matches(ArtifactKind::SourceRpm));
448        assert!(!f.matches(ArtifactKind::Archive));
449        assert!(!f.matches(ArtifactKind::Binary));
450    }
451
452    #[test]
453    fn filter_package_excludes_archives_and_binaries() {
454        let f = BeforePublishArtifactFilter::Package;
455        assert!(f.matches(ArtifactKind::LinuxPackage));
456        assert!(f.matches(ArtifactKind::Snap));
457        assert!(f.matches(ArtifactKind::PublishableSnapcraft));
458        assert!(f.matches(ArtifactKind::Flatpak));
459        assert!(!f.matches(ArtifactKind::Archive));
460        assert!(!f.matches(ArtifactKind::SourceRpm));
461    }
462
463    #[test]
464    fn filter_installer_covers_msi_and_macos_pkg() {
465        let f = BeforePublishArtifactFilter::Installer;
466        assert!(f.matches(ArtifactKind::Installer));
467        assert!(f.matches(ArtifactKind::MacOsPackage));
468        assert!(!f.matches(ArtifactKind::DiskImage));
469    }
470
471    #[test]
472    fn filter_archive_includes_makeself_but_not_source_archive() {
473        let f = BeforePublishArtifactFilter::Archive;
474        assert!(f.matches(ArtifactKind::Archive));
475        assert!(f.matches(ArtifactKind::Makeself));
476        assert!(!f.matches(ArtifactKind::SourceArchive));
477    }
478
479    #[test]
480    fn filter_binary_covers_three_binary_kinds() {
481        let f = BeforePublishArtifactFilter::Binary;
482        assert!(f.matches(ArtifactKind::Binary));
483        assert!(f.matches(ArtifactKind::UploadableBinary));
484        assert!(f.matches(ArtifactKind::UniversalBinary));
485        assert!(!f.matches(ArtifactKind::Library));
486    }
487
488    #[test]
489    fn filter_image_excludes_multiarch_manifest() {
490        let f = BeforePublishArtifactFilter::Image;
491        assert!(f.matches(ArtifactKind::DockerImage));
492        assert!(f.matches(ArtifactKind::DockerImageV2));
493        assert!(f.matches(ArtifactKind::PublishableDockerImage));
494        // multi-arch index entries are not scannable image blobs
495        assert!(!f.matches(ArtifactKind::DockerManifest));
496        assert!(!f.matches(ArtifactKind::DockerDigest));
497    }
498
499    #[test]
500    fn filter_narrow_variants_match_only_themselves() {
501        assert!(BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Checksum));
502        assert!(!BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Sbom));
503        assert!(BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::DiskImage));
504        assert!(!BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::Installer));
505        assert!(BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Sbom));
506        assert!(!BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Checksum));
507    }
508
509    #[test]
510    fn filter_deserializes_snake_case_and_diskimage_alias() {
511        let f: BeforePublishArtifactFilter = serde_yaml_ng::from_str("disk_image").unwrap();
512        assert_eq!(f, BeforePublishArtifactFilter::DiskImage);
513        // legacy single-word `diskimage` alias parses to the same variant
514        let aliased: BeforePublishArtifactFilter = serde_yaml_ng::from_str("diskimage").unwrap();
515        assert_eq!(aliased, BeforePublishArtifactFilter::DiskImage);
516    }
517
518    #[test]
519    fn hook_entry_string_deserializes_as_simple() {
520        let h: HookEntry = serde_yaml_ng::from_str("\"echo hi\"").unwrap();
521        assert!(matches!(h, HookEntry::Simple(ref s) if s == "echo hi"));
522    }
523
524    #[test]
525    fn hook_entry_object_deserializes_as_structured() {
526        let h: HookEntry = serde_yaml_ng::from_str("cmd: build.sh\ndir: subdir").unwrap();
527        match h {
528            HookEntry::Structured(s) => {
529                assert_eq!(s.cmd, "build.sh");
530                assert_eq!(s.dir.as_deref(), Some("subdir"));
531            }
532            HookEntry::Simple(_) => panic!("expected structured hook"),
533        }
534    }
535
536    #[test]
537    fn hook_entry_rejects_non_string_non_object() {
538        // a bare list is neither a command string nor a structured hook
539        let err = serde_yaml_ng::from_str::<HookEntry>("- a\n- b");
540        assert!(err.is_err());
541    }
542
543    #[test]
544    fn hook_entry_if_alias_maps_to_if_condition() {
545        let h: HookEntry = serde_yaml_ng::from_str("cmd: x\nif: \"{{ .IsSnapshot }}\"").unwrap();
546        match h {
547            HookEntry::Structured(s) => {
548                assert_eq!(s.if_condition.as_deref(), Some("{{ .IsSnapshot }}"));
549            }
550            HookEntry::Simple(_) => panic!("expected structured hook"),
551        }
552    }
553
554    #[test]
555    fn hook_entry_partial_eq_str_matches_both_variants() {
556        assert!(HookEntry::Simple("go test".to_string()) == "go test");
557        assert!(HookEntry::Simple("go test".to_string()) != "go vet");
558        let structured = HookEntry::Structured(StructuredHook {
559            cmd: "make lint".to_string(),
560            ..Default::default()
561        });
562        assert!(structured == "make lint");
563        assert!(structured != "make build");
564    }
565
566    #[test]
567    fn deserialize_then_serialize_drops_post_field() {
568        // post-only input folds into hooks: and serialization shows no post:
569        let cfg: HooksConfig = serde_yaml_ng::from_str("post:\n  - legacy.sh").unwrap();
570        assert!(cfg.post.is_none());
571        let out = serde_yaml_ng::to_string(&cfg).unwrap();
572        assert!(out.contains("hooks"), "serialized: {out}");
573        assert!(
574            !out.contains("post"),
575            "serialized must not carry post: {out}"
576        );
577    }
578
579    #[test]
580    fn empty_block_neither_spelling_stays_empty_and_silent() {
581        // (false, false) arm: empty block, nothing folds and nothing warns.
582        let captured = capture_warnings(|| {
583            let mut cfg = HooksConfig {
584                hooks: None,
585                post: None,
586            };
587            cfg.merge_hook_aliases();
588            assert!(cfg.hooks.is_none());
589            assert!(cfg.post.is_none());
590        });
591        assert!(
592            !captured.contains("DEPRECATION"),
593            "empty block must not warn: {captured}"
594        );
595    }
596
597    #[test]
598    fn empty_post_vec_does_not_trigger_fold_or_warn() {
599        // is_some_and(|v| !v.is_empty()) means an empty post vec is treated as
600        // absent — it must not fold into hooks nor warn.
601        let captured = capture_warnings(|| {
602            let mut cfg = HooksConfig {
603                hooks: None,
604                post: Some(vec![]),
605            };
606            cfg.merge_hook_aliases();
607            // empty post was NOT folded; it stays as the (now still-empty) post
608            assert!(cfg.hooks.is_none(), "empty post must not become hooks");
609        });
610        assert!(
611            !captured.contains("DEPRECATION"),
612            "empty post must not warn: {captured}"
613        );
614    }
615
616    #[test]
617    fn default_hooks_config_is_all_none() {
618        let cfg = HooksConfig::default();
619        assert!(cfg.hooks.is_none());
620        assert!(cfg.post.is_none());
621    }
622
623    #[test]
624    fn raw_deserialize_rejects_unknown_key() {
625        let err = serde_yaml_ng::from_str::<HooksConfig>("befor:\n  - x");
626        assert!(err.is_err(), "deny_unknown_fields on Raw must reject typos");
627    }
628
629    #[test]
630    fn structured_hook_full_fields_parse() {
631        let h: HookEntry = serde_yaml_ng::from_str(
632            "cmd: build.sh\nenv: [FOO=1, BAR=2]\noutput: true\nids: [linux-bin]\nartifacts: binary\n",
633        )
634        .unwrap();
635        match h {
636            HookEntry::Structured(s) => {
637                assert_eq!(s.cmd, "build.sh");
638                assert_eq!(
639                    s.env.as_deref(),
640                    Some(&["FOO=1".to_string(), "BAR=2".to_string()][..])
641                );
642                assert_eq!(s.output, Some(true));
643                assert_eq!(s.ids.as_deref(), Some(&["linux-bin".to_string()][..]));
644                assert_eq!(s.artifacts, Some(BeforePublishArtifactFilter::Binary));
645            }
646            HookEntry::Simple(_) => panic!("expected structured hook"),
647        }
648    }
649
650    #[test]
651    fn structured_hook_rejects_unknown_field() {
652        // StructuredHook is deny_unknown_fields; deserialize routes objects here.
653        let err = serde_yaml_ng::from_str::<HookEntry>("cmd: x\nbogus: 1");
654        assert!(
655            err.is_err(),
656            "unknown structured-hook field must be rejected"
657        );
658    }
659
660    #[test]
661    fn hook_entry_simple_serializes_untagged_as_bare_string() {
662        let h = HookEntry::Simple("echo hi".to_string());
663        let out = serde_yaml_ng::to_string(&h).unwrap();
664        // untagged: a Simple serializes to a bare scalar, not a tagged map
665        assert_eq!(out.trim(), "echo hi");
666    }
667
668    #[test]
669    fn hook_entry_structured_serializes_to_mapping() {
670        let h = HookEntry::Structured(StructuredHook {
671            cmd: "make".to_string(),
672            output: Some(true),
673            ..Default::default()
674        });
675        let out = serde_yaml_ng::to_string(&h).unwrap();
676        assert!(out.contains("cmd: make"), "serialized: {out}");
677        assert!(out.contains("output: true"), "serialized: {out}");
678    }
679
680    #[test]
681    fn filter_installer_distinct_from_package_and_diskimage() {
682        // Installer must NOT swallow package/diskimage kinds — guards the
683        // most error-prone overlap in the matches() table.
684        let f = BeforePublishArtifactFilter::Installer;
685        assert!(!f.matches(ArtifactKind::LinuxPackage));
686        assert!(!f.matches(ArtifactKind::DiskImage));
687        assert!(!f.matches(ArtifactKind::Archive));
688    }
689}