anodizer_core/pipe_skip.rs
1//! Aggregated per-stage skip tracking.
2//!
3//! Mirrors GoReleaser's `internal/pipe/pipe.go::SkipMemento`. Pipeline
4//! stages that iterate multiple sub-configs (signs, docker_signs, custom
5//! publishers, archives, nfpms, …) occasionally need to skip a sub-config
6//! for a legitimate reason: `artifacts: none`, an `if:` conditional that
7//! rendered to `"false"`, an `ids` filter that matched nothing, an empty
8//! `cmd`. Before this module those skips used a bare `continue;` — the
9//! end-of-pipeline summary lost all visibility into intentional skips, so a
10//! misconfigured sign block and a deliberately-disabled one looked
11//! identical in the logs.
12//!
13//! `SkipMemento` collects a (stage, config_label, reason) tuple per skip.
14//! The pipeline runner drains it at end-of-pipeline and prints a grouped
15//! summary so users know which sub-configs were intentionally skipped.
16
17use std::sync::{Arc, Mutex};
18
19/// A single skip event: which stage, which sub-config, and why.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SkipEvent {
22 /// Stage that raised the skip (`sign`, `docker-sign`, `publisher`, …).
23 pub stage: String,
24 /// Human-readable label for the sub-config (e.g. the sign config's `id`,
25 /// the publisher's `name`, or a positional `publisher[2]`).
26 pub label: String,
27 /// Reason the skip happened (short, single-line).
28 pub reason: String,
29}
30
31/// Thread-safe aggregator. Cheap to clone (wraps `Arc<Mutex<…>>`).
32///
33/// Stages record via `remember`. The pipeline runner calls `drain` at
34/// end-of-pipeline to print the summary. Duplicate `(stage, label, reason)`
35/// tuples are dropped on insert so a per-artifact inner `continue` doesn't
36/// emit N copies of the same skip message.
37#[derive(Debug, Clone, Default)]
38pub struct SkipMemento {
39 inner: Arc<Mutex<Vec<SkipEvent>>>,
40}
41
42impl SkipMemento {
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 /// Record a skip. Duplicate `(stage, label, reason)` tuples are dropped.
48 pub fn remember(&self, stage: &str, label: &str, reason: &str) {
49 let event = SkipEvent {
50 stage: stage.to_string(),
51 label: label.to_string(),
52 reason: reason.to_string(),
53 };
54 let mut guard = match self.inner.lock() {
55 Ok(g) => g,
56 Err(poisoned) => poisoned.into_inner(),
57 };
58 if !guard.iter().any(|e| e == &event) {
59 guard.push(event);
60 }
61 }
62
63 /// Current number of recorded skips. Useful for tests and the summary
64 /// header (`"3 intentional skips"`).
65 pub fn len(&self) -> usize {
66 self.inner.lock().map(|g| g.len()).unwrap_or(0)
67 }
68
69 pub fn is_empty(&self) -> bool {
70 self.len() == 0
71 }
72
73 /// Take a snapshot of recorded skips without clearing. Used by tests.
74 pub fn snapshot(&self) -> Vec<SkipEvent> {
75 self.inner.lock().map(|g| g.clone()).unwrap_or_default()
76 }
77
78 /// Drain all recorded skips, leaving the memento empty.
79 pub fn drain(&self) -> Vec<SkipEvent> {
80 self.inner
81 .lock()
82 .map(|mut g| std::mem::take(&mut *g))
83 .unwrap_or_default()
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn records_and_drains() {
93 let m = SkipMemento::new();
94 assert!(m.is_empty());
95 m.remember("sign", "cosign", "artifacts: none");
96 m.remember("sign", "gpg", "if: false");
97 assert_eq!(m.len(), 2);
98
99 let drained = m.drain();
100 assert_eq!(drained.len(), 2);
101 assert_eq!(drained[0].stage, "sign");
102 assert_eq!(drained[0].label, "cosign");
103 assert_eq!(drained[0].reason, "artifacts: none");
104 assert!(m.is_empty());
105 }
106
107 #[test]
108 fn deduplicates_identical_events() {
109 let m = SkipMemento::new();
110 // A per-artifact inner loop may fire "ids filter" skip N times for
111 // the same sign config; only one summary line should survive.
112 for _ in 0..10 {
113 m.remember("sign", "cosign", "ids filter matched no artifacts");
114 }
115 assert_eq!(m.len(), 1);
116 }
117
118 #[test]
119 fn keeps_distinct_reasons_per_label() {
120 let m = SkipMemento::new();
121 m.remember("sign", "cosign", "artifacts: none");
122 m.remember("sign", "cosign", "if: false");
123 // Same label, different reasons → both survive so the user sees
124 // each distinct skip path.
125 assert_eq!(m.len(), 2);
126 }
127
128 #[test]
129 fn snapshot_does_not_clear() {
130 let m = SkipMemento::new();
131 m.remember("publisher", "my-tool", "empty cmd");
132 let snap = m.snapshot();
133 assert_eq!(snap.len(), 1);
134 // Still present after snapshot.
135 assert_eq!(m.len(), 1);
136 }
137
138 #[test]
139 fn clone_shares_state() {
140 let m = SkipMemento::new();
141 let m2 = m.clone();
142 m2.remember("docker-sign", "cosign-docker", "artifacts: none");
143 assert_eq!(m.len(), 1);
144 assert_eq!(m.snapshot(), m2.snapshot());
145 }
146}