//! Deferred agent start via one-shot timers. //! //! Provides [`TimerStore`] for persisting timers to `.storkit/timers.json`, //! a 30-second tick loop ([`spawn_timer_tick_loop`]) that fires due timers, //! and command parsing / handling for the `timer` bot command. use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; // ── Data types ───────────────────────────────────────────────────────────── /// A single scheduled timer entry. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TimerEntry { /// The full story ID (filename stem, e.g. `421_story_foo`). pub story_id: String, /// UTC instant at which the timer should fire. pub scheduled_at: DateTime, } // ── TimerStore ───────────────────────────────────────────────────────────── /// Persistent store for pending timers, backed by a JSON file. pub struct TimerStore { path: PathBuf, timers: Mutex>, } impl TimerStore { /// Load the timer store from `path`. Returns an empty store if the file /// does not exist or cannot be parsed. pub fn load(path: PathBuf) -> Self { let timers = if path.exists() { std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str::>(&s).ok()) .unwrap_or_default() } else { Vec::new() }; Self { path, timers: Mutex::new(timers), } } fn save_locked(path: &Path, timers: &[TimerEntry]) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Failed to create directory: {e}"))?; } let content = serde_json::to_string_pretty(timers).map_err(|e| format!("Serialize failed: {e}"))?; std::fs::write(path, content).map_err(|e| format!("Failed to write timers: {e}")) } /// Add a timer and persist to disk. pub fn add(&self, story_id: String, scheduled_at: DateTime) -> Result<(), String> { let mut timers = self.timers.lock().unwrap(); timers.push(TimerEntry { story_id, scheduled_at, }); Self::save_locked(&self.path, &timers) } /// Remove the timer for `story_id`. Returns `true` if one was removed. pub fn remove(&self, story_id: &str) -> bool { let mut timers = self.timers.lock().unwrap(); let before = timers.len(); timers.retain(|t| t.story_id != story_id); let removed = timers.len() < before; if removed { let _ = Self::save_locked(&self.path, &timers); } removed } /// Return all pending timers (cloned). pub fn list(&self) -> Vec { self.timers.lock().unwrap().clone() } /// Add or update a timer for `story_id`. /// /// - If no timer exists for `story_id`, adds it. /// - If a timer already exists and `scheduled_at` is **later**, updates it. /// - If a timer already exists and `scheduled_at` is earlier or equal, no-op. /// /// Use this instead of [`add`] when auto-scheduling from rate-limit events to /// avoid creating duplicates and to always keep the latest reset time. pub fn upsert(&self, story_id: String, scheduled_at: DateTime) -> Result<(), String> { let mut timers = self.timers.lock().unwrap(); if let Some(existing) = timers.iter_mut().find(|t| t.story_id == story_id) { if scheduled_at > existing.scheduled_at { existing.scheduled_at = scheduled_at; Self::save_locked(&self.path, &timers)?; } } else { timers.push(TimerEntry { story_id, scheduled_at, }); Self::save_locked(&self.path, &timers)?; } Ok(()) } /// Remove and return all timers whose `scheduled_at` is ≤ `now`. /// Persists the updated list to disk if any timers were removed. pub fn take_due(&self, now: DateTime) -> Vec { let mut timers = self.timers.lock().unwrap(); let mut due = Vec::new(); let mut remaining = Vec::new(); for t in timers.drain(..) { if t.scheduled_at <= now { due.push(t); } else { remaining.push(t); } } *timers = remaining; if !due.is_empty() { let _ = Self::save_locked(&self.path, &timers); } due } } // ── Tick loop ────────────────────────────────────────────────────────────── /// Spawn a background tokio task that fires due timers every 30 seconds. /// /// Same pattern as the watchdog in `agents::pool::auto_assign`. /// When a timer fires, `start_agent` is called for the story. If all coders /// are busy the story remains in `2_current/` and auto-assign will pick it up. pub fn spawn_timer_tick_loop( store: Arc, agents: Arc, project_root: PathBuf, ) { tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); loop { interval.tick().await; let now = Utc::now(); let due = store.take_due(now); for entry in due { crate::slog!( "[timer] Timer fired for story {}; calling start_agent", entry.story_id ); match agents .start_agent(&project_root, &entry.story_id, None, None) .await { Ok(info) => { crate::slog!( "[timer] Started agent {} for story {}", info.agent_name, entry.story_id ); } Err(e) => { crate::slog!( "[timer] Failed to start agent for story {}: {e}", entry.story_id ); } } } } }); } /// Spawn a background task that listens for [`WatcherEvent::RateLimitHardBlock`] /// events and auto-schedules a timer for the blocked story. /// /// If a timer already exists for the story, it is updated to the later reset time /// rather than creating a duplicate (via [`TimerStore::upsert`]). pub fn spawn_rate_limit_auto_scheduler( store: Arc, mut watcher_rx: tokio::sync::broadcast::Receiver, ) { tokio::spawn(async move { loop { match watcher_rx.recv().await { Ok(crate::io::watcher::WatcherEvent::RateLimitHardBlock { story_id, agent_name, reset_at, }) => { crate::slog!( "[timer] Auto-scheduling timer for story {story_id} \ (agent {agent_name}) to resume at {reset_at}" ); match store.upsert(story_id.clone(), reset_at) { Ok(()) => { crate::slog!( "[timer] Timer upserted for story {story_id}; \ scheduled at {reset_at}" ); } Err(e) => { crate::slog!( "[timer] Failed to upsert timer for story {story_id}: {e}" ); } } } Ok(_) => {} Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { crate::slog!( "[timer] Rate-limit auto-scheduler lagged, skipped {n} events" ); } Err(tokio::sync::broadcast::error::RecvError::Closed) => { crate::slog!( "[timer] Watcher channel closed, stopping rate-limit auto-scheduler" ); break; } } } }); } // ── Command types ────────────────────────────────────────────────────────── /// A parsed `timer` command. #[derive(Debug, PartialEq)] pub enum TimerCommand { /// `timer ` — schedule a deferred start. Schedule { story_number_or_id: String, hhmm: String, }, /// `timer list` — list all pending timers. List, /// `timer cancel ` — remove a pending timer. Cancel { story_number_or_id: String }, /// Malformed arguments. BadArgs, } // ── Command extraction ───────────────────────────────────────────────────── /// Parse a `timer` command from a raw message body. /// /// Strips the bot mention prefix and matches the `timer` keyword. /// Returns `None` when the message is not a timer command at all. pub fn extract_timer_command( message: &str, bot_name: &str, bot_user_id: &str, ) -> Option { let stripped = strip_mention(message, bot_name, bot_user_id); let trimmed = stripped .trim() .trim_start_matches(|c: char| !c.is_alphanumeric()); let (cmd, args) = match trimmed.split_once(char::is_whitespace) { Some((c, a)) => (c, a.trim()), None => (trimmed, ""), }; if !cmd.eq_ignore_ascii_case("timer") { return None; } // `timer` with no args or `timer list` if args.is_empty() || args.eq_ignore_ascii_case("list") { return Some(TimerCommand::List); } let (sub, rest) = match args.split_once(char::is_whitespace) { Some((s, r)) => (s, r.trim()), None => (args, ""), }; // `timer cancel ` if sub.eq_ignore_ascii_case("cancel") { if rest.is_empty() { return Some(TimerCommand::BadArgs); } return Some(TimerCommand::Cancel { story_number_or_id: rest.to_string(), }); } // `timer ` if rest.is_empty() { return Some(TimerCommand::BadArgs); } Some(TimerCommand::Schedule { story_number_or_id: sub.to_string(), hhmm: rest.to_string(), }) } // ── Command handler ──────────────────────────────────────────────────────── /// Handle a parsed `timer` command. Returns a markdown-formatted response. pub async fn handle_timer_command( cmd: TimerCommand, store: &TimerStore, project_root: &Path, ) -> String { match cmd { TimerCommand::Schedule { story_number_or_id, hhmm, } => { let story_id = match resolve_story_id(&story_number_or_id, project_root) { Some(id) => id, None => { return format!( "No story with number or ID **{story_number_or_id}** found." ); } }; // The story must already be in 2_current/ — the timer does not move stories. let current_dir = project_root.join(".storkit").join("work").join("2_current"); let story_file = current_dir.join(format!("{story_id}.md")); if !story_file.exists() { return format!( "Story **{story_id}** is not in `work/2_current/`. \ Move it to current before scheduling a timer." ); } let scheduled_at = match next_occurrence_of_hhmm(&hhmm) { Some(t) => t, None => { return format!( "Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`)." ); } }; match store.add(story_id.clone(), scheduled_at) { Ok(()) => { let local_time = scheduled_at.with_timezone(&Local); format!( "Timer set for **{story_id}** at **{}** (server local time).", local_time.format("%Y-%m-%d %H:%M") ) } Err(e) => format!("Failed to save timer: {e}"), } } TimerCommand::List => { let timers = store.list(); if timers.is_empty() { return "No pending timers.".to_string(); } let mut lines = vec!["**Pending timers:**".to_string()]; for t in &timers { let local_time = t.scheduled_at.with_timezone(&Local); lines.push(format!( "- **{}** → {}", t.story_id, local_time.format("%Y-%m-%d %H:%M") )); } lines.join("\n") } TimerCommand::Cancel { story_number_or_id } => { let story_id = resolve_story_id(&story_number_or_id, project_root) .unwrap_or(story_number_or_id.clone()); if store.remove(&story_id) { format!("Timer for **{story_id}** cancelled.") } else { format!("No timer found for **{story_id}**.") } } TimerCommand::BadArgs => { "Usage:\n\ - `timer ` — schedule deferred start\n\ - `timer list` — show pending timers\n\ - `timer cancel ` — remove a timer" .to_string() } } } // ── Helpers ──────────────────────────────────────────────────────────────── /// Parse `HH:MM` and return the next UTC instant at which the server-local /// clock will read that time. If the time has already passed today, returns /// tomorrow's occurrence. pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option> { let (hh, mm) = hhmm.split_once(':')?; let hours: u32 = hh.parse().ok()?; let minutes: u32 = mm.parse().ok()?; if hours > 23 || minutes > 59 { return None; } let target_time = NaiveTime::from_hms_opt(hours, minutes, 0)?; let now_local = Local::now(); let today = now_local.date_naive(); let candidate = today.and_time(target_time); let candidate_local = Local.from_local_datetime(&candidate).single()?; if candidate_local > now_local { Some(candidate_local.to_utc()) } else { let tomorrow = today + Duration::days(1); let tomorrow_candidate = tomorrow.and_time(target_time); let tomorrow_local = Local.from_local_datetime(&tomorrow_candidate).single()?; Some(tomorrow_local.to_utc()) } } /// Resolve a story ID from a numeric story number or a full ID string. /// /// Searches all pipeline stages. Returns `None` only when the input is /// numeric but no matching file is found. fn resolve_story_id(number_or_id: &str, project_root: &Path) -> Option { const STAGES: &[&str] = &[ "1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived", ]; // Full ID (contains underscores) — return as-is; validation happens at file-check time. if number_or_id.contains('_') { return Some(number_or_id.to_string()); } // Numeric lookup. if !number_or_id.chars().all(|c| c.is_ascii_digit()) { return None; } for stage in STAGES { let dir = project_root.join(".storkit").join("work").join(stage); if !dir.exists() { continue; } if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { let file_num = stem .split('_') .next() .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) .unwrap_or(""); if file_num == number_or_id { return Some(stem.to_string()); } } } } } None } fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { let trimmed = message.trim(); if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { return rest; } if let Some(localpart) = bot_user_id.split(':').next() && let Some(rest) = strip_prefix_ci(trimmed, localpart) { return rest; } if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { return rest; } trimmed } fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { let candidate = text.get(..prefix.len())?; if !candidate.eq_ignore_ascii_case(prefix) { return None; } let rest = &text[prefix.len()..]; match rest.chars().next() { None => Some(rest), Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, _ => Some(rest), } } // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; // ── next_occurrence_of_hhmm ───────────────────────────────────────── #[test] fn valid_hhmm_returns_some() { let result = next_occurrence_of_hhmm("14:30"); assert!(result.is_some(), "valid HH:MM should return Some"); } #[test] fn invalid_hhmm_missing_colon_returns_none() { assert!(next_occurrence_of_hhmm("1430").is_none()); } #[test] fn invalid_hhmm_bad_hours_returns_none() { assert!(next_occurrence_of_hhmm("25:00").is_none()); } #[test] fn invalid_hhmm_bad_minutes_returns_none() { assert!(next_occurrence_of_hhmm("12:60").is_none()); } #[test] fn next_occurrence_is_in_the_future() { let result = next_occurrence_of_hhmm("14:30").unwrap(); assert!(result > Utc::now(), "next occurrence must be in the future"); } // ── extract_timer_command ─────────────────────────────────────────── #[test] fn non_timer_command_returns_none() { assert!(extract_timer_command("Timmy help", "Timmy", "@bot:home").is_none()); } #[test] fn timer_list_no_args() { assert_eq!( extract_timer_command("Timmy timer", "Timmy", "@bot:home"), Some(TimerCommand::List) ); } #[test] fn timer_list_explicit() { assert_eq!( extract_timer_command("Timmy timer list", "Timmy", "@bot:home"), Some(TimerCommand::List) ); } #[test] fn timer_cancel_story_id() { assert_eq!( extract_timer_command( "Timmy timer cancel 421_story_foo", "Timmy", "@bot:home" ), Some(TimerCommand::Cancel { story_number_or_id: "421_story_foo".to_string() }) ); } #[test] fn timer_cancel_no_arg_is_bad_args() { assert_eq!( extract_timer_command("Timmy timer cancel", "Timmy", "@bot:home"), Some(TimerCommand::BadArgs) ); } #[test] fn timer_schedule_with_story_id() { assert_eq!( extract_timer_command( "Timmy timer 421_story_foo 14:30", "Timmy", "@bot:home" ), Some(TimerCommand::Schedule { story_number_or_id: "421_story_foo".to_string(), hhmm: "14:30".to_string(), }) ); } #[test] fn timer_schedule_with_number() { assert_eq!( extract_timer_command("Timmy timer 421 14:30", "Timmy", "@bot:home"), Some(TimerCommand::Schedule { story_number_or_id: "421".to_string(), hhmm: "14:30".to_string(), }) ); } #[test] fn timer_schedule_missing_time_is_bad_args() { assert_eq!( extract_timer_command( "Timmy timer 421_story_foo", "Timmy", "@bot:home" ), Some(TimerCommand::BadArgs) ); } // ── TimerStore ────────────────────────────────────────────────────── #[test] fn timer_store_empty_on_missing_file() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); assert!(store.list().is_empty()); } #[test] fn timer_store_add_and_list() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let t = Utc::now() + Duration::hours(1); store.add("story_1".to_string(), t).unwrap(); let list = store.list(); assert_eq!(list.len(), 1); assert_eq!(list[0].story_id, "story_1"); } #[test] fn timer_store_remove() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let t = Utc::now() + Duration::hours(1); store.add("story_1".to_string(), t).unwrap(); assert!(store.remove("story_1")); assert!(!store.remove("story_1")); // already gone assert!(store.list().is_empty()); } #[test] fn timer_store_persists_and_reloads() { let dir = TempDir::new().unwrap(); let path = dir.path().join("timers.json"); let t = Utc::now() + Duration::hours(2); { let store = TimerStore::load(path.clone()); store.add("421_story_foo".to_string(), t).unwrap(); } // Reload from disk. let store2 = TimerStore::load(path); let list = store2.list(); assert_eq!(list.len(), 1); assert_eq!(list[0].story_id, "421_story_foo"); } #[test] fn take_due_returns_only_past_entries() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let past = Utc::now() - Duration::minutes(1); let future = Utc::now() + Duration::hours(1); store.add("past_story".to_string(), past).unwrap(); store.add("future_story".to_string(), future).unwrap(); let due = store.take_due(Utc::now()); assert_eq!(due.len(), 1); assert_eq!(due[0].story_id, "past_story"); assert_eq!(store.list().len(), 1); assert_eq!(store.list()[0].story_id, "future_story"); } // ── AC3: upsert ───────────────────────────────────────────────────── #[test] fn upsert_adds_new_timer_when_none_exists() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let t = Utc::now() + Duration::hours(1); store.upsert("story_1".to_string(), t).unwrap(); let list = store.list(); assert_eq!(list.len(), 1); assert_eq!(list[0].story_id, "story_1"); assert_eq!(list[0].scheduled_at, t); } #[test] fn upsert_updates_to_later_time() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let early = Utc::now() + Duration::hours(1); let later = Utc::now() + Duration::hours(2); store.upsert("story_1".to_string(), early).unwrap(); store.upsert("story_1".to_string(), later).unwrap(); let list = store.list(); assert_eq!(list.len(), 1, "should not create duplicate"); assert_eq!(list[0].scheduled_at, later, "should update to later time"); } #[test] fn upsert_does_not_downgrade_to_earlier_time() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let later = Utc::now() + Duration::hours(2); let earlier = Utc::now() + Duration::hours(1); store.upsert("story_1".to_string(), later).unwrap(); store.upsert("story_1".to_string(), earlier).unwrap(); let list = store.list(); assert_eq!(list.len(), 1); assert_eq!( list[0].scheduled_at, later, "should keep the later time, not downgrade" ); } // ── AC2: spawn_rate_limit_auto_scheduler ──────────────────────────── /// AC2: a RateLimitHardBlock event causes the auto-scheduler to add a timer. #[tokio::test] async fn rate_limit_auto_scheduler_adds_timer_on_hard_block() { use crate::io::watcher::WatcherEvent; let dir = TempDir::new().unwrap(); let store = Arc::new(TimerStore::load(dir.path().join("timers.json"))); let (watcher_tx, watcher_rx) = tokio::sync::broadcast::channel::(16); spawn_rate_limit_auto_scheduler(Arc::clone(&store), watcher_rx); let reset_at = Utc::now() + Duration::hours(1); watcher_tx .send(WatcherEvent::RateLimitHardBlock { story_id: "423_story_test".to_string(), agent_name: "coder-1".to_string(), reset_at, }) .unwrap(); // Give the spawned task time to process the event. tokio::time::sleep(std::time::Duration::from_millis(100)).await; let list = store.list(); assert_eq!(list.len(), 1, "expected one timer after hard block"); assert_eq!(list[0].story_id, "423_story_test"); assert_eq!(list[0].scheduled_at, reset_at); } /// AC3 integration: a second hard block with a later reset_at updates the /// existing timer rather than creating a duplicate. #[tokio::test] async fn rate_limit_auto_scheduler_upserts_on_repeated_hard_block() { use crate::io::watcher::WatcherEvent; let dir = TempDir::new().unwrap(); let store = Arc::new(TimerStore::load(dir.path().join("timers.json"))); let (watcher_tx, watcher_rx) = tokio::sync::broadcast::channel::(16); spawn_rate_limit_auto_scheduler(Arc::clone(&store), watcher_rx); let first = Utc::now() + Duration::hours(1); let second = Utc::now() + Duration::hours(2); watcher_tx .send(WatcherEvent::RateLimitHardBlock { story_id: "423_story_test".to_string(), agent_name: "coder-1".to_string(), reset_at: first, }) .unwrap(); tokio::time::sleep(std::time::Duration::from_millis(50)).await; watcher_tx .send(WatcherEvent::RateLimitHardBlock { story_id: "423_story_test".to_string(), agent_name: "coder-1".to_string(), reset_at: second, }) .unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; let list = store.list(); assert_eq!(list.len(), 1, "should not create a duplicate timer"); assert_eq!(list[0].scheduled_at, second, "should update to later time"); } #[test] fn multiple_timers_same_time_all_returned() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let past = Utc::now() - Duration::minutes(1); store.add("story_a".to_string(), past).unwrap(); store.add("story_b".to_string(), past).unwrap(); let due = store.take_due(Utc::now()); assert_eq!(due.len(), 2, "both timers at same time must fire"); } // ── handle_timer_command ──────────────────────────────────────────── #[tokio::test] async fn handle_list_empty() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await; assert!(result.contains("No pending timers"), "unexpected: {result}"); } #[tokio::test] async fn handle_cancel_not_found() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Cancel { story_number_or_id: "421_story_foo".to_string(), }, &store, dir.path(), ) .await; assert!( result.contains("No timer found"), "unexpected: {result}" ); } #[tokio::test] async fn handle_schedule_story_not_in_current() { let dir = TempDir::new().unwrap(); // Set up directory structure with no story in 2_current std::fs::create_dir_all(dir.path().join(".storkit/work/2_current")).unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Schedule { story_number_or_id: "421_story_foo".to_string(), hhmm: "14:30".to_string(), }, &store, dir.path(), ) .await; assert!( result.contains("not in `work/2_current/`"), "unexpected: {result}" ); } #[tokio::test] async fn handle_schedule_success() { let dir = TempDir::new().unwrap(); let current_dir = dir.path().join(".storkit/work/2_current"); std::fs::create_dir_all(¤t_dir).unwrap(); std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Schedule { story_number_or_id: "421_story_foo".to_string(), hhmm: "23:59".to_string(), }, &store, dir.path(), ) .await; assert!( result.contains("Timer set for"), "unexpected: {result}" ); assert_eq!(store.list().len(), 1); } #[tokio::test] async fn handle_schedule_invalid_time() { let dir = TempDir::new().unwrap(); let current_dir = dir.path().join(".storkit/work/2_current"); std::fs::create_dir_all(¤t_dir).unwrap(); std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Schedule { story_number_or_id: "421_story_foo".to_string(), hhmm: "99:00".to_string(), }, &store, dir.path(), ) .await; assert!(result.contains("Invalid time"), "unexpected: {result}"); } #[tokio::test] async fn handle_cancel_existing_timer() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let future = Utc::now() + Duration::hours(1); store.add("421_story_foo".to_string(), future).unwrap(); let result = handle_timer_command( TimerCommand::Cancel { story_number_or_id: "421_story_foo".to_string(), }, &store, dir.path(), ) .await; assert!(result.contains("cancelled"), "unexpected: {result}"); assert!(store.list().is_empty()); } #[tokio::test] async fn handle_list_with_entries() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let future = Utc::now() + Duration::hours(1); store.add("421_story_foo".to_string(), future).unwrap(); let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await; assert!(result.contains("421_story_foo"), "unexpected: {result}"); assert!(result.contains("Pending timers"), "unexpected: {result}"); } }