Skip to content

Commit 9dd7cc7

Browse files
committed
vigil: fix slice-1 review findings
- dirge vigil list merges config + VigilStore entries with status, so store-only and paused/rested vigils are visible (was config-only) - build_vigil_entry validates interval/reap intervals > 0 and rejects keyless or unknown key=value args instead of silently dropping them - harbinger CLI entries default to Template mode (an empty Commands map was an invalid entry) - Remove/Pause/Resume/Rest propagate store errors as non-zero exit - add VigilStore::list_all; add tests for all of the above
1 parent ab4f350 commit 9dd7cc7

3 files changed

Lines changed: 265 additions & 48 deletions

File tree

src/extras/vigil_db.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,26 @@ impl VigilStore {
151151
}
152152

153153
pub fn list_non_resting(&self) -> Result<Vec<VigilRow>, String> {
154+
self.query_rows(
155+
"SELECT name, payload_json, status, created_at, updated_at
156+
FROM vigils WHERE status != 'resting' ORDER BY name",
157+
)
158+
}
159+
160+
/// All rows, including resting vigils. `dirge vigil list` needs the
161+
/// full picture so a vigil laid to rest still shows up with its status.
162+
pub fn list_all(&self) -> Result<Vec<VigilRow>, String> {
163+
self.query_rows(
164+
"SELECT name, payload_json, status, created_at, updated_at
165+
FROM vigils ORDER BY name",
166+
)
167+
}
168+
169+
fn query_rows(&self, sql: &str) -> Result<Vec<VigilRow>, String> {
154170
let conn = self.conn.lock().unwrap();
155171
let mut stmt = conn
156-
.prepare(
157-
"SELECT name, payload_json, status, created_at, updated_at
158-
FROM vigils WHERE status != 'resting' ORDER BY name",
159-
)
160-
.map_err(|e| format!("prepare list_non_resting: {e}"))?;
172+
.prepare(sql)
173+
.map_err(|e| format!("prepare vigil list: {e}"))?;
161174
let rows = stmt
162175
.query_map([], |row| {
163176
Ok(VigilRow {
@@ -176,7 +189,7 @@ impl VigilStore {
176189
updated_at: row.get(4)?,
177190
})
178191
})
179-
.map_err(|e| format!("list_non_resting: {e}"))?
192+
.map_err(|e| format!("list vigils: {e}"))?
180193
.filter_map(|r| r.ok())
181194
.collect();
182195
Ok(rows)
@@ -236,6 +249,21 @@ mod tests {
236249
assert_eq!(names, vec!["a", "c"]);
237250
}
238251

252+
#[test]
253+
fn list_all_includes_resting() {
254+
let (store, _dir) = temp_db();
255+
store.upsert("a", "1").unwrap();
256+
store.upsert("b", "2").unwrap();
257+
store.set_status("b", VigilStatus::Resting).unwrap();
258+
let names: Vec<String> = store
259+
.list_all()
260+
.unwrap()
261+
.into_iter()
262+
.map(|r| r.name)
263+
.collect();
264+
assert_eq!(names, vec!["a", "b"]);
265+
}
266+
239267
#[test]
240268
fn remove_deletes_row() {
241269
let (store, _dir) = temp_db();

src/main.rs

Lines changed: 136 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2706,9 +2706,9 @@ async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Resul
27062706

27072707
match action {
27082708
crate::cli::VigilAction::List => {
2709-
let vigils = config::load().vigils.unwrap_or_default();
2710-
println!("Vigils (from config):");
2711-
for v in &vigils {
2709+
let vigils = collect_vigils_for_list(&paths, config::load().vigils.unwrap_or_default());
2710+
println!("Vigils:");
2711+
for (v, status) in &vigils {
27122712
let trigger = match &v.trigger {
27132713
crate::config::VigilTrigger::Toll { interval_secs } => {
27142714
format!("toll every {interval_secs}s")
@@ -2733,8 +2733,10 @@ async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Resul
27332733
v.prompt.clone()
27342734
};
27352735
println!(
2736-
" {} - {trigger} - reap every {}s - prompt: {prompt}",
2737-
v.name, v.reap_interval_secs
2736+
" {} - {trigger} - reap every {}s - {} - prompt: {prompt}",
2737+
v.name,
2738+
v.reap_interval_secs,
2739+
status.as_str()
27382740
);
27392741
}
27402742
if vigils.is_empty() {
@@ -2759,31 +2761,29 @@ async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Resul
27592761
}
27602762
crate::cli::VigilAction::Remove { name } => {
27612763
let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?;
2762-
match store.remove(name) {
2763-
Ok(()) => println!("Removed vigil '{name}'."),
2764-
Err(e) => eprintln!("{e}"),
2765-
}
2764+
store.remove(name).map_err(|e| anyhow::anyhow!("{e}"))?;
2765+
println!("Removed vigil '{name}'.");
27662766
}
27672767
crate::cli::VigilAction::Pause { name } => {
27682768
let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?;
2769-
match store.set_status(name, VigilStatus::Paused) {
2770-
Ok(()) => println!("Paused vigil '{name}'."),
2771-
Err(e) => eprintln!("{e}"),
2772-
}
2769+
store
2770+
.set_status(name, VigilStatus::Paused)
2771+
.map_err(|e| anyhow::anyhow!("{e}"))?;
2772+
println!("Paused vigil '{name}'.");
27732773
}
27742774
crate::cli::VigilAction::Resume { name } => {
27752775
let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?;
2776-
match store.set_status(name, VigilStatus::Active) {
2777-
Ok(()) => println!("Resumed vigil '{name}'."),
2778-
Err(e) => eprintln!("{e}"),
2779-
}
2776+
store
2777+
.set_status(name, VigilStatus::Active)
2778+
.map_err(|e| anyhow::anyhow!("{e}"))?;
2779+
println!("Resumed vigil '{name}'.");
27802780
}
27812781
crate::cli::VigilAction::Rest { name } => {
27822782
let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?;
2783-
match store.set_status(name, VigilStatus::Resting) {
2784-
Ok(()) => println!("vigil '{name}' resting (will sleep until next trigger)."),
2785-
Err(e) => eprintln!("{e}"),
2786-
}
2783+
store
2784+
.set_status(name, VigilStatus::Resting)
2785+
.map_err(|e| anyhow::anyhow!("{e}"))?;
2786+
println!("vigil '{name}' resting (will sleep until next trigger).");
27872787
}
27882788
}
27892789
Ok(())
@@ -2796,23 +2796,38 @@ fn build_vigil_entry(
27962796
trigger: &crate::cli::VigilAddTrigger,
27972797
args: &[String],
27982798
) -> anyhow::Result<crate::config::VigilEntry> {
2799-
use crate::config::{VigilEntry, VigilRite, VigilTrigger};
2800-
2801-
let parsed: std::collections::HashMap<String, String> = args
2802-
.iter()
2803-
.filter_map(|a| a.split_once('='))
2804-
.map(|(k, v)| (k.to_string(), v.to_string()))
2805-
.collect();
2799+
use crate::config::{SocketMode, VigilEntry, VigilRite, VigilTrigger};
2800+
2801+
// Parse key=value args strictly: a keyless arg is a typo, not a value.
2802+
let mut parsed: std::collections::HashMap<String, String> = std::collections::HashMap::new();
2803+
for arg in args {
2804+
let (key, value) = arg
2805+
.split_once('=')
2806+
.ok_or_else(|| anyhow::anyhow!("invalid vigil arg '{arg}': expected key=value"))?;
2807+
parsed.insert(key.to_string(), value.to_string());
2808+
}
2809+
2810+
let known: &[&str] = match trigger {
2811+
crate::cli::VigilAddTrigger::Toll => &["interval_secs", "reap_interval_secs", "prompt"],
2812+
crate::cli::VigilAddTrigger::Watcher => &["path", "reap_interval_secs", "prompt"],
2813+
crate::cli::VigilAddTrigger::Harbinger => &[
2814+
"address",
2815+
"protocol",
2816+
"socket_mode",
2817+
"reap_interval_secs",
2818+
"prompt",
2819+
],
2820+
};
2821+
for key in parsed.keys() {
2822+
if !known.contains(&key.as_str()) {
2823+
anyhow::bail!("unknown vigil arg '{key}' for {trigger:?}");
2824+
}
2825+
}
28062826

28072827
let trigger = match trigger {
28082828
crate::cli::VigilAddTrigger::Toll => {
2809-
let secs = parsed
2810-
.get("interval_secs")
2811-
.and_then(|v| v.parse().ok())
2812-
.unwrap_or(30);
2813-
VigilTrigger::Toll {
2814-
interval_secs: secs,
2815-
}
2829+
let interval_secs = parse_positive_secs(&parsed, "interval_secs", 30)?;
2830+
VigilTrigger::Toll { interval_secs }
28162831
}
28172832
crate::cli::VigilAddTrigger::Watcher => {
28182833
let path = parsed
@@ -2827,19 +2842,28 @@ fn build_vigil_entry(
28272842
.cloned()
28282843
.unwrap_or_else(|| "127.0.0.1:9000".to_string());
28292844
let protocol = parsed.get("protocol").cloned().unwrap_or_default();
2845+
// The flat key=value CLI cannot express a commands map, so a
2846+
// CLI-added harbinger is template mode. Commands-mode harbingers
2847+
// must come from config or a .dirge/vigils/*.json file.
2848+
let socket_mode = match parsed.get("socket_mode").map(String::as_str) {
2849+
None | Some("template") => SocketMode::Template,
2850+
Some("commands") => anyhow::bail!(
2851+
"commands-mode harbingers need a commands map; define '{name}' in config or a vigil JSON file instead"
2852+
),
2853+
Some(other) => {
2854+
anyhow::bail!("invalid socket_mode '{other}': expected template or commands")
2855+
}
2856+
};
28302857
VigilTrigger::Harbinger {
28312858
address,
28322859
protocol,
2833-
socket_mode: crate::config::SocketMode::Commands,
2860+
socket_mode,
28342861
commands: std::collections::HashMap::new(),
28352862
}
28362863
}
28372864
};
28382865

2839-
let reap_interval_secs = parsed
2840-
.get("reap_interval_secs")
2841-
.and_then(|v| v.parse().ok())
2842-
.unwrap_or(30);
2866+
let reap_interval_secs = parse_positive_secs(&parsed, "reap_interval_secs", 30)?;
28432867

28442868
let prompt = parsed.get("prompt").cloned().unwrap_or_default();
28452869

@@ -2855,3 +2879,75 @@ fn build_vigil_entry(
28552879
}),
28562880
})
28572881
}
2882+
2883+
/// Parse a positive-integer seconds arg, rejecting zero and non-numeric
2884+
/// values. Zero trips tokio's non-zero interval assert (the trigger dies
2885+
/// silently) and a zero reap interval tight-loops the reaper.
2886+
#[cfg(feature = "vigil")]
2887+
fn parse_positive_secs(
2888+
parsed: &std::collections::HashMap<String, String>,
2889+
key: &str,
2890+
default: u64,
2891+
) -> anyhow::Result<u64> {
2892+
match parsed.get(key) {
2893+
None => Ok(default),
2894+
Some(raw) => {
2895+
let secs: u64 = raw
2896+
.parse()
2897+
.map_err(|_| anyhow::anyhow!("{key} must be a positive integer, got '{raw}'"))?;
2898+
if secs == 0 {
2899+
anyhow::bail!("{key} must be greater than zero");
2900+
}
2901+
Ok(secs)
2902+
}
2903+
}
2904+
}
2905+
2906+
/// Enumerate vigils for `dirge vigil list`, merged from config and the SQLite
2907+
/// store by name. Config entries win on name collision; the store is
2908+
/// authoritative for status, so a config vigil paused or rested via the CLI
2909+
/// still shows its state. Entries only in the store (added via
2910+
/// `dirge vigil add`) show up too.
2911+
#[cfg(feature = "vigil")]
2912+
fn collect_vigils_for_list(
2913+
paths: &crate::extras::dirge_paths::ProjectPaths,
2914+
config_vigils: Vec<crate::config::VigilEntry>,
2915+
) -> Vec<(
2916+
crate::config::VigilEntry,
2917+
crate::extras::vigil_db::VigilStatus,
2918+
)> {
2919+
use crate::config::VigilEntry;
2920+
use crate::extras::vigil_db::{VigilStatus, VigilStore};
2921+
use std::collections::HashMap;
2922+
2923+
let mut status_by_name: HashMap<String, VigilStatus> = HashMap::new();
2924+
let mut entry_by_name: HashMap<String, VigilEntry> = HashMap::new();
2925+
2926+
// Store first (lowest entry precedence; authoritative for status).
2927+
if let Ok(store) = VigilStore::open(paths) {
2928+
for row in store.list_all().unwrap_or_default() {
2929+
status_by_name.insert(row.name.clone(), row.status);
2930+
if let Ok(entry) = serde_json::from_str::<VigilEntry>(&row.payload_json) {
2931+
entry_by_name.insert(row.name.clone(), entry);
2932+
}
2933+
}
2934+
}
2935+
2936+
// Config wins over store on name collision.
2937+
for entry in config_vigils {
2938+
entry_by_name.insert(entry.name.clone(), entry);
2939+
}
2940+
2941+
let mut out: Vec<(VigilEntry, VigilStatus)> = entry_by_name
2942+
.into_iter()
2943+
.map(|(name, entry)| {
2944+
let status = status_by_name
2945+
.get(&name)
2946+
.copied()
2947+
.unwrap_or(VigilStatus::Active);
2948+
(entry, status)
2949+
})
2950+
.collect();
2951+
out.sort_by(|a, b| a.0.name.cmp(&b.0.name));
2952+
out
2953+
}

0 commit comments

Comments
 (0)