use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use super::{HttpMethod, Trigger};
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum TriggerConfig {
Echo(EchoConfig),
Log(LogConfig),
#[cfg(unix)]
Command(CommandTriggerConfig),
Webhook(WebhookTriggerConfig),
Teams(TeamsTriggerConfig),
Post(PostTriggerConfig),
}
#[non_exhaustive]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EchoConfig {
#[serde(default)]
pub retries: u32,
#[serde(default = "default_required")]
pub required: bool,
}
impl Default for EchoConfig {
fn default() -> Self {
Self {
retries: 0,
required: true,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LogConfig {
pub path: PathBuf,
#[serde(default)]
pub retries: u32,
#[serde(default = "default_required")]
pub required: bool,
}
#[cfg(unix)]
#[non_exhaustive]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandTriggerConfig {
pub command: String,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub working_dir: Option<PathBuf>,
#[serde(default, with = "humantime_serde::option")]
pub timeout: Option<Duration>,
#[serde(default)]
pub retries: u32,
#[serde(default = "default_required")]
pub required: bool,
#[serde(default = "default_fail_fast")]
pub fail_fast: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebhookTriggerConfig {
pub url: String,
#[serde(default)]
pub method: Option<HttpMethod>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub body_template: Option<String>,
#[serde(default, with = "humantime_serde::option")]
pub timeout: Option<Duration>,
#[serde(default)]
pub retries: u32,
#[serde(default = "default_required")]
pub required: bool,
#[serde(default = "default_fail_fast")]
pub fail_fast: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TeamsTriggerConfig {
pub url: String,
#[serde(default = "default_teams_title")]
pub title_template: String,
#[serde(default, with = "humantime_serde::option")]
pub timeout: Option<Duration>,
#[serde(default)]
pub retries: u32,
#[serde(default = "default_required")]
pub required: bool,
#[serde(default = "default_fail_fast")]
pub fail_fast: bool,
}
fn default_teams_title() -> String {
super::teams::DEFAULT_TEAMS_TITLE_TEMPLATE.to_string()
}
#[non_exhaustive]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PostTriggerConfig {
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default, with = "humantime_serde::option")]
pub timeout: Option<Duration>,
#[serde(default)]
pub retries: u32,
#[serde(default = "default_required")]
pub required: bool,
#[serde(default = "default_fail_fast")]
pub fail_fast: bool,
}
impl TriggerConfig {
#[must_use]
pub fn into_trigger(self) -> Trigger {
match self {
Self::Echo(cfg) => Trigger::echo().retries(cfg.retries).required(cfg.required),
Self::Log(cfg) => Trigger::log(cfg.path)
.retries(cfg.retries)
.required(cfg.required),
#[cfg(unix)]
Self::Command(cfg) => {
let mut t = Trigger::command(cfg.command);
for (k, v) in cfg.env {
t = t.env(k, v);
}
if let Some(d) = cfg.working_dir {
t = t.working_dir(d);
}
if let Some(d) = cfg.timeout {
t = t.timeout(d);
}
t.retries(cfg.retries)
.required(cfg.required)
.fail_fast(cfg.fail_fast)
}
Self::Webhook(cfg) => {
let mut t = Trigger::webhook(cfg.url);
if let Some(m) = cfg.method {
t = t.method(m);
}
for (k, v) in cfg.headers {
t = t.header(k, v);
}
if let Some(b) = cfg.body_template {
t = t.body_template(b);
}
if let Some(d) = cfg.timeout {
t = t.timeout(d);
}
t.retries(cfg.retries)
.required(cfg.required)
.fail_fast(cfg.fail_fast)
}
Self::Teams(cfg) => {
let mut t = Trigger::teams(cfg.url).title_template(cfg.title_template);
if let Some(d) = cfg.timeout {
t = t.timeout(d);
}
t.retries(cfg.retries)
.required(cfg.required)
.fail_fast(cfg.fail_fast)
}
Self::Post(cfg) => {
let mut t = Trigger::post(cfg.url);
for (k, v) in cfg.headers {
t = t.post_header(k, v);
}
if let Some(d) = cfg.timeout {
t = t.timeout(d);
}
t.retries(cfg.retries)
.required(cfg.required)
.fail_fast(cfg.fail_fast)
}
}
}
}
fn default_required() -> bool {
true
}
fn default_fail_fast() -> bool {
true
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
reason = "test code: unwrap on YAML deserialisation success and panic on unexpected variant are the standard test diagnostics"
)]
mod tests {
use super::{EchoConfig, HttpMethod, TriggerConfig};
use crate::watch::trigger::kind::TriggerKind;
fn parse(yaml: &str) -> TriggerConfig {
serde_norway::from_str::<TriggerConfig>(yaml).unwrap()
}
fn parse_err(yaml: &str) -> serde_norway::Error {
serde_norway::from_str::<TriggerConfig>(yaml).unwrap_err()
}
#[test]
fn echo_config_default_matches_yaml_serde_defaults_field_for_field() {
let from_default = EchoConfig::default();
let from_yaml: EchoConfig = serde_norway::from_str("{}").unwrap();
assert_eq!(
from_default.retries, 0,
"Default::default must produce retries = 0 to match the serde default for an omitted field",
);
assert!(
from_default.required,
"Default::default must produce required = true to match the YAML default; without this, programmatic constructors of EchoConfig (the CLI inline-listen path) silently get a non-required trigger when the YAML deserialiser would have produced a required one, breaking the at-least-once contract",
);
assert_eq!(
from_default.retries, from_yaml.retries,
"Default::default and serde::from_str(\"{{}}\") MUST agree on retries; if they diverge, switching between YAML and programmatic construction silently changes operator-observable behavior",
);
assert_eq!(
from_default.required, from_yaml.required,
"Default::default and serde::from_str(\"{{}}\") MUST agree on required; if they diverge, switching between YAML and programmatic construction silently changes operator-observable behavior",
);
}
#[test]
fn yaml_deserialise_echo_with_defaults() {
let cfg = parse("type: echo\n");
match cfg {
TriggerConfig::Echo(echo) => {
assert_eq!(echo.retries, 0);
assert!(echo.required);
}
other => panic!("expected Echo, got {other:?}"),
}
}
#[test]
fn yaml_deserialise_echo_with_overrides() {
let cfg = parse("type: echo\nretries: 5\nrequired: false\n");
match cfg {
TriggerConfig::Echo(echo) => {
assert_eq!(echo.retries, 5);
assert!(!echo.required);
}
other => panic!("expected Echo, got {other:?}"),
}
}
#[test]
fn yaml_deserialise_log_with_path() {
let cfg = parse("type: log\npath: /tmp/foo.log\n");
match cfg {
TriggerConfig::Log(log) => {
assert_eq!(log.path.to_str(), Some("/tmp/foo.log"));
assert_eq!(log.retries, 0);
assert!(log.required);
}
other => panic!("expected Log, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn yaml_deserialise_command_with_full_fields() {
let yaml = r#"
type: command
command: "echo hi"
env:
KEY1: value1
KEY2: value2
working_dir: /tmp
timeout: 30s
retries: 2
required: false
fail_fast: false
"#;
let cfg = parse(yaml);
match cfg {
TriggerConfig::Command(cmd) => {
assert_eq!(cmd.command, "echo hi");
assert_eq!(cmd.env.len(), 2);
assert_eq!(cmd.env.get("KEY1").map(String::as_str), Some("value1"));
assert_eq!(
cmd.working_dir.as_deref().and_then(std::path::Path::to_str),
Some("/tmp")
);
assert_eq!(cmd.timeout, Some(std::time::Duration::from_secs(30)));
assert_eq!(cmd.retries, 2);
assert!(!cmd.required);
assert!(!cmd.fail_fast);
}
other => panic!("expected Command, got {other:?}"),
}
}
#[test]
fn yaml_deserialise_webhook_with_full_fields() {
let yaml = r#"
type: webhook
url: "https://hooks.example.org/notify"
method: PUT
headers:
X-Foo: bar
Authorization: "Bearer secret"
body_template: '{"k":"v"}'
timeout: 1m30s
retries: 3
required: false
fail_fast: false
"#;
let cfg = parse(yaml);
match cfg {
TriggerConfig::Webhook(wh) => {
assert_eq!(wh.url, "https://hooks.example.org/notify");
assert_eq!(wh.method, Some(HttpMethod::Put));
assert_eq!(wh.headers.len(), 2);
assert_eq!(wh.body_template.as_deref(), Some("{\"k\":\"v\"}"));
assert_eq!(wh.timeout, Some(std::time::Duration::from_secs(90)));
assert_eq!(wh.retries, 3);
assert!(!wh.required);
assert!(!wh.fail_fast);
}
other => panic!("expected Webhook, got {other:?}"),
}
}
#[test]
fn yaml_deserialise_timeout_humantime_formats() {
for (input, expected_secs) in [("30s", 30), ("2m", 120), ("1h", 3600), ("1h30m", 5400)] {
let yaml = format!("type: webhook\nurl: https://x\ntimeout: {input}\n");
let cfg = parse(&yaml);
match cfg {
TriggerConfig::Webhook(wh) => assert_eq!(
wh.timeout,
Some(std::time::Duration::from_secs(expected_secs)),
"input: {input}"
),
other => panic!("expected Webhook, got {other:?}"),
}
}
}
#[test]
fn yaml_deserialise_timeout_milliseconds_format() {
let cfg = parse("type: webhook\nurl: https://x\ntimeout: 500ms\n");
match cfg {
TriggerConfig::Webhook(wh) => {
assert_eq!(wh.timeout, Some(std::time::Duration::from_millis(500)));
}
other => panic!("expected Webhook, got {other:?}"),
}
}
#[test]
fn yaml_deserialise_unknown_type_fails_with_clear_message() {
let err = parse_err("type: comand\n");
let message = err.to_string();
assert!(
message.contains("comand") || message.contains("unknown variant"),
"error should name the bad variant: {message}"
);
}
#[test]
fn yaml_deserialise_unknown_field_within_echo_fails() {
let err = parse_err("type: echo\nbogus_field: 1\n");
let message = err.to_string();
assert!(
message.contains("bogus_field") || message.contains("unknown field"),
"error should name the bad field: {message}"
);
}
#[test]
fn yaml_deserialise_unknown_field_within_log_fails() {
let err = parse_err("type: log\npath: /tmp/x\nbogus_field: 1\n");
let message = err.to_string();
assert!(
message.contains("bogus_field") || message.contains("unknown field"),
"error should name the bad field: {message}"
);
}
#[cfg(unix)]
#[test]
fn yaml_deserialise_unknown_field_within_command_fails() {
let err = parse_err("type: command\ncommand: \"true\"\nbogus_field: 1\n");
let message = err.to_string();
assert!(
message.contains("bogus_field") || message.contains("unknown field"),
"error should name the bad field: {message}"
);
}
#[test]
fn yaml_deserialise_unknown_field_within_webhook_fails() {
let err = parse_err("type: webhook\nurl: https://x\nbogus_field: 1\n");
let message = err.to_string();
assert!(
message.contains("bogus_field") || message.contains("unknown field"),
"error should name the bad field: {message}"
);
}
#[test]
fn yaml_method_uppercase_required() {
let cfg = parse("type: webhook\nurl: https://x\nmethod: POST\n");
match cfg {
TriggerConfig::Webhook(wh) => assert_eq!(wh.method, Some(HttpMethod::Post)),
other => panic!("expected Webhook, got {other:?}"),
}
}
#[test]
fn yaml_method_lowercase_fails() {
let err = parse_err("type: webhook\nurl: https://x\nmethod: post\n");
let message = err.to_string();
assert!(
message.contains("unknown variant") || message.contains("post"),
"lowercase method should fail: {message}"
);
}
#[test]
fn yaml_to_trigger_round_trip_echo() {
let cfg = parse("type: echo\nretries: 3\nrequired: false\n");
let trigger = cfg.into_trigger();
assert_eq!(trigger.retries, 3);
assert!(!trigger.required);
assert!(matches!(trigger.kind, TriggerKind::Echo { .. }));
}
#[test]
fn yaml_to_trigger_round_trip_webhook_applies_method_header_body() {
let yaml = r#"
type: webhook
url: "https://hooks.example.org/notify"
method: PATCH
headers:
X-Custom: value
body_template: '{"x":1}'
timeout: 5s
retries: 1
"#;
let cfg = parse(yaml);
let trigger = cfg.into_trigger();
assert_eq!(trigger.retries, 1);
assert!(trigger.required);
assert_eq!(trigger.timeout, Some(std::time::Duration::from_secs(5)));
assert!(matches!(trigger.kind, TriggerKind::Webhook(_)));
}
#[test]
fn yaml_deserialise_teams_with_defaults() {
let cfg = parse("type: teams\nurl: \"https://example/workflow\"\n");
match cfg {
TriggerConfig::Teams(teams) => {
assert_eq!(teams.url, "https://example/workflow");
assert!(
teams.title_template.contains("notification.event_type"),
"default title template must reference notification.event_type: {}",
teams.title_template
);
assert_eq!(teams.retries, 0);
assert!(teams.required);
}
other => panic!("expected Teams, got {other:?}"),
}
}
#[test]
fn yaml_deserialise_teams_with_explicit_title_template() {
let yaml = r#"
type: teams
url: "{{ env.TEAMS_WEBHOOK_URL }}"
title_template: "custom: {{ notification.event_type }} #{{ notification.sequence }}"
retries: 2
timeout: 10s
"#;
let cfg = parse(yaml);
match cfg {
TriggerConfig::Teams(teams) => {
assert_eq!(teams.url, "{{ env.TEAMS_WEBHOOK_URL }}");
assert_eq!(
teams.title_template,
"custom: {{ notification.event_type }} #{{ notification.sequence }}"
);
assert_eq!(teams.retries, 2);
assert_eq!(teams.timeout, Some(std::time::Duration::from_secs(10)));
}
other => panic!("expected Teams, got {other:?}"),
}
}
#[test]
fn yaml_teams_to_trigger_produces_teams_kind() {
let yaml = r#"
type: teams
url: "https://example/workflow"
title_template: "aviso fires"
"#;
let cfg = parse(yaml);
let trigger = cfg.into_trigger();
assert!(
matches!(trigger.kind, TriggerKind::Teams(_)),
"type: teams must produce TriggerKind::Teams (was a webhook desugaring before; now a proper kind that builds the Adaptive Card from the notification at dispatch time)"
);
assert_eq!(trigger.retries, 0);
assert!(trigger.required);
}
}