use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskTrigger {
Once,
Cron,
Interval,
FileWatch,
Manual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
Running,
Completed,
Failed,
Scheduled,
Disabled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskExecution {
pub execution_id: String,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub status: TaskStatus,
pub answer: String,
pub error: Option<String>,
pub duration_ms: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub name: String,
pub prompt: String,
pub trigger: TaskTrigger,
pub schedule: String,
pub watch_path: String,
pub agent_metadata: HashMap<String, Value>,
pub max_turns: u32,
pub system_prompt: String,
pub status: TaskStatus,
pub created_at: DateTime<Utc>,
pub last_run_at: Option<DateTime<Utc>>,
pub run_count: u32,
pub executions: Vec<TaskExecution>,
pub enabled: bool,
}
impl Task {
pub fn new(name: &str, prompt: &str) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string()[..10].to_string(),
name: name.to_string(),
prompt: prompt.to_string(),
trigger: TaskTrigger::Manual,
schedule: String::new(),
watch_path: String::new(),
agent_metadata: HashMap::new(),
max_turns: 10,
system_prompt: String::new(),
status: TaskStatus::Pending,
created_at: Utc::now(),
last_run_at: None,
run_count: 0,
executions: Vec::new(),
enabled: true,
}
}
pub fn with_trigger(mut self, trigger: TaskTrigger, schedule: &str) -> Self {
self.trigger = trigger;
self.schedule = schedule.to_string();
self
}
pub fn with_file_watch(mut self, path: &str) -> Self {
self.trigger = TaskTrigger::FileWatch;
self.watch_path = path.to_string();
self
}
pub fn with_system_prompt(mut self, prompt: &str) -> Self {
self.system_prompt = prompt.to_string();
self
}
pub fn with_metadata(mut self, key: &str, value: Value) -> Self {
self.agent_metadata.insert(key.to_string(), value);
self
}
}
pub struct TaskStore {
dir: PathBuf,
}
impl TaskStore {
pub fn new(dir: &Path) -> Self {
Self {
dir: dir.to_path_buf(),
}
}
pub fn default_path() -> PathBuf {
dirs_home().join(".car").join("tasks")
}
pub fn save(&self, task: &Task) -> Result<PathBuf, std::io::Error> {
std::fs::create_dir_all(&self.dir)?;
let path = self.dir.join(format!("{}.json", task.id));
let json = serde_json::to_string_pretty(task)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
std::fs::write(&path, json)?;
Ok(path)
}
pub fn load(&self, task_id: &str) -> Option<Task> {
let path = self.dir.join(format!("{}.json", task_id));
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
pub fn list(&self) -> Vec<Task> {
let mut tasks = Vec::new();
if let Ok(entries) = std::fs::read_dir(&self.dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|e| e == "json") {
if let Ok(data) = std::fs::read_to_string(entry.path()) {
if let Ok(task) = serde_json::from_str::<Task>(&data) {
tasks.push(task);
}
}
}
}
}
tasks.sort_by(|a, b| a.created_at.cmp(&b.created_at));
tasks
}
pub fn delete(&self, task_id: &str) -> bool {
let path = self.dir.join(format!("{}.json", task_id));
std::fs::remove_file(path).is_ok()
}
}
fn dirs_home() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
}
pub fn parse_interval(schedule: &str) -> f64 {
let s = schedule.trim().to_lowercase();
if let Some(m) = s.strip_suffix('m') {
m.parse::<f64>().unwrap_or(60.0) * 60.0
} else if let Some(h) = s.strip_suffix('h') {
h.parse::<f64>().unwrap_or(1.0) * 3600.0
} else if let Some(sec) = s.strip_suffix('s') {
sec.parse::<f64>().unwrap_or(60.0)
} else {
s.parse::<f64>().unwrap_or(60.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_interval() {
assert_eq!(parse_interval("30"), 30.0);
assert_eq!(parse_interval("5m"), 300.0);
assert_eq!(parse_interval("1h"), 3600.0);
assert_eq!(parse_interval("10s"), 10.0);
}
#[test]
fn test_task_store_crud() {
let dir = TempDir::new().unwrap();
let store = TaskStore::new(dir.path());
let task = Task::new("test_task", "do something");
store.save(&task).unwrap();
let loaded = store.load(&task.id).unwrap();
assert_eq!(loaded.name, "test_task");
assert_eq!(loaded.prompt, "do something");
let all = store.list();
assert_eq!(all.len(), 1);
assert!(store.delete(&task.id));
assert!(store.load(&task.id).is_none());
}
#[test]
fn test_task_builder() {
let task = Task::new("deploy", "deploy to staging")
.with_trigger(TaskTrigger::Interval, "5m")
.with_system_prompt("You are a deployment agent")
.with_metadata("provider", Value::String("openai".to_string()));
assert_eq!(task.trigger, TaskTrigger::Interval);
assert_eq!(task.schedule, "5m");
assert!(!task.system_prompt.is_empty());
assert!(task.agent_metadata.contains_key("provider"));
}
}