use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Trigger {
Cron { expr: String },
Interval { seconds: u64 },
FileChange { path: String },
GoalSatisfied { goal: String },
Manual {},
}
impl Trigger {
pub fn is_self_reachable(&self) -> bool {
matches!(self, Trigger::FileChange { .. } | Trigger::GoalSatisfied { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TriggerSpec {
#[serde(rename = "on")]
pub trigger: Trigger,
#[serde(default)]
pub idempotency_key: Option<String>,
#[serde(default = "super::yes")]
pub enabled: bool,
}
impl From<Trigger> for TriggerSpec {
fn from(trigger: Trigger) -> Self {
TriggerSpec {
trigger,
idempotency_key: None,
enabled: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TriggerPolicy {
#[serde(default)]
pub triggers: Vec<TriggerSpec>,
#[serde(default = "default_max_depth")]
pub max_depth: u32,
#[serde(default = "default_dedup_window")]
pub dedup_window_seconds: u64,
}
fn default_max_depth() -> u32 {
5
}
fn default_dedup_window() -> u64 {
300
}
impl Default for TriggerPolicy {
fn default() -> Self {
Self {
triggers: Vec::new(),
max_depth: default_max_depth(),
dedup_window_seconds: default_dedup_window(),
}
}
}
impl TriggerPolicy {
pub fn may_chain(&self, depth: u32) -> bool {
depth < self.max_depth
}
pub fn has_self_reachable_trigger(&self) -> bool {
self.triggers
.iter()
.any(|t| t.enabled && t.trigger.is_self_reachable())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_trigger_spec_defaults_to_enabled_with_a_derived_key() {
let t: TriggerSpec = serde_yaml::from_str("on:\n type: cron\n expr: \"0 2 * * *\"\n")
.expect("trigger spec parses");
assert_eq!(
t.trigger,
Trigger::Cron {
expr: "0 2 * * *".into()
}
);
assert!(t.enabled);
assert!(t.idempotency_key.is_none());
}
#[test]
fn a_misspelled_bookkeeping_key_is_refused_not_dropped() {
let err = serde_yaml::from_str::<TriggerSpec>(
"on:\n type: manual\nidempotency_kye: dupe\n",
)
.expect_err("a misspelled key must be refused");
assert!(err.to_string().contains("idempotency_kye"), "got: {err}");
}
#[test]
fn chaining_stops_at_the_cap() {
let p = TriggerPolicy::default();
assert!(p.may_chain(0));
assert!(p.may_chain(4));
assert!(!p.may_chain(5), "depth 5 must not start a sixth run");
assert!(!p.may_chain(99));
}
#[test]
fn goal_and_file_triggers_are_the_self_reachable_ones() {
assert!(Trigger::GoalSatisfied { goal: "g".into() }.is_self_reachable());
assert!(Trigger::FileChange { path: "out".into() }.is_self_reachable());
assert!(!Trigger::Cron { expr: "* * * * *".into() }.is_self_reachable());
assert!(!Trigger::Interval { seconds: 60 }.is_self_reachable());
assert!(!Trigger::Manual {}.is_self_reachable());
}
}