Skip to main content

anodizer_core/config/
announce.rs

1use std::collections::HashMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::{HumanDuration, StringOrBool, deserialize_string_or_bool_opt};
7
8/// Default overall announce-stage deadline when `announce.deadline` is unset.
9///
10/// Announce is a best-effort post-publish notification: every configured
11/// channel runs concurrently, each bounded by the per-call HTTP/SMTP timeout
12/// and the announce retry profile. This caps the *aggregate* stage so a set of
13/// unreachable channels (e.g. external endpoints on an egress-firewalled
14/// self-hosted runner) cannot accumulate into a multi-minute hang that trips
15/// the pipeline timeout AFTER the release already published. Stragglers past
16/// this deadline are abandoned with a warning, never awaited forever.
17pub const DEFAULT_ANNOUNCE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(90);
18
19/// Floor for the resolved announce deadline.
20///
21/// An explicit `deadline: "0s"` (or any sub-second value) would let the runner
22/// abandon every channel on its first scheduling tick — traffic still leaves,
23/// but every result is warned as "did not complete", which is a useless and
24/// surprising state. Clamping the resolved deadline up to this floor makes that
25/// footgun unrepresentable: the smallest meaningful aggregate budget is one
26/// second, enough for a reachable local relay to report.
27pub const MIN_ANNOUNCE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(1);
28
29// ---------------------------------------------------------------------------
30// AnnounceConfig
31// ---------------------------------------------------------------------------
32
33/// Announce-stage gate semantics.
34///
35/// Decides whether [`AnnounceStage`] runs based on the `PublishReport`
36/// produced by `PublishStage` (and contributed to by `BlobStage`):
37///
38/// - `required_publishers` (default): announce runs only if every
39///   `required: true` publisher across the run succeeded.
40/// - `all_publishers`: announce runs only if every configured
41///   publisher succeeded (Submitter gate failures count here too).
42/// - `none`: announce always runs.
43///
44/// [`AnnounceStage`]: ../../stage-announce/struct.AnnounceStage.html
45#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
46#[serde(rename_all = "snake_case")]
47pub enum AnnounceGate {
48    #[default]
49    RequiredPublishers,
50    AllPublishers,
51    None,
52}
53
54/// Announce-stage integrations.
55///
56/// Message bodies are secret-redacted before send: known secret env values
57/// are masked (a real token becomes `$NAME`). Redaction is on by default;
58/// `anodizer notify --allow-secrets` opts a single send out for a trusted
59/// private channel, while anodizer's own log output stays redacted regardless.
60#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
61#[serde(default, deny_unknown_fields)]
62pub struct AnnounceConfig {
63    /// Template-conditional skip: if rendered to "true", skip the entire announce stage.
64    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
65    pub skip: Option<StringOrBool>,
66    /// Template-conditional gate: when the rendered result is falsy
67    /// (`"false"` / `"0"` / `"no"` / empty), the entire announce stage is
68    /// skipped. Render failure hard-errors. The
69    /// `announce.if:`. Distinct from `skip:` (always-skip predicate) — both
70    /// surfaces are documented.
71    #[serde(rename = "if")]
72    pub if_condition: Option<String>,
73    /// Selects when AnnounceStage runs vs. skips based on the
74    /// `PublishReport` written by PublishStage/BlobStage. Default is
75    /// `required_publishers` (announce only if every required publisher
76    /// succeeded). See [`AnnounceGate`] for the other variants.
77    #[serde(default)]
78    pub gate_on: AnnounceGate,
79    /// Overall wall-clock deadline for the announce stage (e.g. `"90s"`,
80    /// `"2m"`). Optional — defaults to [`DEFAULT_ANNOUNCE_DEADLINE`] (90s).
81    ///
82    /// Announcers run concurrently; any still running when this deadline
83    /// elapses is abandoned with a warning rather than awaited. This bounds the
84    /// stage so unreachable channels cannot accumulate into a hang that trips
85    /// the pipeline timeout *after* publishers already crossed one-way doors.
86    /// Raise it only if a slow-but-reachable channel legitimately needs longer.
87    pub deadline: Option<HumanDuration>,
88    /// Discord announcement configuration.
89    pub discord: Option<DiscordAnnounce>,
90    /// Discourse announcement configuration.
91    pub discourse: Option<DiscourseAnnounce>,
92    /// Slack announcement configuration.
93    pub slack: Option<SlackAnnounce>,
94    /// Generic webhook announcement configuration.
95    pub webhook: Option<WebhookConfig>,
96    /// Telegram announcement configuration.
97    pub telegram: Option<TelegramAnnounce>,
98    /// Microsoft Teams announcement configuration.
99    pub teams: Option<TeamsAnnounce>,
100    /// Mattermost announcement configuration.
101    pub mattermost: Option<MattermostAnnounce>,
102    /// Email announcement configuration. accepts the
103    /// historical `smtp:` key as an alias because the field was renamed
104    /// `smtp:` -> `email:` in v1.21+ and kept the alias for migration.
105    /// Keeping the alias avoids forcing a re-yaml of legacy configs.
106    #[serde(alias = "smtp")]
107    pub email: Option<EmailAnnounce>,
108    /// Reddit announcement configuration.
109    pub reddit: Option<RedditAnnounce>,
110    /// Twitter/X announcement configuration.
111    pub twitter: Option<TwitterAnnounce>,
112    /// Mastodon announcement configuration.
113    pub mastodon: Option<MastodonAnnounce>,
114    /// Bluesky announcement configuration.
115    pub bluesky: Option<BlueskyAnnounce>,
116    /// LinkedIn announcement configuration.
117    pub linkedin: Option<LinkedInAnnounce>,
118    /// OpenCollective announcement configuration.
119    pub opencollective: Option<OpenCollectiveAnnounce>,
120}
121
122impl AnnounceConfig {
123    /// Resolve the overall announce-stage deadline, falling back to
124    /// [`DEFAULT_ANNOUNCE_DEADLINE`] when `deadline:` is unset.
125    ///
126    /// An explicit but degenerate value (`"0s"` or anything below
127    /// [`MIN_ANNOUNCE_DEADLINE`]) is clamped UP to the floor: a zero/sub-second
128    /// deadline would make the runner abandon every channel on the first tick
129    /// (traffic leaves, every result warned as "did not complete"), so it is
130    /// raised to the smallest budget under which a reachable channel can report.
131    pub fn deadline_duration(&self) -> std::time::Duration {
132        self.deadline
133            .map(|d| d.duration())
134            .unwrap_or(DEFAULT_ANNOUNCE_DEADLINE)
135            .max(MIN_ANNOUNCE_DEADLINE)
136    }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
140#[serde(default, deny_unknown_fields)]
141pub struct BlueskyAnnounce {
142    /// Enable Bluesky announcements (supports template expressions).
143    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
144    pub enabled: Option<StringOrBool>,
145    /// Bluesky handle/username (e.g. "user.bsky.social").
146    pub username: Option<String>,
147    /// Message template for the post. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
148    pub message_template: Option<String>,
149    /// Override the Bluesky PDS (Personal Data Server) URL. Defaults to
150    /// `https://bsky.social`. Set this to point at a self-hosted PDS or
151    /// alternative instance (e.g. `https://pds.example.com`).
152    pub pds_url: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
156#[serde(default, deny_unknown_fields)]
157pub struct DiscourseAnnounce {
158    /// Enable Discourse announcements (supports template expressions).
159    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
160    pub enabled: Option<StringOrBool>,
161    /// Discourse forum URL (e.g. `https://forum.example.com`).
162    pub server: Option<String>,
163    /// Category ID to post in (required, must be non-zero).
164    pub category_id: Option<u64>,
165    /// Username for the API request (default: "system").
166    pub username: Option<String>,
167    /// Title template for the forum topic. Default: "{{ ProjectName }} {{ Tag }} is out!"
168    pub title_template: Option<String>,
169    /// Message body template for the forum topic. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
170    pub message_template: Option<String>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
174#[serde(default, deny_unknown_fields)]
175pub struct LinkedInAnnounce {
176    /// Enable LinkedIn announcements. Requires LINKEDIN_ACCESS_TOKEN env var (supports template expressions).
177    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
178    pub enabled: Option<StringOrBool>,
179    /// Message template for the LinkedIn share post. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
180    pub message_template: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
184#[serde(default, deny_unknown_fields)]
185pub struct OpenCollectiveAnnounce {
186    /// Enable OpenCollective announcements. Requires OPENCOLLECTIVE_TOKEN env var (supports template expressions).
187    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
188    pub enabled: Option<StringOrBool>,
189    /// Collective slug (e.g. "my-project").
190    pub slug: Option<String>,
191    /// Title template for the update. Default: "{{ Tag }}"
192    pub title_template: Option<String>,
193    /// HTML message template for the update. Default includes `<br/>` and `<a>` tags with ReleaseURL.
194    pub message_template: Option<String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
198#[serde(default, deny_unknown_fields)]
199pub struct TwitterAnnounce {
200    /// Enable Twitter/X announcements. Requires TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET env vars (supports template expressions).
201    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
202    pub enabled: Option<StringOrBool>,
203    /// Tweet message template. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
204    pub message_template: Option<String>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
208#[serde(default, deny_unknown_fields)]
209pub struct MastodonAnnounce {
210    /// Enable Mastodon announcements. Requires `MASTODON_ACCESS_TOKEN` env var (supports template expressions).
211    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
212    pub enabled: Option<StringOrBool>,
213    /// Mastodon instance URL (e.g. `https://mastodon.social`).
214    pub server: Option<String>,
215    /// Toot message template. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
216    pub message_template: Option<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
220#[serde(default, deny_unknown_fields)]
221pub struct DiscordAnnounce {
222    /// Enable Discord announcements (supports template expressions).
223    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
224    pub enabled: Option<StringOrBool>,
225    /// Discord webhook URL.
226    ///
227    /// Prefer `{{ Env.DISCORD_WEBHOOK }}` (or similar) over an in-config
228    /// literal — plaintext webhook URLs grant full posting access and are
229    /// NOT redacted from error messages or `dist/config.yaml` after a
230    /// dry-run / snapshot run.
231    pub webhook_url: Option<String>,
232    /// Message template for the Discord embed. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
233    pub message_template: Option<String>,
234    /// Author name displayed in the embed.
235    pub author: Option<String>,
236    /// Embed color as a decimal integer string (default: "3888754", a blue).
237    /// Parsed to u32 at runtime. Supports template expressions.
238    pub color: Option<String>,
239    /// Icon URL for the embed footer.
240    pub icon_url: Option<String>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
244#[serde(default, deny_unknown_fields)]
245pub struct WebhookConfig {
246    /// Enable generic webhook announcements (supports template expressions).
247    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
248    pub enabled: Option<StringOrBool>,
249    /// Webhook endpoint URL (supports template variables).
250    ///
251    /// Prefer `{{ Env.WEBHOOK_URL }}` for any URL containing a secret
252    /// token in its path / query string — plaintext values are NOT
253    /// redacted from error messages or `dist/config.yaml` after a
254    /// dry-run / snapshot run.
255    pub endpoint_url: Option<String>,
256    /// Custom HTTP headers to include in the request.
257    ///
258    /// Precedence — **anodizer-specific**:
259    /// - anodizer: a config-supplied `Authorization` header wins over the
260    ///   `BASIC_AUTH_HEADER_VALUE` / `BEARER_TOKEN_HEADER_VALUE` env var.
261    /// - The conventional behaviour: env-supplied `Authorization` is
262    ///   appended FIRST; most servers honour the first occurrence, so the
263    ///   env value effectively wins.
264    ///
265    /// Migrating configs that relied on env-overriding the config header
266    /// must either remove the config entry or be reconfigured. Use
267    /// templated config (`Authorization: "Bearer {{ Env.MY_TOKEN }}"`) for
268    /// the cleanest migration.
269    pub headers: Option<HashMap<String, String>>,
270    /// Content-Type header value. Default: "application/json".
271    pub content_type: Option<String>,
272    /// Message body template. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
273    pub message_template: Option<String>,
274    /// When true, skip TLS certificate verification for the webhook endpoint.
275    pub skip_tls_verify: Option<bool>,
276    /// HTTP status codes to accept as success (default: [200, 201, 202, 204]).
277    #[serde(default)]
278    pub expected_status_codes: Vec<u16>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
282#[serde(default, deny_unknown_fields)]
283pub struct TelegramAnnounce {
284    /// Enable Telegram announcements. Requires bot_token and chat_id (supports template expressions).
285    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
286    pub enabled: Option<StringOrBool>,
287    /// Telegram Bot API token. Get one from @BotFather.
288    ///
289    /// Prefer `{{ Env.TELEGRAM_BOT_TOKEN }}` over an in-config literal —
290    /// plaintext tokens grant full bot impersonation and are NOT redacted
291    /// from error messages or `dist/config.yaml` after a dry-run / snapshot
292    /// run.
293    pub bot_token: Option<String>,
294    /// Telegram chat ID to send the message to (supports template variables).
295    pub chat_id: Option<String>,
296    /// Message template. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
297    pub message_template: Option<String>,
298    /// Parse mode: "MarkdownV2" or "HTML" (defaults to "MarkdownV2").
299    pub parse_mode: Option<String>,
300    /// Message thread ID for sending to a specific topic in a forum group.
301    /// Supports template expressions; parsed to i64 at runtime.
302    pub message_thread_id: Option<String>,
303}
304
305/// Default Adaptive Card title for Teams announcements. Centralised so that a
306/// config-load round-trip (parse → serialise → re-parse) preserves the value
307/// instead of stripping it back to `None`.
308pub const TEAMS_DEFAULT_TITLE_TEMPLATE: &str = "{{ ProjectName }} {{ Tag }} is out!";
309
310fn default_teams_title_template() -> Option<String> {
311    Some(TEAMS_DEFAULT_TITLE_TEMPLATE.to_string())
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
315#[serde(default, deny_unknown_fields)]
316pub struct TeamsAnnounce {
317    /// Enable Microsoft Teams announcements (supports template expressions).
318    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
319    pub enabled: Option<StringOrBool>,
320    /// Teams incoming webhook URL.
321    pub webhook_url: Option<String>,
322    /// Message template for the Adaptive Card body. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
323    pub message_template: Option<String>,
324    /// Title template for the Adaptive Card header. Default: "{{ ProjectName }} {{ Tag }} is out!"
325    #[serde(default = "default_teams_title_template")]
326    pub title_template: Option<String>,
327    /// Theme color for the card (hex string, e.g. "0076D7").
328    pub color: Option<String>,
329    /// Icon URL displayed in the card header.
330    pub icon_url: Option<String>,
331}
332
333impl Default for TeamsAnnounce {
334    fn default() -> Self {
335        Self {
336            enabled: None,
337            webhook_url: None,
338            message_template: None,
339            title_template: default_teams_title_template(),
340            color: None,
341            icon_url: None,
342        }
343    }
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
347#[serde(default, deny_unknown_fields)]
348pub struct MattermostAnnounce {
349    /// Enable Mattermost announcements (supports template expressions).
350    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
351    pub enabled: Option<StringOrBool>,
352    /// Mattermost incoming webhook URL.
353    pub webhook_url: Option<String>,
354    /// Channel override (e.g. "town-square").
355    pub channel: Option<String>,
356    /// Username override for the bot post.
357    pub username: Option<String>,
358    /// Icon URL for the bot post.
359    pub icon_url: Option<String>,
360    /// Icon emoji for the bot post (e.g. ":rocket:").
361    pub icon_emoji: Option<String>,
362    /// Attachment color (hex string, e.g. "#36a64f").
363    pub color: Option<String>,
364    /// Message template for the Mattermost post. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
365    pub message_template: Option<String>,
366    /// Title template for the Mattermost attachment.
367    pub title_template: Option<String>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
371#[serde(default, deny_unknown_fields)]
372pub struct EmailAnnounce {
373    /// Enable email announcements (supports template expressions).
374    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
375    pub enabled: Option<StringOrBool>,
376    /// SMTP server hostname. When set, uses SMTP transport.
377    /// When absent, falls back to sendmail/msmtp.
378    pub host: Option<String>,
379    /// SMTP server port (default: 587 for STARTTLS).
380    ///
381    /// Anodize-additive UX win (locked 2026-04-28): an unset SMTP
382    /// `port` would otherwise be an error when it is
383    /// unset (zero value). Anodize defaults to 587 — the IETF submission
384    /// port — so the common case (corporate / SaaS SMTP relays exposing
385    /// STARTTLS on 587) works out of the box without a config knob. The
386    /// `auto` encryption mode then resolves to STARTTLS for 587, which is
387    /// the conventional pairing. Pinned by
388    /// `test_email_smtp_port_defaults_to_587`.
389    pub port: Option<u16>,
390    /// SMTP username (can also be set via SMTP_USERNAME env var).
391    pub username: Option<String>,
392    /// Sender email address.
393    pub from: Option<String>,
394    /// Recipient email addresses.
395    #[serde(default)]
396    pub to: Vec<String>,
397    /// Email subject template. Default: "{{ ProjectName }} {{ Tag }} is out!"
398    pub subject_template: Option<String>,
399    /// Email body template.
400    pub message_template: Option<String>,
401    /// Skip TLS certificate verification (default: false).
402    pub insecure_skip_verify: Option<bool>,
403    /// Transport encryption mode. `auto` (the default) picks SMTPS for port
404    /// 465, plain SMTP for port 25, and STARTTLS for everything else; `tls`
405    /// forces SMTPS, `starttls` forces STARTTLS, `none` forces plain SMTP.
406    pub encryption: Option<EmailEncryption>,
407}
408
409/// Email transport encryption mode.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
411#[serde(rename_all = "lowercase")]
412pub enum EmailEncryption {
413    /// Pick based on port: 465 → SMTPS, 25 → none, otherwise STARTTLS.
414    #[default]
415    Auto,
416    /// Implicit TLS on connect (typically port 465).
417    Tls,
418    /// Plain SMTP that upgrades to TLS via STARTTLS (typically port 587).
419    Starttls,
420    /// Plain SMTP, no TLS. Only safe on trusted local relays (port 25).
421    None,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
425#[serde(default, deny_unknown_fields)]
426pub struct RedditAnnounce {
427    /// Enable Reddit announcements. Requires REDDIT_SECRET and REDDIT_PASSWORD env vars (supports template expressions).
428    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
429    pub enabled: Option<StringOrBool>,
430    /// Reddit application (OAuth client) ID.
431    pub application_id: Option<String>,
432    /// Reddit username for posting.
433    pub username: Option<String>,
434    /// Subreddit to post to (without /r/ prefix).
435    pub sub: Option<String>,
436    /// Title template for the Reddit link post. Default: "{{ ProjectName }} {{ Tag }} is out!"
437    pub title_template: Option<String>,
438    /// URL template for the Reddit link post. Default: "{{ ReleaseURL }}"
439    pub url_template: Option<String>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
443#[serde(default, deny_unknown_fields)]
444pub struct SlackAnnounce {
445    /// Enable Slack announcements (supports template expressions).
446    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
447    pub enabled: Option<StringOrBool>,
448    /// Slack incoming webhook URL. Use template `{{ Env.SLACK_WEBHOOK }}` to reference an environment variable.
449    pub webhook_url: Option<String>,
450    /// Message template for the Slack post. Default: "{{ ProjectName }} {{ Tag }} is out! Check it out at {{ ReleaseURL }}"
451    pub message_template: Option<String>,
452    /// Override the webhook's default channel (e.g. "#releases").
453    pub channel: Option<String>,
454    /// Override the webhook's default username (e.g. "release-bot").
455    pub username: Option<String>,
456    /// Override the webhook's default icon with an emoji (e.g. ":rocket:").
457    pub icon_emoji: Option<String>,
458    /// Override the webhook's default icon with an image URL.
459    pub icon_url: Option<String>,
460    /// Slack Block Kit blocks (typed for schema validation).
461    pub blocks: Option<Vec<SlackBlock>>,
462    /// Slack legacy attachments (typed for schema validation).
463    pub attachments: Option<Vec<SlackAttachment>>,
464}
465
466/// A Slack Block Kit block element.
467/// Common fields are typed; additional block-type-specific fields are captured via flatten.
468#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
469pub struct SlackBlock {
470    /// Block type (e.g., "header", "section", "divider", "actions", "context", "image").
471    #[serde(rename = "type")]
472    pub block_type: String,
473    /// Text object for the block (used by header, section, context types).
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub text: Option<SlackTextObject>,
476    /// Block ID for interactive payloads.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub block_id: Option<String>,
479    /// Additional block-specific fields (elements, accessory, fields, etc.).
480    #[serde(flatten)]
481    pub extra: HashMap<String, serde_json::Value>,
482}
483
484/// A Slack text composition object.
485#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
486#[serde(deny_unknown_fields)]
487pub struct SlackTextObject {
488    /// Text type: "plain_text" or "mrkdwn".
489    #[serde(rename = "type")]
490    pub text_type: String,
491    /// Text content (supports template variables).
492    pub text: String,
493    /// Whether to render emoji shortcodes (plain_text only).
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub emoji: Option<bool>,
496    /// Whether to render verbatim (mrkdwn only).
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub verbatim: Option<bool>,
499}
500
501/// A Slack legacy attachment.
502/// Common fields are typed; additional fields are captured via flatten.
503#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
504pub struct SlackAttachment {
505    /// Attachment sidebar color (hex string, e.g., "#36a64f" for green).
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub color: Option<String>,
508    /// Main body text of the attachment (supports template variables).
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub text: Option<String>,
511    /// Bold title text at the top of the attachment.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub title: Option<String>,
514    /// Plain-text summary shown in notifications that cannot render attachments.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub fallback: Option<String>,
517    /// Text shown above the attachment block.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub pretext: Option<String>,
520    /// Small text shown at the bottom of the attachment.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub footer: Option<String>,
523    /// Additional attachment-specific fields.
524    #[serde(flatten)]
525    pub extra: HashMap<String, serde_json::Value>,
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::config::HumanDuration;
532    use std::time::Duration;
533
534    #[test]
535    fn deadline_unset_uses_default() {
536        let cfg = AnnounceConfig::default();
537        assert_eq!(cfg.deadline_duration(), DEFAULT_ANNOUNCE_DEADLINE);
538    }
539
540    #[test]
541    fn deadline_zero_is_clamped_to_floor() {
542        // An explicit `deadline: "0s"` must NOT abandon every channel on the
543        // first tick — it is raised to the 1s floor.
544        let cfg = AnnounceConfig {
545            deadline: Some(HumanDuration(Duration::ZERO)),
546            ..Default::default()
547        };
548        assert_eq!(cfg.deadline_duration(), MIN_ANNOUNCE_DEADLINE);
549    }
550
551    #[test]
552    fn deadline_subsecond_is_clamped_to_floor() {
553        let cfg = AnnounceConfig {
554            deadline: Some(HumanDuration(Duration::from_millis(250))),
555            ..Default::default()
556        };
557        assert_eq!(cfg.deadline_duration(), MIN_ANNOUNCE_DEADLINE);
558    }
559
560    #[test]
561    fn deadline_above_floor_is_preserved() {
562        let cfg = AnnounceConfig {
563            deadline: Some(HumanDuration(Duration::from_secs(45))),
564            ..Default::default()
565        };
566        assert_eq!(cfg.deadline_duration(), Duration::from_secs(45));
567    }
568}