pub mod engine;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Routine {
pub id: String,
pub name: String,
pub enabled: bool,
pub trigger: Trigger,
pub action: RoutineAction,
pub guardrails: RoutineGuardrails,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Trigger {
#[serde(rename = "cron")]
Cron { schedule: String },
#[serde(rename = "event")]
Event {
pattern: String,
channel: Option<String>,
},
#[serde(rename = "webhook")]
Webhook {
path: String,
},
#[serde(rename = "manual")]
Manual,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RoutineAction {
#[serde(rename = "lightweight")]
Lightweight { prompt: String },
#[serde(rename = "full_job")]
FullJob { prompt: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutineGuardrails {
#[serde(default = "default_cooldown")]
pub cooldown_secs: u64,
#[serde(default = "default_max_concurrent")]
pub max_concurrent: usize,
}
fn default_cooldown() -> u64 {
60
}
fn default_max_concurrent() -> usize {
1
}
impl Default for RoutineGuardrails {
fn default() -> Self {
Self {
cooldown_secs: default_cooldown(),
max_concurrent: default_max_concurrent(),
}
}
}
pub struct RoutineStore {
path: PathBuf,
routines: Vec<Routine>,
last_executed: HashMap<String, Instant>,
}
impl RoutineStore {
pub fn new(path: PathBuf) -> Self {
let routines = if path.exists() {
match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Vec::new(),
}
} else {
Vec::new()
};
Self {
path,
routines,
last_executed: HashMap::new(),
}
}
pub fn list(&self) -> &[Routine] {
&self.routines
}
pub fn get(&self, id: &str) -> Option<&Routine> {
self.routines.iter().find(|r| r.id == id)
}
pub fn add(&mut self, routine: Routine) -> Result<(), String> {
if self.routines.iter().any(|r| r.id == routine.id) {
return Err(format!("Routine '{}' already exists", routine.id));
}
self.routines.push(routine);
self.save()
}
pub fn remove(&mut self, id: &str) -> Result<(), String> {
let len_before = self.routines.len();
self.routines.retain(|r| r.id != id);
if self.routines.len() == len_before {
return Err(format!("Routine '{}' not found", id));
}
self.save()
}
pub fn toggle(&mut self, id: &str) -> Result<bool, String> {
let routine = self
.routines
.iter_mut()
.find(|r| r.id == id)
.ok_or_else(|| format!("Routine '{}' not found", id))?;
routine.enabled = !routine.enabled;
let enabled = routine.enabled;
self.save()?;
Ok(enabled)
}
pub fn check_cooldown(&self, id: &str) -> bool {
let routine = match self.get(id) {
Some(r) => r,
None => return false,
};
match self.last_executed.get(id) {
Some(last) => last.elapsed() >= Duration::from_secs(routine.guardrails.cooldown_secs),
None => true, }
}
pub fn record_execution(&mut self, id: &str) {
self.last_executed.insert(id.to_string(), Instant::now());
}
pub fn len(&self) -> usize {
self.routines.len()
}
pub fn is_empty(&self) -> bool {
self.routines.is_empty()
}
fn save(&self) -> Result<(), String> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
let json = serde_json::to_string_pretty(&self.routines)
.map_err(|e| format!("Failed to serialize routines: {}", e))?;
std::fs::write(&self.path, json)
.map_err(|e| format!("Failed to write routines file: {}", e))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_routine(id: &str, trigger: Trigger, action: RoutineAction) -> Routine {
Routine {
id: id.to_string(),
name: format!("Test {}", id),
enabled: true,
trigger,
action,
guardrails: RoutineGuardrails::default(),
}
}
fn temp_path(suffix: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"zeptoclaw_test_routines_{}_{}.json",
suffix,
std::process::id()
))
}
#[test]
fn test_trigger_cron_serde() {
let trigger = Trigger::Cron {
schedule: "0 9 * * *".to_string(),
};
let json = serde_json::to_string(&trigger).unwrap();
let parsed: Trigger = serde_json::from_str(&json).unwrap();
match parsed {
Trigger::Cron { schedule } => assert_eq!(schedule, "0 9 * * *"),
_ => panic!("Expected Trigger::Cron"),
}
}
#[test]
fn test_trigger_event_serde() {
let trigger = Trigger::Event {
pattern: r"deploy\s+\w+".to_string(),
channel: Some("telegram".to_string()),
};
let json = serde_json::to_string(&trigger).unwrap();
let parsed: Trigger = serde_json::from_str(&json).unwrap();
match parsed {
Trigger::Event { pattern, channel } => {
assert_eq!(pattern, r"deploy\s+\w+");
assert_eq!(channel, Some("telegram".to_string()));
}
_ => panic!("Expected Trigger::Event"),
}
}
#[test]
fn test_trigger_webhook_serde() {
let trigger = Trigger::Webhook {
path: "/hooks/deploy".to_string(),
};
let json = serde_json::to_string(&trigger).unwrap();
let parsed: Trigger = serde_json::from_str(&json).unwrap();
match parsed {
Trigger::Webhook { path } => assert_eq!(path, "/hooks/deploy"),
_ => panic!("Expected Trigger::Webhook"),
}
}
#[test]
fn test_trigger_manual_serde() {
let trigger = Trigger::Manual;
let json = serde_json::to_string(&trigger).unwrap();
let parsed: Trigger = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, Trigger::Manual));
}
#[test]
fn test_action_lightweight_serde() {
let action = RoutineAction::Lightweight {
prompt: "Summarize today's logs".to_string(),
};
let json = serde_json::to_string(&action).unwrap();
let parsed: RoutineAction = serde_json::from_str(&json).unwrap();
match parsed {
RoutineAction::Lightweight { prompt } => {
assert_eq!(prompt, "Summarize today's logs");
}
_ => panic!("Expected RoutineAction::Lightweight"),
}
}
#[test]
fn test_action_full_job_serde() {
let action = RoutineAction::FullJob {
prompt: "Run the deployment pipeline".to_string(),
};
let json = serde_json::to_string(&action).unwrap();
let parsed: RoutineAction = serde_json::from_str(&json).unwrap();
match parsed {
RoutineAction::FullJob { prompt } => {
assert_eq!(prompt, "Run the deployment pipeline");
}
_ => panic!("Expected RoutineAction::FullJob"),
}
}
#[test]
fn test_guardrails_defaults() {
let guardrails = RoutineGuardrails::default();
assert_eq!(guardrails.cooldown_secs, 60);
assert_eq!(guardrails.max_concurrent, 1);
}
#[test]
fn test_store_add_and_list() {
let path = temp_path("add_list");
let _ = std::fs::remove_file(&path);
let mut store = RoutineStore::new(path.clone());
assert!(store.is_empty());
let routine = make_routine(
"r1",
Trigger::Manual,
RoutineAction::Lightweight {
prompt: "hello".to_string(),
},
);
store.add(routine).unwrap();
assert_eq!(store.len(), 1);
assert_eq!(store.list()[0].id, "r1");
assert_eq!(store.get("r1").unwrap().name, "Test r1");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_store_remove() {
let path = temp_path("remove");
let _ = std::fs::remove_file(&path);
let mut store = RoutineStore::new(path.clone());
let routine = make_routine(
"r1",
Trigger::Manual,
RoutineAction::Lightweight {
prompt: "hello".to_string(),
},
);
store.add(routine).unwrap();
assert_eq!(store.len(), 1);
store.remove("r1").unwrap();
assert!(store.is_empty());
let err = store.remove("r1").unwrap_err();
assert!(err.contains("not found"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_store_toggle() {
let path = temp_path("toggle");
let _ = std::fs::remove_file(&path);
let mut store = RoutineStore::new(path.clone());
let routine = make_routine(
"r1",
Trigger::Manual,
RoutineAction::Lightweight {
prompt: "hello".to_string(),
},
);
store.add(routine).unwrap();
assert!(store.get("r1").unwrap().enabled);
let enabled = store.toggle("r1").unwrap();
assert!(!enabled);
assert!(!store.get("r1").unwrap().enabled);
let enabled = store.toggle("r1").unwrap();
assert!(enabled);
assert!(store.get("r1").unwrap().enabled);
let err = store.toggle("nonexistent").unwrap_err();
assert!(err.contains("not found"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_store_duplicate_id_error() {
let path = temp_path("duplicate");
let _ = std::fs::remove_file(&path);
let mut store = RoutineStore::new(path.clone());
let routine = make_routine(
"r1",
Trigger::Manual,
RoutineAction::Lightweight {
prompt: "hello".to_string(),
},
);
store.add(routine.clone()).unwrap();
let err = store.add(routine).unwrap_err();
assert!(err.contains("already exists"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_store_persistence_roundtrip() {
let path = temp_path("persistence");
let _ = std::fs::remove_file(&path);
{
let mut store = RoutineStore::new(path.clone());
store
.add(make_routine(
"r1",
Trigger::Cron {
schedule: "0 9 * * *".to_string(),
},
RoutineAction::FullJob {
prompt: "daily report".to_string(),
},
))
.unwrap();
store
.add(make_routine(
"r2",
Trigger::Webhook {
path: "/hooks/deploy".to_string(),
},
RoutineAction::Lightweight {
prompt: "notify deploy".to_string(),
},
))
.unwrap();
}
let store = RoutineStore::new(path.clone());
assert_eq!(store.len(), 2);
assert_eq!(store.get("r1").unwrap().name, "Test r1");
assert_eq!(store.get("r2").unwrap().name, "Test r2");
match &store.get("r1").unwrap().trigger {
Trigger::Cron { schedule } => assert_eq!(schedule, "0 9 * * *"),
_ => panic!("Expected Trigger::Cron"),
}
match &store.get("r2").unwrap().action {
RoutineAction::Lightweight { prompt } => assert_eq!(prompt, "notify deploy"),
_ => panic!("Expected RoutineAction::Lightweight"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_cooldown_enforcement() {
let path = temp_path("cooldown");
let _ = std::fs::remove_file(&path);
let mut store = RoutineStore::new(path.clone());
let mut routine = make_routine(
"r1",
Trigger::Manual,
RoutineAction::Lightweight {
prompt: "hello".to_string(),
},
);
routine.guardrails.cooldown_secs = 0;
store.add(routine).unwrap();
assert!(store.check_cooldown("r1"));
store.record_execution("r1");
assert!(store.check_cooldown("r1"));
assert!(!store.check_cooldown("nonexistent"));
let mut routine2 = make_routine(
"r2",
Trigger::Manual,
RoutineAction::Lightweight {
prompt: "hello".to_string(),
},
);
routine2.guardrails.cooldown_secs = 3600; store.add(routine2).unwrap();
assert!(store.check_cooldown("r2"));
store.record_execution("r2");
assert!(!store.check_cooldown("r2"));
let _ = std::fs::remove_file(&path);
}
}