Files
Lumotia/src-tauri/src/commands/intentions.rs
Claude 89c63891fa chore: rebrand from Kon/Corbie to Magnotia
Replace all instances of the legacy product names "Kon" and "Corbie" with
"Magnotia" across user-facing copy, code identifiers, package names, bundle
ids, file paths, and documentation. Preserves the unrelated "konsole" (KDE
terminal) reference and the parent CORBEL company name.

- Renames 10 Rust crates (kon-* → magnotia-*) and the tauri binary
- Updates package.json, tauri.conf.json (productName + identifier)
- Renames CSS classes (kon-rh-* → magnotia-rh-*) and animations
- Renames brand and roadmap docs
- Regenerates Cargo.lock and package-lock.json

Verified: svelte-check passes; pure-rust crates compile under new names.
2026-04-30 13:06:55 +00:00

309 lines
9.4 KiB
Rust

//! 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<String>,
#[serde(default)]
label: Option<String>,
},
StartTimer {
seconds: u32,
#[serde(default)]
#[serde(rename = "taskId")]
task_id: Option<String>,
#[serde(default)]
label: Option<String>,
},
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<RuleAction>,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub last_fired_key: Option<String>,
}
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<RuleAction>,
pub last_fired_key: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl TryFrom<ImplementationRuleRow> for ImplementationRuleDto {
type Error = String;
fn try_from(row: ImplementationRuleRow) -> Result<Self, Self::Error> {
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::<Vec<RuleAction>>(&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<Vec<ImplementationRuleDto>, 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<ImplementationRuleDto, String> {
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<ImplementationRuleDto, String> {
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<ImplementationRuleDto, String> {
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());
}
}