diff --git a/Cargo.toml b/Cargo.toml index 92cbb795..4198ae1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,6 +129,7 @@ no-plugin = [ # The release workflow builds the Windows target with this set. windows-default = ['no-plugin'] loop = [] +vigil = [] # Run dirge itself as an MCP server (`dirge mcp`) so another agent (e.g. # Claude Code) can delegate implementation tasks to dirge and review them. # Pulls rmcp's server side + stdio transport + the tool macros. diff --git a/docs/config.md b/docs/config.md index 9886163b..d9febf41 100644 --- a/docs/config.md +++ b/docs/config.md @@ -147,6 +147,7 @@ Accepted top-level keys: | `mcp_servers` | object | MCP server map when compiled with the `mcp` feature. When omitted, defaults to a single Exa Web Search server; see below. | | `acp_servers` | object | ACP server config map when compiled with the `acp` feature. See the ACP section below. | | `editor_open_command` | string | Opt-in editor follow-along: a command template with `{path}` and `{line}` placeholders (e.g. `"zed {path}:{line}"`, `"code --goto {path}:{line}"`). When set, dirge opens files it reads or edits in this external GUI editor, detached and non-blocking — the editor "follows along" like Zed's AI panel. `None` (unset) disables the feature entirely. | +| `vigils` | array | Vigil definitions consulted only when `--vigil` is active (compiled with the `vigil` feature). Each entry: `name`, a `trigger` (`toll` timer with `interval_secs`, `watcher` on a `path`, or `harbinger` TCP socket with `address` and `socket_mode`), an optional `reap_interval_secs` (default 30), an optional `prompt` for the observance turn, and optional `procession` (Janet) and `rite` gate. | ### Desktop Notifications diff --git a/src/cli.rs b/src/cli.rs index 66485653..f2f47de1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -307,6 +307,48 @@ pub enum Command { #[arg(long = "sandbox")] sandbox: Option, }, + /// Manage vigils — list, add, remove, pause, resume, restart. + #[cfg(feature = "vigil")] + Vigil { + #[command(subcommand)] + action: VigilAction, + }, +} + +/// Vigil management subcommands. +#[cfg(feature = "vigil")] +#[derive(clap::Subcommand, Debug)] +pub enum VigilAction { + /// List all configured vigils and their status. + List, + /// Add a new vigil trigger. + Add { + /// Vigil name. + name: String, + /// Trigger type: toll, watcher, or harbinger. + #[arg(value_enum)] + trigger: VigilAddTrigger, + /// Additional trigger args as key=value pairs. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Remove a vigil by name. + Remove { name: String }, + /// Pause a running vigil. + Pause { name: String }, + /// Resume a paused vigil. + Resume { name: String }, + /// Restart a vigil (stop and re-create its trigger). + Rest { name: String }, +} + +/// Trigger type for `dirge vigil add`. +#[cfg(feature = "vigil")] +#[derive(clap::ValueEnum, Debug, Clone)] +pub enum VigilAddTrigger { + Toll, + Watcher, + Harbinger, } #[derive(clap::Subcommand, Debug)] diff --git a/src/config/mod.rs b/src/config/mod.rs index ce7f5e95..a4136049 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::Deserialize; +#[cfg(feature = "vigil")] +use serde::Serialize; use crate::session::storage; @@ -1373,6 +1375,92 @@ pub struct Config { /// future expansion but are not honored today. #[cfg(feature = "acp")] pub acp_servers: Option>, + + /// Vigil definitions loaded from `config.toml` under `[vigils.]`. + /// Only consulted when `--vigil` is active. + #[cfg(feature = "vigil")] + #[serde(default)] + pub vigils: Option>, +} + +/// A single vigil definition from config. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(default)] +pub struct VigilEntry { + pub name: String, + pub trigger: VigilTrigger, + #[serde(default = "default_reap_interval")] + pub reap_interval_secs: u64, + #[serde(default)] + pub prompt: String, + /// Optional Janet script for per-observance procession. + #[serde(default)] + pub procession: Option, + #[serde(default)] + pub rite: Option, +} + +#[cfg(feature = "vigil")] +fn default_reap_interval() -> u64 { + 30 +} + +/// What triggers a vigil to fire. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum VigilTrigger { + /// Timer-based: fires every N seconds. + Toll { interval_secs: u64 }, + /// Filesystem watcher: fires on changes under `path`. + Watcher { path: String }, + /// Network socket: external process sends events to a TCP port. + Harbinger { + address: String, + #[serde(default)] + protocol: String, + /// `template` or `commands` — see `SocketMode`. + #[serde(default)] + socket_mode: SocketMode, + #[serde(default)] + commands: HashMap, + }, +} + +#[cfg(feature = "vigil")] +impl Default for VigilTrigger { + fn default() -> Self { + VigilTrigger::Toll { interval_secs: 30 } + } +} + +/// Harbinger socket mode. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum SocketMode { + #[default] + Template, + Commands, +} + +/// A pre-registered command for `commands` socket mode. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VigilCommand { + pub tool: String, + #[serde(default)] + pub args: serde_json::Map, +} + +/// Optional gate condition checked before an observance runs. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct VigilRite { + pub cmd: Option, + #[serde(default)] + pub git_dirty: bool, } impl Config { diff --git a/src/extras/mod.rs b/src/extras/mod.rs index 3b4f3207..9f8628ee 100644 --- a/src/extras/mod.rs +++ b/src/extras/mod.rs @@ -42,3 +42,4 @@ pub mod session_search; pub mod skill_db; pub mod skills; pub mod spec_db; +pub mod vigil_db; diff --git a/src/extras/vigil_db.rs b/src/extras/vigil_db.rs new file mode 100644 index 00000000..179be401 --- /dev/null +++ b/src/extras/vigil_db.rs @@ -0,0 +1,281 @@ +//! SQLite store for vigil heartbeat/wakeup configurations. +//! +//! Vigil entries live in the per-project session DB (`.dirge/sessions/state.db`). +//! The store owns its schema via idempotent `CREATE TABLE IF NOT EXISTS` on open. +#![allow(dead_code)] + +use std::path::Path; +use std::sync::Mutex; + +use rusqlite::{Connection, OpenFlags, OptionalExtension, params}; + +/// Lifecycle states for a vigil. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VigilStatus { + Active, + Paused, + Resting, +} + +impl VigilStatus { + pub fn as_str(&self) -> &'static str { + match self { + VigilStatus::Active => "active", + VigilStatus::Paused => "paused", + VigilStatus::Resting => "resting", + } + } +} + +/// A stored vigil row. +pub struct VigilRow { + pub name: String, + pub payload_json: String, + pub status: VigilStatus, + pub created_at: String, + pub updated_at: String, +} + +/// SQLite-backed vigil store. +pub struct VigilStore { + conn: Mutex, +} + +impl VigilStore { + pub fn open(paths: &super::dirge_paths::ProjectPaths) -> Result { + Self::open_at(&paths.session_db_path()) + } + + pub fn open_at(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + ) + .map_err(|e| format!("open vigil db at {}: {e}", path.display()))?; + let _ = conn.busy_timeout(std::time::Duration::from_secs(5)); + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + let store = Self { + conn: Mutex::new(conn), + }; + store.ensure_schema()?; + Ok(store) + } + + fn ensure_schema(&self) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS vigils ( + name TEXT PRIMARY KEY NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_vigils_status ON vigils(status);", + ) + .map_err(|e| format!("create vigils table: {e}")) + } + + pub fn upsert(&self, name: &str, payload_json: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO vigils (name, payload_json, status, updated_at) + VALUES (?1, ?2, 'active', datetime('now')) + ON CONFLICT(name) DO UPDATE SET + payload_json = excluded.payload_json, + status = 'active', + updated_at = datetime('now')", + params![name, payload_json], + ) + .map_err(|e| format!("upsert vigil {name}: {e}"))?; + Ok(()) + } + + pub fn set_status(&self, name: &str, status: VigilStatus) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let affected = conn + .execute( + "UPDATE vigils SET status = ?1, updated_at = datetime('now') WHERE name = ?2", + params![status.as_str(), name], + ) + .map_err(|e| format!("set status for vigil {name}: {e}"))?; + if affected == 0 { + return Err(format!("vigil {name} not found")); + } + Ok(()) + } + + pub fn remove(&self, name: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let affected = conn + .execute("DELETE FROM vigils WHERE name = ?1", params![name]) + .map_err(|e| format!("remove vigil {name}: {e}"))?; + if affected == 0 { + return Err(format!("vigil {name} not found")); + } + Ok(()) + } + + pub fn get(&self, name: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT name, payload_json, status, created_at, updated_at + FROM vigils WHERE name = ?1", + ) + .map_err(|e| format!("prepare get vigil {name}: {e}"))?; + let row = stmt + .query_row(params![name], |row| { + Ok(VigilRow { + name: row.get(0)?, + payload_json: row.get(1)?, + status: { + let s: String = row.get(2)?; + match s.as_str() { + "active" => VigilStatus::Active, + "paused" => VigilStatus::Paused, + "resting" => VigilStatus::Resting, + _ => VigilStatus::Active, + } + }, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .optional() + .map_err(|e| format!("get vigil {name}: {e}"))?; + Ok(row) + } + + pub fn list_non_resting(&self) -> Result, String> { + self.query_rows( + "SELECT name, payload_json, status, created_at, updated_at + FROM vigils WHERE status != 'resting' ORDER BY name", + ) + } + + /// All rows, including resting vigils. `dirge vigil list` needs the + /// full picture so a vigil laid to rest still shows up with its status. + pub fn list_all(&self) -> Result, String> { + self.query_rows( + "SELECT name, payload_json, status, created_at, updated_at + FROM vigils ORDER BY name", + ) + } + + fn query_rows(&self, sql: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare(sql) + .map_err(|e| format!("prepare vigil list: {e}"))?; + let rows = stmt + .query_map([], |row| { + Ok(VigilRow { + name: row.get(0)?, + payload_json: row.get(1)?, + status: { + let s: String = row.get(2)?; + match s.as_str() { + "active" => VigilStatus::Active, + "paused" => VigilStatus::Paused, + "resting" => VigilStatus::Resting, + _ => VigilStatus::Active, + } + }, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .map_err(|e| format!("list vigils: {e}"))? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + + fn temp_db() -> (VigilStore, std::path::PathBuf) { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("dirge-vigildb-test-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = VigilStore::open_at(&dir.join("state.db")).unwrap(); + (store, dir) + } + + #[test] + fn upsert_then_get_roundtrips_payload() { + let (store, _dir) = temp_db(); + store.upsert("poll", "{\"name\":\"poll\"}").unwrap(); + let row = store.get("poll").unwrap().expect("row exists"); + assert_eq!(row.name, "poll"); + assert_eq!(row.payload_json, "{\"name\":\"poll\"}"); + assert_eq!(row.status, VigilStatus::Active); + } + + #[test] + fn upsert_resets_status_to_active() { + let (store, _dir) = temp_db(); + store.upsert("poll", "v1").unwrap(); + store.set_status("poll", VigilStatus::Paused).unwrap(); + store.upsert("poll", "v2").unwrap(); + let row = store.get("poll").unwrap().unwrap(); + assert_eq!(row.status, VigilStatus::Active); + assert_eq!(row.payload_json, "v2"); + } + + #[test] + fn list_non_resting_excludes_resting() { + let (store, _dir) = temp_db(); + store.upsert("a", "1").unwrap(); + store.upsert("b", "2").unwrap(); + store.upsert("c", "3").unwrap(); + store.set_status("b", VigilStatus::Resting).unwrap(); + let names: Vec = store + .list_non_resting() + .unwrap() + .into_iter() + .map(|r| r.name) + .collect(); + assert_eq!(names, vec!["a", "c"]); + } + + #[test] + fn list_all_includes_resting() { + let (store, _dir) = temp_db(); + store.upsert("a", "1").unwrap(); + store.upsert("b", "2").unwrap(); + store.set_status("b", VigilStatus::Resting).unwrap(); + let names: Vec = store + .list_all() + .unwrap() + .into_iter() + .map(|r| r.name) + .collect(); + assert_eq!(names, vec!["a", "b"]); + } + + #[test] + fn remove_deletes_row() { + let (store, _dir) = temp_db(); + store.upsert("poll", "1").unwrap(); + store.remove("poll").unwrap(); + assert!(store.get("poll").unwrap().is_none()); + } + + #[test] + fn status_and_remove_on_missing_name_error() { + let (store, _dir) = temp_db(); + assert!(store.set_status("nope", VigilStatus::Paused).is_err()); + assert!(store.remove("nope").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index b5d582e7..4f975696 100644 --- a/src/main.rs +++ b/src/main.rs @@ -573,6 +573,8 @@ async fn main() -> anyhow::Result<()> { cli::Command::Sandbox { .. } => {} #[cfg(feature = "mcp-server")] cli::Command::Mcp { .. } => {} + #[cfg(feature = "vigil")] + cli::Command::Vigil { .. } => {} } } @@ -747,6 +749,11 @@ async fn main() -> anyhow::Result<()> { cli::Command::Mcp { model, sandbox } => { return extras::mcp_server::serve(&cli, &cfg, model.clone(), sandbox.clone()).await; } + #[cfg(feature = "vigil")] + cli::Command::Vigil { action } => { + handle_vigil_command(action).await?; + return Ok(()); + } } } @@ -2699,3 +2706,261 @@ mod resume_staleness_tests { ); } } + +/// Handle `dirge vigil add/list/remove/pause/resume/rest` subcommands. +#[cfg(feature = "vigil")] +async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Result<()> { + use crate::extras::dirge_paths::ProjectPaths; + use crate::extras::vigil_db::{VigilStatus, VigilStore}; + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + + match action { + crate::cli::VigilAction::List => { + let vigils = collect_vigils_for_list(&paths, config::load().vigils.unwrap_or_default()); + println!("Vigils:"); + for (v, status) in &vigils { + let trigger = match &v.trigger { + crate::config::VigilTrigger::Toll { interval_secs } => { + format!("toll every {interval_secs}s") + } + crate::config::VigilTrigger::Watcher { path } => { + format!("watcher on {path}") + } + crate::config::VigilTrigger::Harbinger { + address, protocol, .. + } => { + let p = if protocol.is_empty() { + "tcp" + } else { + protocol.as_str() + }; + format!("harbinger {p}://{address}") + } + }; + let prompt = if v.prompt.is_empty() { + "(default)".to_string() + } else { + v.prompt.clone() + }; + println!( + " {} - {trigger} - reap every {}s - {} - prompt: {prompt}", + v.name, + v.reap_interval_secs, + status.as_str() + ); + } + if vigils.is_empty() { + println!(" (none)"); + } + } + crate::cli::VigilAction::Add { + name, + trigger, + args, + } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + let entry = build_vigil_entry(name, trigger, args)?; + let json = serde_json::to_string(&entry)?; + store + .upsert(&entry.name, &json) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!( + "Added vigil '{}'. Run `dirge --vigil` to start the keeper.", + entry.name + ); + } + crate::cli::VigilAction::Remove { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + store.remove(name).map_err(|e| anyhow::anyhow!("{e}"))?; + println!("Removed vigil '{name}'."); + } + crate::cli::VigilAction::Pause { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + store + .set_status(name, VigilStatus::Paused) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!("Paused vigil '{name}'."); + } + crate::cli::VigilAction::Resume { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + store + .set_status(name, VigilStatus::Active) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!("Resumed vigil '{name}'."); + } + crate::cli::VigilAction::Rest { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + store + .set_status(name, VigilStatus::Resting) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!("vigil '{name}' resting (will sleep until next trigger)."); + } + } + Ok(()) +} + +/// Build a VigilEntry from CLI `vigil add` args. +#[cfg(feature = "vigil")] +fn build_vigil_entry( + name: &str, + trigger: &crate::cli::VigilAddTrigger, + args: &[String], +) -> anyhow::Result { + use crate::config::{SocketMode, VigilEntry, VigilRite, VigilTrigger}; + + // Parse key=value args strictly: a keyless arg is a typo, not a value. + let mut parsed: std::collections::HashMap = std::collections::HashMap::new(); + for arg in args { + let (key, value) = arg + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("invalid vigil arg '{arg}': expected key=value"))?; + parsed.insert(key.to_string(), value.to_string()); + } + + let known: &[&str] = match trigger { + crate::cli::VigilAddTrigger::Toll => &["interval_secs", "reap_interval_secs", "prompt"], + crate::cli::VigilAddTrigger::Watcher => &["path", "reap_interval_secs", "prompt"], + crate::cli::VigilAddTrigger::Harbinger => &[ + "address", + "protocol", + "socket_mode", + "reap_interval_secs", + "prompt", + ], + }; + for key in parsed.keys() { + if !known.contains(&key.as_str()) { + anyhow::bail!("unknown vigil arg '{key}' for {trigger:?}"); + } + } + + let trigger = match trigger { + crate::cli::VigilAddTrigger::Toll => { + let interval_secs = parse_positive_secs(&parsed, "interval_secs", 30)?; + VigilTrigger::Toll { interval_secs } + } + crate::cli::VigilAddTrigger::Watcher => { + let path = parsed + .get("path") + .cloned() + .unwrap_or_else(|| ".".to_string()); + VigilTrigger::Watcher { path } + } + crate::cli::VigilAddTrigger::Harbinger => { + let address = parsed + .get("address") + .cloned() + .unwrap_or_else(|| "127.0.0.1:9000".to_string()); + let protocol = parsed.get("protocol").cloned().unwrap_or_default(); + // The flat key=value CLI cannot express a commands map, so a + // CLI-added harbinger is template mode. Commands-mode harbingers + // must come from config or a .dirge/vigils/*.json file. + let socket_mode = match parsed.get("socket_mode").map(String::as_str) { + None | Some("template") => SocketMode::Template, + Some("commands") => anyhow::bail!( + "commands-mode harbingers need a commands map; define '{name}' in config or a vigil JSON file instead" + ), + Some(other) => { + anyhow::bail!("invalid socket_mode '{other}': expected template or commands") + } + }; + VigilTrigger::Harbinger { + address, + protocol, + socket_mode, + commands: std::collections::HashMap::new(), + } + } + }; + + let reap_interval_secs = parse_positive_secs(&parsed, "reap_interval_secs", 30)?; + + let prompt = parsed.get("prompt").cloned().unwrap_or_default(); + + Ok(VigilEntry { + name: name.to_string(), + trigger, + reap_interval_secs, + prompt, + procession: None, + rite: Some(VigilRite { + cmd: None, + git_dirty: false, + }), + }) +} + +/// Parse a positive-integer seconds arg, rejecting zero and non-numeric +/// values. Zero trips tokio's non-zero interval assert (the trigger dies +/// silently) and a zero reap interval tight-loops the reaper. +#[cfg(feature = "vigil")] +fn parse_positive_secs( + parsed: &std::collections::HashMap, + key: &str, + default: u64, +) -> anyhow::Result { + match parsed.get(key) { + None => Ok(default), + Some(raw) => { + let secs: u64 = raw + .parse() + .map_err(|_| anyhow::anyhow!("{key} must be a positive integer, got '{raw}'"))?; + if secs == 0 { + anyhow::bail!("{key} must be greater than zero"); + } + Ok(secs) + } + } +} + +/// Enumerate vigils for `dirge vigil list`, merged from config and the SQLite +/// store by name. Config entries win on name collision; the store is +/// authoritative for status, so a config vigil paused or rested via the CLI +/// still shows its state. Entries only in the store (added via +/// `dirge vigil add`) show up too. +#[cfg(feature = "vigil")] +fn collect_vigils_for_list( + paths: &crate::extras::dirge_paths::ProjectPaths, + config_vigils: Vec, +) -> Vec<( + crate::config::VigilEntry, + crate::extras::vigil_db::VigilStatus, +)> { + use crate::config::VigilEntry; + use crate::extras::vigil_db::{VigilStatus, VigilStore}; + use std::collections::HashMap; + + let mut status_by_name: HashMap = HashMap::new(); + let mut entry_by_name: HashMap = HashMap::new(); + + // Store first (lowest entry precedence; authoritative for status). + if let Ok(store) = VigilStore::open(paths) { + for row in store.list_all().unwrap_or_default() { + status_by_name.insert(row.name.clone(), row.status); + if let Ok(entry) = serde_json::from_str::(&row.payload_json) { + entry_by_name.insert(row.name.clone(), entry); + } + } + } + + // Config wins over store on name collision. + for entry in config_vigils { + entry_by_name.insert(entry.name.clone(), entry); + } + + let mut out: Vec<(VigilEntry, VigilStatus)> = entry_by_name + .into_iter() + .map(|(name, entry)| { + let status = status_by_name + .get(&name) + .copied() + .unwrap_or(VigilStatus::Active); + (entry, status) + }) + .collect(); + out.sort_by(|a, b| a.0.name.cmp(&b.0.name)); + out +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 94ef6781..21c8d41e 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -10,6 +10,8 @@ mod learning_loop_tests; mod picker_tests; #[cfg(all(test, feature = "semantic"))] mod semantic_tests; +#[cfg(all(test, feature = "vigil"))] +mod vigil_tests; use ctor::ctor; // Install rustls ring crypto provider before any test runs. // Tests bypass main(), so the provider install in main() is not diff --git a/src/tests/vigil_tests.rs b/src/tests/vigil_tests.rs new file mode 100644 index 00000000..62dada03 --- /dev/null +++ b/src/tests/vigil_tests.rs @@ -0,0 +1,199 @@ +//! Tests for the `dirge vigil` CLI management layer: entry parsing and the +//! vigil config serde shape. Gated on the `vigil` feature because every type +//! under test (VigilEntry, VigilTrigger, VigilAddTrigger) is cfg-gated too. + +use crate::cli::VigilAddTrigger; +use crate::config::{SocketMode, VigilEntry, VigilTrigger}; + +fn args(values: &[&str]) -> Vec { + values.iter().map(|s| s.to_string()).collect() +} + +#[test] +fn toll_entry_uses_defaults() { + let entry = crate::build_vigil_entry("poll", &VigilAddTrigger::Toll, &[]).unwrap(); + assert_eq!(entry.name, "poll"); + assert!(matches!( + entry.trigger, + VigilTrigger::Toll { interval_secs: 30 } + )); + assert_eq!(entry.reap_interval_secs, 30); + assert!(entry.prompt.is_empty()); + assert!(entry.rite.is_some()); +} + +#[test] +fn toll_entry_parses_interval_and_reap() { + let entry = crate::build_vigil_entry( + "poll", + &VigilAddTrigger::Toll, + &args(&["interval_secs=60", "reap_interval_secs=10", "prompt=hi"]), + ) + .unwrap(); + assert!(matches!( + entry.trigger, + VigilTrigger::Toll { interval_secs: 60 } + )); + assert_eq!(entry.reap_interval_secs, 10); + assert_eq!(entry.prompt, "hi"); +} + +#[test] +fn watcher_entry_parses_path() { + let entry = + crate::build_vigil_entry("w", &VigilAddTrigger::Watcher, &args(&["path=/tmp/watch"])) + .unwrap(); + assert!(matches!(entry.trigger, VigilTrigger::Watcher { path } if path == "/tmp/watch")); +} + +#[test] +fn watcher_entry_defaults_path_to_dot() { + let entry = crate::build_vigil_entry("w", &VigilAddTrigger::Watcher, &[]).unwrap(); + assert!(matches!(entry.trigger, VigilTrigger::Watcher { path } if path == ".")); +} + +#[test] +fn harbinger_entry_defaults_to_template_mode() { + let entry = crate::build_vigil_entry("h", &VigilAddTrigger::Harbinger, &[]).unwrap(); + match entry.trigger { + VigilTrigger::Harbinger { + address, + protocol, + socket_mode, + commands, + } => { + assert_eq!(address, "127.0.0.1:9000"); + assert!(protocol.is_empty()); + assert_eq!(socket_mode, SocketMode::Template); + assert!(commands.is_empty()); + } + other => panic!("expected harbinger, got {other:?}"), + } +} + +#[test] +fn toll_entry_rejects_zero_interval() { + let err = crate::build_vigil_entry("poll", &VigilAddTrigger::Toll, &args(&["interval_secs=0"])) + .unwrap_err(); + assert!(err.to_string().contains("interval_secs"), "{err}"); +} + +#[test] +fn entry_rejects_zero_reap_interval() { + let err = crate::build_vigil_entry( + "poll", + &VigilAddTrigger::Toll, + &args(&["reap_interval_secs=0"]), + ) + .unwrap_err(); + assert!(err.to_string().contains("reap_interval_secs"), "{err}"); +} + +#[test] +fn entry_rejects_keyless_arg() { + let err = + crate::build_vigil_entry("poll", &VigilAddTrigger::Toll, &args(&["bogus"])).unwrap_err(); + assert!(err.to_string().contains("key=value"), "{err}"); +} + +#[test] +fn entry_rejects_unknown_arg() { + let err = crate::build_vigil_entry("poll", &VigilAddTrigger::Toll, &args(&["interval_sec=60"])) + .unwrap_err(); + assert!(err.to_string().contains("unknown vigil arg"), "{err}"); +} + +#[test] +fn harbinger_rejects_commands_socket_mode() { + let err = crate::build_vigil_entry( + "h", + &VigilAddTrigger::Harbinger, + &args(&["socket_mode=commands"]), + ) + .unwrap_err(); + assert!(err.to_string().contains("commands"), "{err}"); +} + +#[test] +fn list_merges_config_and_store_with_status() { + use crate::extras::dirge_paths::ProjectPaths; + use crate::extras::vigil_db::{VigilStatus, VigilStore}; + use std::sync::atomic::{AtomicU32, Ordering}; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let tmp = std::env::temp_dir().join(format!("dirge-vigil-list-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let paths = ProjectPaths::at(&tmp); + + // Store-only vigil, laid to rest: must appear with its resting status. + let store = VigilStore::open(&paths).unwrap(); + let store_entry = VigilEntry { + name: "from-store".to_string(), + trigger: VigilTrigger::Toll { interval_secs: 30 }, + ..Default::default() + }; + store + .upsert("from-store", &serde_json::to_string(&store_entry).unwrap()) + .unwrap(); + store + .set_status("from-store", VigilStatus::Resting) + .unwrap(); + + // Config vigil: must appear and default to Active. + let config_entry = VigilEntry { + name: "from-config".to_string(), + trigger: VigilTrigger::Watcher { + path: "/tmp/x".to_string(), + }, + ..Default::default() + }; + + let vigils = crate::collect_vigils_for_list(&paths, vec![config_entry]); + let names: Vec<&str> = vigils.iter().map(|(e, _)| e.name.as_str()).collect(); + assert_eq!(names, vec!["from-config", "from-store"]); + + for (entry, status) in &vigils { + match entry.name.as_str() { + "from-store" => assert_eq!(*status, VigilStatus::Resting), + "from-config" => assert_eq!(*status, VigilStatus::Active), + other => panic!("unexpected vigil {other}"), + } + } + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn config_deserializes_toll_with_defaults() { + let entry: VigilEntry = + serde_json::from_str(r#"{"name":"poll","trigger":{"type":"toll","interval_secs":45}}"#) + .unwrap(); + assert!(matches!( + entry.trigger, + VigilTrigger::Toll { interval_secs: 45 } + )); + assert_eq!(entry.reap_interval_secs, 30); + assert!(entry.prompt.is_empty()); +} + +#[test] +fn config_deserializes_harbinger_kebab_case() { + let entry: VigilEntry = serde_json::from_str( + r#"{"name":"jh","trigger":{"type":"harbinger","address":"127.0.0.1:9001","socket_mode":"commands"}}"#, + ) + .unwrap(); + match entry.trigger { + VigilTrigger::Harbinger { + address, + protocol, + socket_mode, + .. + } => { + assert_eq!(address, "127.0.0.1:9001"); + assert!(protocol.is_empty()); + assert_eq!(socket_mode, SocketMode::Commands); + } + other => panic!("expected harbinger, got {other:?}"), + } +}