1use std::collections::HashMap;
10use std::time::{Duration, Instant};
11
12use serde::{Deserialize, Serialize};
13
14use super::event_matcher::{EventPattern, RoutineEvent, matches_any};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum RoutineAction {
20 Sop { name: String },
22 Shell { command: String },
24 Message { channel: String, text: String },
26 CronJob { job_name: String },
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Routine {
33 pub name: String,
35 #[serde(default)]
37 pub description: String,
38 pub patterns: Vec<EventPattern>,
40 pub action: RoutineAction,
42 #[serde(default)]
44 pub cooldown_secs: u64,
45 #[serde(default = "default_enabled")]
47 pub enabled: bool,
48}
49
50fn default_enabled() -> bool {
51 true
52}
53
54#[derive(Debug, Clone, Deserialize)]
56pub struct RoutinesManifest {
57 #[serde(default)]
58 pub routines: Vec<Routine>,
59}
60
61#[derive(Debug, Clone)]
63pub enum RoutineDispatchResult {
64 Fired {
66 routine_name: String,
67 action: RoutineAction,
68 },
69 Cooldown {
71 routine_name: String,
72 remaining_secs: u64,
73 },
74 Disabled { routine_name: String },
76 NoMatch,
78}
79
80pub struct RoutinesEngine {
82 routines: Vec<Routine>,
83 cooldowns: HashMap<String, Instant>,
85}
86
87impl RoutinesEngine {
88 pub fn new(routines: Vec<Routine>) -> Self {
90 Self {
91 routines,
92 cooldowns: HashMap::new(),
93 }
94 }
95
96 pub fn empty() -> Self {
98 Self::new(Vec::new())
99 }
100
101 pub fn len(&self) -> usize {
103 self.routines.len()
104 }
105
106 pub fn is_empty(&self) -> bool {
108 self.routines.is_empty()
109 }
110
111 pub fn routines(&self) -> &[Routine] {
113 &self.routines
114 }
115
116 pub fn add_routine(&mut self, routine: Routine) {
118 self.routines.push(routine);
119 }
120
121 pub fn remove_routine(&mut self, name: &str) -> bool {
123 let before = self.routines.len();
124 self.routines.retain(|r| r.name != name);
125 self.cooldowns.remove(name);
126 self.routines.len() < before
127 }
128
129 pub fn dispatch(&mut self, event: &RoutineEvent) -> Vec<RoutineDispatchResult> {
134 let mut results = Vec::new();
135 let now = Instant::now();
136
137 for routine in &self.routines {
138 if !matches_any(&routine.patterns, event) {
139 continue;
140 }
141
142 if !routine.enabled {
143 ::zeroclaw_log::record!(
144 DEBUG,
145 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
146 .with_attrs(::serde_json::json!({"routine": routine.name})),
147 "routine matched but disabled"
148 );
149 results.push(RoutineDispatchResult::Disabled {
150 routine_name: routine.name.clone(),
151 });
152 continue;
153 }
154
155 if routine.cooldown_secs > 0
157 && let Some(last_fired) = self.cooldowns.get(&routine.name)
158 {
159 let elapsed = now.saturating_duration_since(*last_fired);
160 let cooldown = Duration::from_secs(routine.cooldown_secs);
161 if elapsed < cooldown {
162 let remaining = cooldown.saturating_sub(elapsed).as_secs();
163 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"routine": routine.name, "remaining_secs": remaining})), "routine in cooldown");
164 results.push(RoutineDispatchResult::Cooldown {
165 routine_name: routine.name.clone(),
166 remaining_secs: remaining,
167 });
168 continue;
169 }
170 }
171
172 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"routine": routine.name, "source": event.source, "topic": event.topic})), "routine fired");
173 self.cooldowns.insert(routine.name.clone(), now);
174 results.push(RoutineDispatchResult::Fired {
175 routine_name: routine.name.clone(),
176 action: routine.action.clone(),
177 });
178 }
179
180 if results.is_empty() {
181 results.push(RoutineDispatchResult::NoMatch);
182 }
183
184 results
185 }
186
187 pub fn reset_cooldowns(&mut self) {
189 self.cooldowns.clear();
190 }
191}
192
193pub fn load_routines_from_file(path: &std::path::Path) -> Vec<Routine> {
195 match std::fs::read_to_string(path) {
196 Ok(content) => match toml::from_str::<RoutinesManifest>(&content) {
197 Ok(manifest) => manifest.routines,
198 Err(e) => {
199 ::zeroclaw_log::record!(
200 WARN,
201 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
202 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
203 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
204 &format!(
205 "Failed to parse routines file {}",
206 path.display().to_string()
207 )
208 );
209 Vec::new()
210 }
211 },
212 Err(e) => {
213 ::zeroclaw_log::record!(
214 DEBUG,
215 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
216 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
217 &format!("Routines file not found at {}", path.display().to_string())
218 );
219 Vec::new()
220 }
221 }
222}
223
224pub fn load_routines(workspace_dir: &std::path::Path) -> Vec<Routine> {
226 let path = workspace_dir.join("routines.toml");
227 load_routines_from_file(&path)
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::routines::event_matcher::{EventPattern, MatchStrategy, RoutineEvent};
234
235 fn test_event(source: &str, topic: &str) -> RoutineEvent {
236 RoutineEvent {
237 source: source.into(),
238 topic: topic.into(),
239 payload: None,
240 timestamp: "2026-03-24T00:00:00Z".into(),
241 }
242 }
243
244 fn test_routine(name: &str, source: &str, pattern: &str, strategy: MatchStrategy) -> Routine {
245 Routine {
246 name: name.into(),
247 description: String::new(),
248 patterns: vec![EventPattern {
249 source: source.into(),
250 pattern: pattern.into(),
251 strategy,
252 }],
253 action: RoutineAction::Sop {
254 name: "test-sop".into(),
255 },
256 cooldown_secs: 0,
257 enabled: true,
258 }
259 }
260
261 #[test]
262 fn dispatch_fires_matching_routine() {
263 let mut engine = RoutinesEngine::new(vec![test_routine(
264 "deploy-hook",
265 "webhook",
266 "/deploy",
267 MatchStrategy::Exact,
268 )]);
269
270 let results = engine.dispatch(&test_event("webhook", "/deploy"));
271 assert_eq!(results.len(), 1);
272 assert!(matches!(results[0], RoutineDispatchResult::Fired { .. }));
273 }
274
275 #[test]
276 fn dispatch_returns_no_match() {
277 let mut engine = RoutinesEngine::new(vec![test_routine(
278 "deploy-hook",
279 "webhook",
280 "/deploy",
281 MatchStrategy::Exact,
282 )]);
283
284 let results = engine.dispatch(&test_event("channel", "slack-main"));
285 assert_eq!(results.len(), 1);
286 assert!(matches!(results[0], RoutineDispatchResult::NoMatch));
287 }
288
289 #[test]
290 fn dispatch_skips_disabled_routine() {
291 let mut routine = test_routine("disabled", "webhook", "/deploy", MatchStrategy::Exact);
292 routine.enabled = false;
293 let mut engine = RoutinesEngine::new(vec![routine]);
294
295 let results = engine.dispatch(&test_event("webhook", "/deploy"));
296 assert_eq!(results.len(), 1);
297 assert!(matches!(results[0], RoutineDispatchResult::Disabled { .. }));
298 }
299
300 #[test]
301 fn dispatch_enforces_cooldown() {
302 let mut routine = test_routine("deploy-hook", "webhook", "/deploy", MatchStrategy::Exact);
303 routine.cooldown_secs = 3600; let mut engine = RoutinesEngine::new(vec![routine]);
305
306 let results = engine.dispatch(&test_event("webhook", "/deploy"));
308 assert!(matches!(results[0], RoutineDispatchResult::Fired { .. }));
309
310 let results = engine.dispatch(&test_event("webhook", "/deploy"));
312 assert!(matches!(results[0], RoutineDispatchResult::Cooldown { .. }));
313 }
314
315 #[test]
316 fn dispatch_multiple_routines_match() {
317 let mut engine = RoutinesEngine::new(vec![
318 test_routine("exact-deploy", "webhook", "/deploy", MatchStrategy::Exact),
319 test_routine("glob-deploy", "webhook", "/deploy*", MatchStrategy::Glob),
320 ]);
321
322 let results = engine.dispatch(&test_event("webhook", "/deploy"));
323 assert_eq!(results.len(), 2);
324 assert!(
325 results
326 .iter()
327 .all(|r| matches!(r, RoutineDispatchResult::Fired { .. }))
328 );
329 }
330
331 #[test]
332 fn reset_cooldowns_clears_state() {
333 let mut routine = test_routine("deploy", "webhook", "/deploy", MatchStrategy::Exact);
334 routine.cooldown_secs = 3600;
335 let mut engine = RoutinesEngine::new(vec![routine]);
336
337 engine.dispatch(&test_event("webhook", "/deploy")); engine.reset_cooldowns();
339 let results = engine.dispatch(&test_event("webhook", "/deploy")); assert!(matches!(results[0], RoutineDispatchResult::Fired { .. }));
341 }
342
343 #[test]
344 fn add_and_remove_routine() {
345 let mut engine = RoutinesEngine::empty();
346 assert!(engine.is_empty());
347
348 engine.add_routine(test_routine("r1", "channel", "test", MatchStrategy::Exact));
349 assert_eq!(engine.len(), 1);
350
351 assert!(engine.remove_routine("r1"));
352 assert!(engine.is_empty());
353 assert!(!engine.remove_routine("nonexistent"));
354 }
355
356 #[test]
357 fn load_routines_from_toml_file() {
358 let dir = tempfile::tempdir().unwrap();
359 let path = dir.path().join("routines.toml");
360 std::fs::write(
361 &path,
362 r#"
363[[routines]]
364name = "deploy-notify"
365description = "Notify on deploy"
366cooldown_secs = 60
367
368[[routines.patterns]]
369source = "webhook"
370pattern = "/deploy"
371strategy = "exact"
372
373[routines.action]
374type = "message"
375channel = "slack-general"
376text = "Deploy triggered!"
377
378[[routines]]
379name = "build-monitor"
380description = "Monitor builds"
381
382[[routines.patterns]]
383source = "system"
384pattern = "build.*"
385strategy = "glob"
386
387[routines.action]
388type = "sop"
389name = "check-build"
390"#,
391 )
392 .unwrap();
393
394 let routines = load_routines_from_file(&path);
395 assert_eq!(routines.len(), 2);
396 assert_eq!(routines[0].name, "deploy-notify");
397 assert_eq!(routines[0].cooldown_secs, 60);
398 assert_eq!(routines[1].name, "build-monitor");
399 }
400
401 #[test]
402 fn load_routines_missing_file() {
403 let routines = load_routines_from_file(std::path::Path::new("/nonexistent/routines.toml"));
404 assert!(routines.is_empty());
405 }
406
407 #[test]
408 fn glob_pattern_dispatch() {
409 let mut engine = RoutinesEngine::new(vec![test_routine(
410 "channel-watcher",
411 "channel",
412 "telegram-*",
413 MatchStrategy::Glob,
414 )]);
415
416 assert!(matches!(
417 engine.dispatch(&test_event("channel", "telegram-main"))[0],
418 RoutineDispatchResult::Fired { .. }
419 ));
420 assert!(matches!(
421 engine.dispatch(&test_event("channel", "discord-main"))[0],
422 RoutineDispatchResult::NoMatch
423 ));
424 }
425
426 #[test]
427 fn regex_pattern_dispatch() {
428 let mut engine = RoutinesEngine::new(vec![test_routine(
429 "error-watcher",
430 "system",
431 r"^error\.(critical|fatal)$",
432 MatchStrategy::Regex,
433 )]);
434
435 assert!(matches!(
436 engine.dispatch(&test_event("system", "error.critical"))[0],
437 RoutineDispatchResult::Fired { .. }
438 ));
439 assert!(matches!(
440 engine.dispatch(&test_event("system", "error.warning"))[0],
441 RoutineDispatchResult::NoMatch
442 ));
443 }
444
445 #[test]
446 fn routine_action_serde_roundtrip() {
447 let action = RoutineAction::Sop {
448 name: "test-sop".into(),
449 };
450 let json = serde_json::to_string(&action).unwrap();
451 let parsed: RoutineAction = serde_json::from_str(&json).unwrap();
452 assert!(matches!(parsed, RoutineAction::Sop { name } if name == "test-sop"));
453 }
454}