diff --git a/server/src/io/watcher.rs b/server/src/io/watcher.rs index ac2ac1e..8748399 100644 --- a/server/src/io/watcher.rs +++ b/server/src/io/watcher.rs @@ -307,6 +307,220 @@ pub fn start_watcher( #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use std::fs; + use tempfile::TempDir; + + /// Initialise a minimal git repo so commit operations work. + fn init_git_repo(dir: &std::path::Path) { + use std::process::Command; + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .expect("git init"); + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(dir) + .output() + .expect("git config email"); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .expect("git config name"); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(dir) + .output() + .expect("git initial commit"); + } + + /// Create the `.story_kit/work/{stage}/` dir tree inside `root`. + fn make_stage_dir(root: &std::path::Path, stage: &str) -> PathBuf { + let dir = root.join(".story_kit").join("work").join(stage); + fs::create_dir_all(&dir).expect("create stage dir"); + dir + } + + // ── git_add_work_and_commit ─────────────────────────────────────────────── + + #[test] + fn git_commit_returns_true_when_file_added() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), "2_current"); + fs::write( + stage_dir.join("42_story_foo.md"), + "---\nname: test\n---\n", + ) + .unwrap(); + + let result = git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo"); + assert_eq!(result, Ok(true), "should return Ok(true) when a commit was made"); + } + + #[test] + fn git_commit_returns_false_when_nothing_to_commit() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), "2_current"); + fs::write( + stage_dir.join("42_story_foo.md"), + "---\nname: test\n---\n", + ) + .unwrap(); + + // First commit — should succeed. + git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo").unwrap(); + + // Second call with no changes — should return Ok(false). + let result = git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo"); + assert_eq!( + result, + Ok(false), + "should return Ok(false) when nothing to commit" + ); + } + + // ── flush_pending ───────────────────────────────────────────────────────── + + #[test] + fn flush_pending_commits_and_broadcasts_work_item_for_addition() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), "2_current"); + let story_path = stage_dir.join("42_story_foo.md"); + fs::write(&story_path, "---\nname: test\n---\n").unwrap(); + + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(story_path, "2_current".to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + let evt = rx.try_recv().expect("expected a broadcast event"); + match evt { + WatcherEvent::WorkItem { + stage, + item_id, + action, + commit_msg, + } => { + assert_eq!(stage, "2_current"); + assert_eq!(item_id, "42_story_foo"); + assert_eq!(action, "start"); + assert_eq!(commit_msg, "story-kit: start 42_story_foo"); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn flush_pending_broadcasts_for_all_pipeline_stages() { + let stages = [ + ("1_upcoming", "create", "story-kit: create 10_story_x"), + ("3_qa", "qa", "story-kit: queue 10_story_x for QA"), + ("4_merge", "merge", "story-kit: queue 10_story_x for merge"), + ("5_archived", "accept", "story-kit: accept 10_story_x"), + ]; + + for (stage, expected_action, expected_msg) in stages { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), stage); + let story_path = stage_dir.join("10_story_x.md"); + fs::write(&story_path, "---\nname: test\n---\n").unwrap(); + + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(story_path, stage.to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + let evt = rx.try_recv().expect("expected broadcast for stage {stage}"); + match evt { + WatcherEvent::WorkItem { + action, commit_msg, .. + } => { + assert_eq!(action, expected_action, "stage {stage}"); + assert_eq!(commit_msg, expected_msg, "stage {stage}"); + } + other => panic!("unexpected event for stage {stage}: {other:?}"), + } + } + } + + #[test] + fn flush_pending_deletion_only_broadcasts_remove_event() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + // Create the work dir tree but NOT the file (simulates a deletion). + make_stage_dir(tmp.path(), "2_current"); + let deleted_path = tmp + .path() + .join(".story_kit") + .join("work") + .join("2_current") + .join("42_story_foo.md"); + + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(deleted_path, "2_current".to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + // Even when nothing was committed (file never existed), an event is broadcast. + let evt = rx.try_recv().expect("expected a broadcast event for deletion"); + match evt { + WatcherEvent::WorkItem { + action, item_id, .. + } => { + assert_eq!(action, "remove"); + assert_eq!(item_id, "42_story_foo"); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn flush_pending_skips_unknown_stage_for_addition() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + // File sits in an unrecognised directory. + let unknown_dir = tmp.path().join(".story_kit").join("work").join("9_unknown"); + fs::create_dir_all(&unknown_dir).unwrap(); + let path = unknown_dir.join("42_story_foo.md"); + fs::write(&path, "---\nname: test\n---\n").unwrap(); + + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(path, "9_unknown".to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + // No event should be broadcast because stage_metadata returns None for unknown stages. + assert!( + rx.try_recv().is_err(), + "no event should be broadcast for unknown stage" + ); + } + + #[test] + fn flush_pending_empty_pending_does_nothing() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + make_stage_dir(tmp.path(), "2_current"); + + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let pending: HashMap = HashMap::new(); + + // Should not panic and should not broadcast anything. + flush_pending(&pending, tmp.path(), &tx); + assert!(rx.try_recv().is_err(), "no event for empty pending map"); + } + + // ── stage_for_path (additional edge cases) ──────────────────────────────── #[test] fn stage_for_path_recognises_pipeline_dirs() {