mod engine;
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub use engine::{AutomationEngine, FiredResult};
#[derive(Debug, Clone, Default)]
pub struct EventContext {
pub tool_name: Option<String>,
pub error_message: Option<String>,
pub session_id: String,
}
impl EventContext {
pub fn apply(&self, template: &str) -> String {
let mut s = template.to_string();
s = s.replace("{{tool_name}}", self.tool_name.as_deref().unwrap_or(""));
s = s.replace(
"{{error_message}}",
self.error_message.as_deref().unwrap_or(""),
);
s = s.replace("{{session_id}}", &self.session_id);
s
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TriggerKind {
SessionStart,
Interval { every_secs: u64 },
Idle { after_secs: u64 },
TurnComplete,
ToolComplete {
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
},
OnError,
OnApproval,
}
impl TriggerKind {
pub const NAMES: &'static [&'static str] = &[
"Session Start", "Interval", "Idle", "Turn Complete", "Tool Complete", "On Error", "On Approval", ];
pub fn kind_index(&self) -> usize {
match self {
Self::SessionStart => 0,
Self::Interval { .. } => 1,
Self::Idle { .. } => 2,
Self::TurnComplete => 3,
Self::ToolComplete { .. } => 4,
Self::OnError => 5,
Self::OnApproval => 6,
}
}
pub fn has_secs(&self) -> bool {
matches!(self, Self::Interval { .. } | Self::Idle { .. })
}
pub fn has_tool_filter(&self) -> bool {
matches!(self, Self::ToolComplete { .. })
}
pub fn secs(&self) -> u64 {
match self {
Self::Interval { every_secs } => *every_secs,
Self::Idle { after_secs } => *after_secs,
_ => 0,
}
}
pub fn tool_filter(&self) -> Option<&str> {
if let Self::ToolComplete { tool_name: Some(n) } = self {
Some(n.as_str())
} else {
None
}
}
pub fn from_parts(kind_idx: usize, secs: u64, tool_filter: &str) -> Self {
let secs = secs.max(10);
let filter = if tool_filter.trim().is_empty() {
None
} else {
Some(tool_filter.trim().to_string())
};
match kind_idx {
1 => Self::Interval { every_secs: secs },
2 => Self::Idle { after_secs: secs },
3 => Self::TurnComplete,
4 => Self::ToolComplete { tool_name: filter },
5 => Self::OnError,
6 => Self::OnApproval,
_ => Self::SessionStart,
}
}
pub fn summary(&self) -> String {
match self {
Self::SessionStart => "on start".to_string(),
Self::Interval { every_secs } => format_duration_label(*every_secs, "every"),
Self::Idle { after_secs } => format_duration_label(*after_secs, "idle"),
Self::TurnComplete => "turn done".to_string(),
Self::ToolComplete { tool_name: None } => "any tool".to_string(),
Self::ToolComplete { tool_name: Some(n) } => format!("tool:{n}"),
Self::OnError => "on error".to_string(),
Self::OnApproval => "on approval".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ActionKind {
SendPrompt { text: String },
SlashRun { cmd: String },
RunShell { cmd: String },
Notify { message: String },
}
impl ActionKind {
pub const NAMES: &'static [&'static str] = &["Send Prompt", "Run /cmd", "Run Shell", "Notify"];
pub fn kind_index(&self) -> usize {
match self {
Self::SendPrompt { .. } => 0,
Self::SlashRun { .. } => 1,
Self::RunShell { .. } => 2,
Self::Notify { .. } => 3,
}
}
pub fn text(&self) -> &str {
match self {
Self::SendPrompt { text } => text,
Self::SlashRun { cmd } => cmd,
Self::RunShell { cmd } => cmd,
Self::Notify { message } => message,
}
}
pub fn from_parts(kind_idx: usize, text: String) -> Self {
match kind_idx {
1 => Self::SlashRun { cmd: text },
2 => Self::RunShell { cmd: text },
3 => Self::Notify { message: text },
_ => Self::SendPrompt { text },
}
}
pub fn summary(&self) -> String {
let raw = self.text();
let display = if raw.chars().count() > 28 {
let truncated: String = raw.chars().take(27).collect();
format!("{truncated}…")
} else {
raw.to_string()
};
match self {
Self::SendPrompt { .. } => format!("→ \"{display}\""),
Self::SlashRun { .. } => format!("→ /{display}"),
Self::RunShell { .. } => format!("$ {display}"),
Self::Notify { .. } => format!("🔔 {display}"),
}
}
}
#[derive(Debug, Deserialize)]
struct AutomationRuleRaw {
pub id: u64,
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
pub trigger: TriggerKind,
#[serde(default)]
pub actions: Vec<ActionKind>,
#[serde(default)]
pub action: Option<ActionKind>,
}
fn default_true() -> bool {
true
}
impl From<AutomationRuleRaw> for AutomationRule {
fn from(raw: AutomationRuleRaw) -> Self {
let actions = if !raw.actions.is_empty() {
raw.actions
} else if let Some(action) = raw.action {
vec![action]
} else {
Vec::new()
};
AutomationRule {
id: raw.id,
name: raw.name,
enabled: raw.enabled,
trigger: raw.trigger,
actions,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AutomationRule {
pub id: u64,
pub name: String,
pub enabled: bool,
pub trigger: TriggerKind,
pub actions: Vec<ActionKind>,
}
impl<'de> Deserialize<'de> for AutomationRule {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = AutomationRuleRaw::deserialize(d)?;
Ok(AutomationRule::from(raw))
}
}
impl AutomationRule {
pub fn new(id: u64, name: String, trigger: TriggerKind, actions: Vec<ActionKind>) -> Self {
Self {
id,
name,
enabled: true,
trigger,
actions,
}
}
pub fn primary_action_summary(&self) -> String {
match self.actions.first() {
Some(a) => a.summary(),
None => "(no actions)".to_string(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AutomationConfig {
#[serde(default)]
pub rules: Vec<AutomationRule>,
#[serde(default)]
pub next_id: u64,
}
impl AutomationConfig {
pub fn load() -> Self {
let Some(path) = Self::path() else {
return Self::default();
};
let Ok(raw) = fs::read_to_string(&path) else {
return Self::default();
};
let mut cfg: AutomationConfig = toml::from_str(&raw).unwrap_or_default();
let max_existing = cfg.rules.iter().map(|r| r.id).max().unwrap_or(0);
if cfg.next_id < max_existing {
cfg.next_id = max_existing;
}
cfg
}
pub fn save(&self) -> std::io::Result<()> {
let Some(path) = Self::path() else {
return Ok(());
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(self).map_err(std::io::Error::other)?;
fs::write(path, body)
}
pub fn path() -> Option<PathBuf> {
zagens_config::user_data_path("automation.toml").ok()
}
pub fn alloc_id(&mut self) -> u64 {
self.next_id += 1;
self.next_id
}
}
fn format_duration_label(secs: u64, prefix: &str) -> String {
if secs == 0 {
format!("{prefix} —")
} else if secs < 60 {
format!("{prefix} {secs}s")
} else if secs < 3600 {
let m = secs / 60;
let s = secs % 60;
if s == 0 {
format!("{prefix} {m}m")
} else {
format!("{prefix} {m}m{s}s")
}
} else {
let h = secs / 3600;
let rem = (secs % 3600) / 60;
if rem == 0 {
format!("{prefix} {h}h")
} else {
format!("{prefix} {h}h{rem}m")
}
}
}