//! Phase 7 implementation intentions: small if-then automation rules. //! //! The frontend owns scheduling and execution because the v1 triggers are //! app-local events (timer/task/ritual) plus local clock checks. Rust owns //! durable rule storage, validation, and main-window-only command access. use serde::{Deserialize, Serialize}; use uuid::Uuid; use magnotia_storage::{ delete_implementation_rule as db_delete_rule, get_task_by_id, insert_implementation_rule as db_insert_rule, list_implementation_rules as db_list_rules, mark_implementation_rule_fired as db_mark_rule_fired, set_implementation_rule_enabled as db_set_rule_enabled, ImplementationRuleRow, }; use crate::commands::security::ensure_main_window; use crate::AppState; #[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum RuleTriggerKind { TimeOfDay, TaskCompleted, MorningTriageFinished, } impl RuleTriggerKind { fn as_str(&self) -> &'static str { match self { RuleTriggerKind::TimeOfDay => "time_of_day", RuleTriggerKind::TaskCompleted => "task_completed", RuleTriggerKind::MorningTriageFinished => "morning_triage_finished", } } } #[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum SurfaceTarget { Inbox, Today, Tasks, Task, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RuleAction { Surface { target: SurfaceTarget, #[serde(default)] #[serde(rename = "taskId")] task_id: Option, #[serde(default)] label: Option, }, StartTimer { seconds: u32, #[serde(default)] #[serde(rename = "taskId")] task_id: Option, #[serde(default)] label: Option, }, SpeakLine { text: String, }, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateImplementationRuleRequest { pub trigger_kind: RuleTriggerKind, pub trigger_value: String, pub actions: Vec, #[serde(default = "default_enabled")] pub enabled: bool, #[serde(default)] pub last_fired_key: Option, } fn default_enabled() -> bool { true } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ImplementationRuleDto { pub id: String, pub enabled: bool, pub trigger_kind: RuleTriggerKind, pub trigger_value: String, pub actions: Vec, pub last_fired_key: Option, pub created_at: String, pub updated_at: String, } impl TryFrom for ImplementationRuleDto { type Error = String; fn try_from(row: ImplementationRuleRow) -> Result { let trigger_kind = match row.trigger_kind.as_str() { "time_of_day" => RuleTriggerKind::TimeOfDay, "task_completed" => RuleTriggerKind::TaskCompleted, "morning_triage_finished" => RuleTriggerKind::MorningTriageFinished, other => return Err(format!("unknown rule trigger kind: {other}")), }; let actions = serde_json::from_str::>(&row.actions_json) .map_err(|e| format!("invalid rule actions JSON for {}: {e}", row.id))?; Ok(Self { id: row.id, enabled: row.enabled, trigger_kind, trigger_value: row.trigger_value, actions, last_fired_key: row.last_fired_key, created_at: row.created_at, updated_at: row.updated_at, }) } } fn validate_hhmm(value: &str) -> Result<(), String> { let (h, m) = value .split_once(':') .ok_or_else(|| "time rules need HH:MM".to_string())?; if h.len() != 2 || m.len() != 2 { return Err("time rules need HH:MM".into()); } let hour: u8 = h .parse() .map_err(|_| "time hour must be 00-23".to_string())?; let minute: u8 = m .parse() .map_err(|_| "time minute must be 00-59".to_string())?; if hour > 23 || minute > 59 { return Err("time must be between 00:00 and 23:59".into()); } Ok(()) } async fn validate_actions( state: &tauri::State<'_, AppState>, actions: &[RuleAction], ) -> Result<(), String> { if actions.is_empty() { return Err("add at least one rule action".into()); } for action in actions { match action { RuleAction::Surface { target, task_id, label: _, } => { if matches!(target, SurfaceTarget::Task) { let task_id = task_id .as_deref() .map(str::trim) .filter(|id| !id.is_empty()) .ok_or_else(|| "surface-task actions need a task".to_string())?; let exists = get_task_by_id(&state.db, task_id) .await .map_err(|e| e.to_string())? .is_some(); if !exists { return Err(format!("task {task_id} does not exist")); } } } RuleAction::StartTimer { seconds, .. } => { if *seconds != 300 { return Err("v1 rule timers are fixed at 5 minutes".into()); } } RuleAction::SpeakLine { text } => { let trimmed = text.trim(); if trimmed.is_empty() { return Err("speak actions need a line to read".into()); } if trimmed.chars().count() > 240 { return Err("speak lines must be 240 characters or fewer".into()); } } } } Ok(()) } async fn validate_request( state: &tauri::State<'_, AppState>, request: &CreateImplementationRuleRequest, ) -> Result<(), String> { match request.trigger_kind { RuleTriggerKind::TimeOfDay => validate_hhmm(request.trigger_value.trim())?, RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => { if !request.trigger_value.trim().is_empty() { return Err("this trigger does not take an extra value".into()); } } } validate_actions(state, &request.actions).await } #[tauri::command] pub async fn list_implementation_rules( window: tauri::WebviewWindow, state: tauri::State<'_, AppState>, ) -> Result, String> { ensure_main_window(&window)?; db_list_rules(&state.db) .await .map_err(|e| e.to_string())? .into_iter() .map(ImplementationRuleDto::try_from) .collect() } #[tauri::command] pub async fn create_implementation_rule( window: tauri::WebviewWindow, state: tauri::State<'_, AppState>, request: CreateImplementationRuleRequest, ) -> Result { ensure_main_window(&window)?; validate_request(&state, &request).await?; let id = Uuid::new_v4().to_string(); let actions_json = serde_json::to_string(&request.actions) .map_err(|e| format!("serialise rule actions failed: {e}"))?; let trigger_value = match request.trigger_kind { RuleTriggerKind::TimeOfDay => request.trigger_value.trim().to_string(), RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => String::new(), }; db_insert_rule( &state.db, &id, request.enabled, request.trigger_kind.as_str(), &trigger_value, &actions_json, request.last_fired_key.as_deref(), ) .await .map_err(|e| e.to_string()) .and_then(ImplementationRuleDto::try_from) } #[tauri::command] pub async fn set_implementation_rule_enabled( window: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, enabled: bool, ) -> Result { ensure_main_window(&window)?; db_set_rule_enabled(&state.db, &id, enabled) .await .map_err(|e| e.to_string()) .and_then(ImplementationRuleDto::try_from) } #[tauri::command] pub async fn mark_implementation_rule_fired( window: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, fired_key: String, ) -> Result { ensure_main_window(&window)?; let key = fired_key.trim(); if key.is_empty() { return Err("fired_key is required".into()); } db_mark_rule_fired(&state.db, &id, key) .await .map_err(|e| e.to_string()) .and_then(ImplementationRuleDto::try_from) } #[tauri::command] pub async fn delete_implementation_rule( window: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { ensure_main_window(&window)?; db_delete_rule(&state.db, &id) .await .map_err(|e| e.to_string()) } #[cfg(test)] mod tests { use super::validate_hhmm; #[test] fn hhmm_validation_accepts_and_rejects_expected_shapes() { assert!(validate_hhmm("09:00").is_ok()); assert!(validate_hhmm("23:59").is_ok()); assert!(validate_hhmm("9:00").is_err()); assert!(validate_hhmm("24:00").is_err()); assert!(validate_hhmm("08:60").is_err()); } }