Skip to content

Commit 99505a0

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 fb53ccf commit 99505a0

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
@@ -2719,9 +2719,9 @@ async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Resul
27192719

27202720
match action {
27212721
crate::cli::VigilAction::List => {
2722-
let vigils = config::load().vigils.unwrap_or_default();
2723-
println!("Vigils (from config):");
2724-
for v in &vigils {
2722+
let vigils = collect_vigils_for_list(&paths, config::load().vigils.unwrap_or_default());
2723+
println!("Vigils:");
2724+
for (v, status) in &vigils {
27252725
let trigger = match &v.trigger {
27262726
crate::config::VigilTrigger::Toll { interval_secs } => {
27272727
format!("toll every {interval_secs}s")
@@ -2746,8 +2746,10 @@ async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Resul
27462746
v.prompt.clone()
27472747
};
27482748
println!(
2749-
" {} - {trigger} - reap every {}s - prompt: {prompt}",
2750-
v.name, v.reap_interval_secs
2749+
" {} - {trigger} - reap every {}s - {} - prompt: {prompt}",
2750+
v.name,
2751+
v.reap_interval_secs,
2752+
status.as_str()
27512753
);
27522754
}
27532755
if vigils.is_empty() {
@@ -2772,31 +2774,29 @@ async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Resul
27722774
}
27732775
crate::cli::VigilAction::Remove { name } => {
27742776
let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?;
2775-
match store.remove(name) {
2776-
Ok(()) => println!("Removed vigil '{name}'."),
2777-
Err(e) => eprintln!("{e}"),
2778-
}
2777+
store.remove(name).map_err(|e| anyhow::anyhow!("{e}"))?;
2778+
println!("Removed vigil '{name}'.");
27792779
}
27802780
crate::cli::VigilAction::Pause { name } => {
27812781
let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?;
2782-
match store.set_status(name, VigilStatus::Paused) {
2783-
Ok(()) => println!("Paused vigil '{name}'."),
2784-
Err(e) => eprintln!("{e}"),
2785-
}
2782+
store
2783+
.set_status(name, VigilStatus::Paused)
2784+
.map_err(|e| anyhow::anyhow!("{e}"))?;
2785+
println!("Paused vigil '{name}'.");
27862786
}
27872787
crate::cli::VigilAction::Resume { name } => {
27882788
let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?;
2789-
match store.set_status(name, VigilStatus::Active) {
2790-
Ok(()) => println!("Resumed vigil '{name}'."),
2791-
Err(e) => eprintln!("{e}"),
2792-
}
2789+
store
2790+
.set_status(name, VigilStatus::Active)
2791+
.map_err(|e| anyhow::anyhow!("{e}"))?;
2792+
println!("Resumed vigil '{name}'.");
27932793
}
27942794
crate::cli::VigilAction::Rest { name } => {
27952795
let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?;
2796-
match store.set_status(name, VigilStatus::Resting) {
2797-
Ok(()) => println!("vigil '{name}' resting (will sleep until next trigger)."),
2798-
Err(e) => eprintln!("{e}"),
2799-
}
2796+
store
2797+
.set_status(name, VigilStatus::Resting)
2798+
.map_err(|e| anyhow::anyhow!("{e}"))?;
2799+
println!("vigil '{name}' resting (will sleep until next trigger).");
28002800
}
28012801
}
28022802
Ok(())
@@ -2809,23 +2809,38 @@ fn build_vigil_entry(
28092809
trigger: &crate::cli::VigilAddTrigger,
28102810
args: &[String],
28112811
) -> anyhow::Result<crate::config::VigilEntry> {
2812-
use crate::config::{VigilEntry, VigilRite, VigilTrigger};
2813-
2814-
let parsed: std::collections::HashMap<String, String> = args
2815-
.iter()
2816-
.filter_map(|a| a.split_once('='))
2817-
.map(|(k, v)| (k.to_string(), v.to_string()))
2818-
.collect();
2812+
use crate::config::{SocketMode, VigilEntry, VigilRite, VigilTrigger};
2813+
2814+
// Parse key=value args strictly: a keyless arg is a typo, not a value.
2815+
let mut parsed: std::collections::HashMap<String, String> = std::collections::HashMap::new();
2816+
for arg in args {
2817+
let (key, value) = arg
2818+
.split_once('=')
2819+
.ok_or_else(|| anyhow::anyhow!("invalid vigil arg '{arg}': expected key=value"))?;
2820+
parsed.insert(key.to_string(), value.to_string());
2821+
}
2822+
2823+
let known: &[&str] = match trigger {
2824+
crate::cli::VigilAddTrigger::Toll => &["interval_secs", "reap_interval_secs", "prompt"],
2825+
crate::cli::VigilAddTrigger::Watcher => &["path", "reap_interval_secs", "prompt"],
2826+
crate::cli::VigilAddTrigger::Harbinger => &[
2827+
"address",
2828+
"protocol",
2829+
"socket_mode",
2830+
"reap_interval_secs",
2831+
"prompt",
2832+
],
2833+
};
2834+
for key in parsed.keys() {
2835+
if !known.contains(&key.as_str()) {
2836+
anyhow::bail!("unknown vigil arg '{key}' for {trigger:?}");
2837+
}
2838+
}
28192839

28202840
let trigger = match trigger {
28212841
crate::cli::VigilAddTrigger::Toll => {
2822-
let secs = parsed
2823-
.get("interval_secs")
2824-
.and_then(|v| v.parse().ok())
2825-
.unwrap_or(30);
2826-
VigilTrigger::Toll {
2827-
interval_secs: secs,
2828-
}
2842+
let interval_secs = parse_positive_secs(&parsed, "interval_secs", 30)?;
2843+
VigilTrigger::Toll { interval_secs }
28292844
}
28302845
crate::cli::VigilAddTrigger::Watcher => {
28312846
let path = parsed
@@ -2840,19 +2855,28 @@ fn build_vigil_entry(
28402855
.cloned()
28412856
.unwrap_or_else(|| "127.0.0.1:9000".to_string());
28422857
let protocol = parsed.get("protocol").cloned().unwrap_or_default();
2858+
// The flat key=value CLI cannot express a commands map, so a
2859+
// CLI-added harbinger is template mode. Commands-mode harbingers
2860+
// must come from config or a .dirge/vigils/*.json file.
2861+
let socket_mode = match parsed.get("socket_mode").map(String::as_str) {
2862+
None | Some("template") => SocketMode::Template,
2863+
Some("commands") => anyhow::bail!(
2864+
"commands-mode harbingers need a commands map; define '{name}' in config or a vigil JSON file instead"
2865+
),
2866+
Some(other) => {
2867+
anyhow::bail!("invalid socket_mode '{other}': expected template or commands")
2868+
}
2869+
};
28432870
VigilTrigger::Harbinger {
28442871
address,
28452872
protocol,
2846-
socket_mode: crate::config::SocketMode::Commands,
2873+
socket_mode,
28472874
commands: std::collections::HashMap::new(),
28482875
}
28492876
}
28502877
};
28512878

2852-
let reap_interval_secs = parsed
2853-
.get("reap_interval_secs")
2854-
.and_then(|v| v.parse().ok())
2855-
.unwrap_or(30);
2879+
let reap_interval_secs = parse_positive_secs(&parsed, "reap_interval_secs", 30)?;
28562880

28572881
let prompt = parsed.get("prompt").cloned().unwrap_or_default();
28582882

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

0 commit comments

Comments
 (0)