Files
huskies/server/src/chat/timer.rs
T

939 lines
34 KiB
Rust
Raw Normal View History

//! 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};
use crate::chat::util::strip_bot_mention;
// ── 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<Utc>,
}
// ── TimerStore ─────────────────────────────────────────────────────────────
/// Persistent store for pending timers, backed by a JSON file.
pub struct TimerStore {
path: PathBuf,
timers: Mutex<Vec<TimerEntry>>,
}
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::<Vec<TimerEntry>>(&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<Utc>) -> 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<TimerEntry> {
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<Utc>) -> 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<Utc>) -> Vec<TimerEntry> {
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<TimerStore>,
agents: Arc<crate::agents::AgentPool>,
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 {}",
entry.story_id
);
// Move from backlog to current if needed — the auto-assign
// watcher will then start an agent automatically.
if let Err(e) = crate::agents::lifecycle::move_story_to_current(
&project_root,
&entry.story_id,
) {
crate::slog!(
"[timer] Failed to move story {} to current: {e}",
entry.story_id
);
continue;
}
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} \
(auto-assign may pick it up)",
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<TimerStore>,
mut watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
) {
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 <story_id_or_number> <HH:MM>` — schedule a deferred start.
Schedule {
story_number_or_id: String,
hhmm: String,
},
/// `timer list` — list all pending timers.
List,
/// `timer cancel <story_id_or_number>` — 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<TimerCommand> {
let stripped = strip_bot_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 <id>`
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 <id> <HH:MM>`
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 be in backlog or current. When the timer fires,
// backlog stories are moved to current automatically.
let work_dir = project_root.join(".storkit").join("work");
let in_backlog = work_dir.join("1_backlog").join(format!("{story_id}.md")).exists();
let in_current = work_dir.join("2_current").join(format!("{story_id}.md")).exists();
if !in_backlog && !in_current {
return format!(
"Story **{story_id}** is not in backlog or current."
);
}
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 <story_id> <HH:MM>` — schedule deferred start\n\
- `timer list` — show pending timers\n\
- `timer cancel <story_id>` — 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<DateTime<Utc>> {
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<String> {
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
}
// ── 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::<WatcherEvent>(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::<WatcherEvent>(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_backlog_or_current() {
let dir = TempDir::new().unwrap();
// Set up directory structure with no story in backlog or current
std::fs::create_dir_all(dir.path().join(".storkit/work/1_backlog")).unwrap();
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 backlog or current"),
"unexpected: {result}"
);
}
#[tokio::test]
async fn handle_schedule_accepts_backlog_story() {
let dir = TempDir::new().unwrap();
let backlog_dir = dir.path().join(".storkit/work/1_backlog");
std::fs::create_dir_all(&backlog_dir).unwrap();
std::fs::write(
backlog_dir.join("421_story_foo.md"),
"---\nname: Foo\n---\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: "14:30".to_string(),
},
&store,
dir.path(),
)
.await;
assert!(
result.contains("Timer set"),
"backlog story should be accepted: {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(&current_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(&current_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}");
}
}