#![allow(dead_code)]
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub enum TriggerType {
Schedule(ScheduleTrigger),
FileArrival(FileArrivalTrigger),
ApiCall,
EventBased(EventTrigger),
ManualStart,
Dependency,
}
#[derive(Debug, Clone)]
pub struct ScheduleTrigger {
pub cron_expr: String,
pub timezone: String,
pub max_runs: Option<u32>,
}
impl ScheduleTrigger {
#[must_use]
pub fn new(cron_expr: impl Into<String>, timezone: impl Into<String>) -> Self {
Self {
cron_expr: cron_expr.into(),
timezone: timezone.into(),
max_runs: None,
}
}
#[must_use]
pub fn with_max_runs(mut self, max_runs: u32) -> Self {
self.max_runs = Some(max_runs);
self
}
#[must_use]
pub fn next_fire_ms(&self, now_ms: u64) -> u64 {
let parts: Vec<&str> = self.cron_expr.split_whitespace().collect();
if parts.len() < 3 {
return now_ms + 3_600_000;
}
let minute: u64 = parts[1].parse().unwrap_or(0);
let hour: u64 = parts[2].parse().unwrap_or(0);
let now_secs = now_ms / 1000;
let seconds_in_day = now_secs % 86400;
let current_hour = seconds_in_day / 3600;
let current_minute = (seconds_in_day % 3600) / 60;
let day_start_ms = now_ms - (seconds_in_day * 1000);
let target_ms = day_start_ms + hour * 3_600_000 + minute * 60_000;
if target_ms > now_ms {
target_ms
} else if hour == current_hour && minute == current_minute {
now_ms + 60_000
} else {
target_ms + 86_400_000
}
}
}
#[derive(Debug, Clone)]
pub struct FileArrivalTrigger {
pub watch_path: String,
pub pattern: String,
pub min_size_bytes: u64,
pub stable_for_secs: u32,
}
impl FileArrivalTrigger {
#[must_use]
pub fn new(
watch_path: impl Into<String>,
pattern: impl Into<String>,
min_size_bytes: u64,
stable_for_secs: u32,
) -> Self {
Self {
watch_path: watch_path.into(),
pattern: pattern.into(),
min_size_bytes,
stable_for_secs,
}
}
#[must_use]
pub fn matches(&self, path: &str, size: u64) -> bool {
if size < self.min_size_bytes {
return false;
}
let filename = path.rsplit('/').next().unwrap_or(path);
glob_match(&self.pattern, filename)
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
let pattern_bytes = pattern.as_bytes();
let text_bytes = text.as_bytes();
glob_match_inner(pattern_bytes, text_bytes)
}
fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool {
match (pattern.first(), text.first()) {
(None, None) => true,
(Some(&b'*'), _) => {
glob_match_inner(&pattern[1..], text)
|| (!text.is_empty() && glob_match_inner(pattern, &text[1..]))
}
(None, Some(_)) | (Some(_), None) => false,
(Some(&p), Some(&t)) => p == t && glob_match_inner(&pattern[1..], &text[1..]),
}
}
#[derive(Debug, Clone)]
pub struct EventTrigger {
pub event_type: String,
pub filter: HashMap<String, String>,
}
impl EventTrigger {
#[must_use]
pub fn new(event_type: impl Into<String>) -> Self {
Self {
event_type: event_type.into(),
filter: HashMap::new(),
}
}
#[must_use]
pub fn with_filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.filter.insert(key.into(), value.into());
self
}
#[must_use]
pub fn matches(&self, event: &HashMap<String, String>) -> bool {
for (key, expected_value) in &self.filter {
match event.get(key) {
Some(actual_value) if actual_value == expected_value => {}
_ => return false,
}
}
true
}
}
#[derive(Debug, Clone)]
pub enum TriggerCondition {
All(Vec<TriggerType>),
Any(Vec<TriggerType>),
None(Vec<TriggerType>),
}
#[derive(Debug, Default)]
pub struct TriggerEngine {
triggers: HashMap<String, Vec<TriggerType>>,
}
impl TriggerEngine {
#[must_use]
pub fn new() -> Self {
Self {
triggers: HashMap::new(),
}
}
pub fn add_trigger(&mut self, workflow_id: &str, trigger: TriggerType) {
self.triggers
.entry(workflow_id.to_string())
.or_default()
.push(trigger);
}
#[must_use]
pub fn evaluate_file_arrival(&self, path: &str, size: u64) -> Vec<String> {
let mut triggered = Vec::new();
for (workflow_id, triggers) in &self.triggers {
for trigger in triggers {
if let TriggerType::FileArrival(file_trigger) = trigger {
if file_trigger.matches(path, size) {
triggered.push(workflow_id.clone());
break; }
}
}
}
triggered
}
#[must_use]
pub fn evaluate_event(&self, event: &HashMap<String, String>) -> Vec<String> {
let mut triggered = Vec::new();
for (workflow_id, triggers) in &self.triggers {
for trigger in triggers {
if let TriggerType::EventBased(event_trigger) = trigger {
if event_trigger.matches(event) {
triggered.push(workflow_id.clone());
break;
}
}
}
}
triggered
}
#[must_use]
pub fn get_triggers(&self, workflow_id: &str) -> &[TriggerType] {
self.triggers.get(workflow_id).map_or(&[], Vec::as_slice)
}
pub fn remove_workflow(&mut self, workflow_id: &str) {
self.triggers.remove(workflow_id);
}
#[must_use]
pub fn workflow_ids(&self) -> Vec<&str> {
self.triggers.keys().map(String::as_str).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schedule_trigger_next_fire_future_today() {
let now_ms = 8 * 3_600_000_u64; let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC");
let next = trigger.next_fire_ms(now_ms);
assert_eq!(next, 9 * 3_600_000);
}
#[test]
fn test_schedule_trigger_next_fire_tomorrow() {
let now_ms = 10 * 3_600_000_u64;
let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC");
let next = trigger.next_fire_ms(now_ms);
assert!(next > now_ms);
}
#[test]
fn test_schedule_trigger_with_max_runs() {
let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC").with_max_runs(5);
assert_eq!(trigger.max_runs, Some(5));
}
#[test]
fn test_file_arrival_trigger_matches_pattern() {
let trigger = FileArrivalTrigger::new("/watch", "*.mp4", 1000, 5);
assert!(trigger.matches("/watch/video.mp4", 5000));
assert!(!trigger.matches("/watch/video.mp4", 500)); assert!(!trigger.matches("/watch/video.mov", 5000)); }
#[test]
fn test_file_arrival_trigger_wildcard_pattern() {
let trigger = FileArrivalTrigger::new("/ingest", "mxf_*_v2.mxf", 0, 0);
assert!(trigger.matches("/ingest/mxf_cam1_v2.mxf", 0));
assert!(!trigger.matches("/ingest/mxf_cam1_v1.mxf", 0));
}
#[test]
fn test_file_arrival_trigger_no_wildcard() {
let trigger = FileArrivalTrigger::new("/dir", "exact.mp4", 0, 0);
assert!(trigger.matches("/dir/exact.mp4", 0));
assert!(!trigger.matches("/dir/other.mp4", 0));
}
#[test]
fn test_event_trigger_matches() {
let trigger = EventTrigger::new("media.ready")
.with_filter("format", "mp4")
.with_filter("resolution", "4k");
let mut event = HashMap::new();
event.insert("format".to_string(), "mp4".to_string());
event.insert("resolution".to_string(), "4k".to_string());
event.insert("extra_field".to_string(), "ignored".to_string());
assert!(trigger.matches(&event));
}
#[test]
fn test_event_trigger_no_match() {
let trigger = EventTrigger::new("media.ready").with_filter("format", "mp4");
let mut event = HashMap::new();
event.insert("format".to_string(), "mov".to_string());
assert!(!trigger.matches(&event));
}
#[test]
fn test_event_trigger_empty_filter() {
let trigger = EventTrigger::new("any.event");
let event = HashMap::new();
assert!(trigger.matches(&event));
}
#[test]
fn test_trigger_engine_add_and_evaluate_file() {
let mut engine = TriggerEngine::new();
engine.add_trigger(
"workflow-1",
TriggerType::FileArrival(FileArrivalTrigger::new("/ingest", "*.mxf", 1000, 5)),
);
engine.add_trigger(
"workflow-2",
TriggerType::FileArrival(FileArrivalTrigger::new("/ingest", "*.mp4", 1000, 5)),
);
let triggered = engine.evaluate_file_arrival("/ingest/clip.mxf", 50_000);
assert_eq!(triggered.len(), 1);
assert_eq!(triggered[0], "workflow-1");
}
#[test]
fn test_trigger_engine_multiple_workflows() {
let mut engine = TriggerEngine::new();
engine.add_trigger(
"wf-a",
TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
);
engine.add_trigger(
"wf-b",
TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
);
let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
assert_eq!(triggered.len(), 2);
}
#[test]
fn test_trigger_engine_no_match() {
let mut engine = TriggerEngine::new();
engine.add_trigger(
"wf-1",
TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mxf", 0, 0)),
);
let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
assert!(triggered.is_empty());
}
#[test]
fn test_trigger_engine_remove_workflow() {
let mut engine = TriggerEngine::new();
engine.add_trigger(
"wf-1",
TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
);
engine.remove_workflow("wf-1");
let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
assert!(triggered.is_empty());
}
#[test]
fn test_trigger_condition_variants() {
let triggers = vec![TriggerType::ManualStart, TriggerType::ApiCall];
let _all = TriggerCondition::All(triggers.clone());
let _any = TriggerCondition::Any(triggers.clone());
let _none = TriggerCondition::None(triggers);
}
#[test]
fn test_glob_match_star_extension() {
assert!(glob_match("*.mp4", "video.mp4"));
assert!(glob_match("*.mp4", ".mp4"));
assert!(!glob_match("*.mp4", "video.mov"));
}
#[test]
fn test_glob_match_multiple_stars() {
assert!(glob_match("*_*_v2.*", "clip_cam1_v2.mxf"));
assert!(!glob_match("*_*_v2.*", "clip_cam1_v1.mxf"));
}
}