From 225c4f2b46ce0b033524934f3d236c9026ef1009 Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 27 Apr 2026 22:16:12 +0000 Subject: [PATCH] huskies: merge 758 --- server/src/io/watcher/mod.rs | 201 ------------ server/src/io/watcher/tests.rs | 565 ++------------------------------- 2 files changed, 20 insertions(+), 746 deletions(-) diff --git a/server/src/io/watcher/mod.rs b/server/src/io/watcher/mod.rs index 0c117e5a..d364d1b3 100644 --- a/server/src/io/watcher/mod.rs +++ b/server/src/io/watcher/mod.rs @@ -157,206 +157,5 @@ pub fn start_watcher(git_root: PathBuf, event_tx: broadcast::Sender Option { - // Reject any path that passes through the worktrees directory. - if path.components().any(|c| c.as_os_str() == "worktrees") { - return None; - } - - if path.extension().is_none_or(|e| e != "md") { - return None; - } - let stage = path - .parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str())?; - matches!( - stage, - "1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived" - ) - .then(|| stage.to_string()) -} - -/// Stage all changes in the work directory and commit with the given message. -/// -/// Uses `git add -A .huskies/work/` to catch both additions and deletions in -/// a single commit. Returns `Ok(true)` if a commit was made, `Ok(false)` if -/// there was nothing to commit, and `Err` for unexpected failures. -/// -/// Retained for tests; no longer called in production (CRDT drives events). -#[cfg(test)] -fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result { - let work_rel = PathBuf::from(".huskies").join("work"); - - let add_out = std::process::Command::new("git") - .args(["add", "-A"]) - .arg(&work_rel) - .current_dir(git_root) - .output() - .map_err(|e| format!("git add: {e}"))?; - if !add_out.status.success() { - return Err(format!( - "git add failed: {}", - String::from_utf8_lossy(&add_out.stderr) - )); - } - - let commit_out = std::process::Command::new("git") - .args(["commit", "-m", message]) - .current_dir(git_root) - .output() - .map_err(|e| format!("git commit: {e}"))?; - - if commit_out.status.success() { - return Ok(true); - } - - let stderr = String::from_utf8_lossy(&commit_out.stderr); - let stdout = String::from_utf8_lossy(&commit_out.stdout); - if stdout.contains("nothing to commit") || stderr.contains("nothing to commit") { - return Ok(false); - } - - Err(format!("git commit failed: {stderr}")) -} - -/// Stages that represent meaningful git checkpoints (creation and archival). -/// Intermediate stages (current, qa, merge, done) are transient pipeline state -/// that don't need to be committed. -/// -/// Retained for tests; no longer called in production (CRDT drives events). -#[cfg(test)] -const COMMIT_WORTHY_STAGES: &[&str] = &["1_backlog", "5_done", "6_archived"]; - -/// Return `true` if changes in `stage` should be committed to git. -/// -/// Retained for tests; no longer called in production (CRDT drives events). -#[cfg(test)] -fn should_commit_stage(stage: &str) -> bool { - COMMIT_WORTHY_STAGES.contains(&stage) -} - -/// Process a batch of pending (path → stage) entries: commit and broadcast. -/// -/// Only files that still exist on disk are used to derive the commit message -/// (they represent the destination of a move or a new file). Deletions are -/// captured by `git add -A .huskies/work/` automatically. -/// -/// Only terminal stages (`1_backlog` and `6_archived`) trigger git commits. -/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync. -/// -/// Retained for tests; no longer called in production (CRDT drives events). -#[cfg(test)] -fn flush_pending( - pending: &std::collections::HashMap, - git_root: &Path, - event_tx: &broadcast::Sender, -) { - use crate::io::story_metadata::clear_front_matter_field; - - // Separate into files that exist (additions) vs gone (deletions). - let mut additions: Vec<(&PathBuf, &str)> = Vec::new(); - for (path, stage) in pending { - if path.exists() { - additions.push((path, stage.as_str())); - } - } - - // Pick the commit message from the first addition (the meaningful side of a move). - // If there are only deletions, use a generic message. - let (action, item_id, commit_msg) = if let Some((path, stage)) = additions.first() { - let item = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown"); - if let Some((act, msg)) = stage_metadata(stage, item) { - (act, item.to_string(), msg) - } else { - return; - } - } else { - // Only deletions — pick any pending path for the item name. - let Some((path, _)) = pending.iter().next() else { - return; - }; - let item = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown"); - ( - "remove", - item.to_string(), - format!("huskies: remove {item}"), - ) - }; - - // Strip stale merge_failure front matter from any story that has left 4_merge/. - for (path, stage) in &additions { - if *stage != "4_merge" - && let Err(e) = clear_front_matter_field(path, "merge_failure") - { - slog!( - "[watcher] Warning: could not clear merge_failure from {}: {e}", - path.display() - ); - } - } - - // Only commit for terminal stages; intermediate moves are broadcast-only. - let dest_stage = additions.first().map_or("unknown", |(_, s)| *s); - let should_commit = should_commit_stage(dest_stage); - - if should_commit { - slog!("[watcher] flush: {commit_msg}"); - match git_add_work_and_commit(git_root, &commit_msg) { - Ok(committed) => { - if committed { - slog!("[watcher] committed: {commit_msg}"); - } else { - slog!("[watcher] skipped (already committed): {commit_msg}"); - } - } - Err(e) => { - slog!("[watcher] git error: {e}"); - return; - } - } - } else { - slog!("[watcher] flush (broadcast-only): {commit_msg}"); - } - - // For move operations, find the source stage from deleted entries with matching item_id. - let from_stage: Option = if !additions.is_empty() { - pending - .iter() - .filter(|(path, _)| !path.exists()) - .find(|(path, _)| path.file_stem().and_then(|s| s.to_str()) == Some(item_id.as_str())) - .map(|(_, stage)| stage.clone()) - } else { - None - }; - - // Always broadcast the event so connected WebSocket clients stay in sync. - let evt = WatcherEvent::WorkItem { - stage: dest_stage.to_string(), - item_id, - action: action.to_string(), - commit_msg, - from_stage, - }; - let _ = event_tx.send(evt); -} - #[cfg(test)] mod tests; diff --git a/server/src/io/watcher/tests.rs b/server/src/io/watcher/tests.rs index 2a6cd553..9dd9b83a 100644 --- a/server/src/io/watcher/tests.rs +++ b/server/src/io/watcher/tests.rs @@ -1,550 +1,6 @@ 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 `.huskies/work/{stage}/` dir tree inside `root`. -fn make_stage_dir(root: &std::path::Path, stage: &str) -> PathBuf { - let dir = root.join(".huskies").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(), "huskies: 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(), "huskies: start 42_story_foo").unwrap(); - - // Second call with no changes — should return Ok(false). - let result = git_add_work_and_commit(tmp.path(), "huskies: 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_for_terminal_stage() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - let stage_dir = make_stage_dir(tmp.path(), "1_backlog"); - 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, "1_backlog".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, "1_backlog"); - assert_eq!(item_id, "42_story_foo"); - assert_eq!(action, "create"); - assert_eq!(commit_msg, "huskies: create 42_story_foo"); - } - other => panic!("unexpected event: {other:?}"), - } - - // Verify the file was actually committed. - let log = std::process::Command::new("git") - .args(["log", "--oneline", "-1"]) - .current_dir(tmp.path()) - .output() - .expect("git log"); - let log_msg = String::from_utf8_lossy(&log.stdout); - assert!( - log_msg.contains("huskies: create 42_story_foo"), - "terminal stage should produce a git commit" - ); -} - -#[test] -fn flush_pending_broadcasts_without_commit_for_intermediate_stage() { - 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); - - // Event should still be broadcast for frontend sync. - 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, "huskies: start 42_story_foo"); - } - other => panic!("unexpected event: {other:?}"), - } - - // Verify NO git commit was made (only the initial empty commit should exist). - let log = std::process::Command::new("git") - .args(["log", "--oneline"]) - .current_dir(tmp.path()) - .output() - .expect("git log"); - let log_msg = String::from_utf8_lossy(&log.stdout); - assert!( - !log_msg.contains("huskies:"), - "intermediate stage should NOT produce a git commit" - ); -} - -#[test] -fn flush_pending_broadcasts_for_all_pipeline_stages() { - let stages = [ - ("1_backlog", "create", "huskies: create 10_story_x"), - ("3_qa", "qa", "huskies: queue 10_story_x for QA"), - ("4_merge", "merge", "huskies: queue 10_story_x for merge"), - ("5_done", "done", "huskies: done 10_story_x"), - ("6_archived", "accept", "huskies: 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); - - // All stages should broadcast events regardless of commit behavior. - 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(".huskies") - .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(".huskies").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"); -} - -// ── flush_pending clears merge_failure ───────────────────────────────────── - -#[test] -fn flush_pending_clears_merge_failure_when_leaving_merge_stage() { - 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("50_story_retry.md"); - fs::write( - &story_path, - "---\nname: Retry Story\nmerge_failure: \"conflicts detected\"\n---\n# Story\n", - ) - .unwrap(); - - let (tx, _rx) = tokio::sync::broadcast::channel(16); - let mut pending = HashMap::new(); - pending.insert(story_path.clone(), "2_current".to_string()); - - flush_pending(&pending, tmp.path(), &tx); - - let contents = fs::read_to_string(&story_path).unwrap(); - assert!( - !contents.contains("merge_failure"), - "merge_failure should be stripped when story lands in 2_current" - ); - assert!(contents.contains("name: Retry Story")); -} - -#[test] -fn flush_pending_clears_merge_failure_when_moving_to_backlog() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - let stage_dir = make_stage_dir(tmp.path(), "1_backlog"); - let story_path = stage_dir.join("51_story_reset.md"); - fs::write( - &story_path, - "---\nname: Reset Story\nmerge_failure: \"gate failed\"\n---\n# Story\n", - ) - .unwrap(); - - let (tx, _rx) = tokio::sync::broadcast::channel(16); - let mut pending = HashMap::new(); - pending.insert(story_path.clone(), "1_backlog".to_string()); - - flush_pending(&pending, tmp.path(), &tx); - - let contents = fs::read_to_string(&story_path).unwrap(); - assert!( - !contents.contains("merge_failure"), - "merge_failure should be stripped when story lands in 1_backlog" - ); -} - -#[test] -fn flush_pending_clears_merge_failure_when_moving_to_done() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - let stage_dir = make_stage_dir(tmp.path(), "5_done"); - let story_path = stage_dir.join("52_story_done.md"); - fs::write( - &story_path, - "---\nname: Done Story\nmerge_failure: \"stale error\"\n---\n# Story\n", - ) - .unwrap(); - - let (tx, _rx) = tokio::sync::broadcast::channel(16); - let mut pending = HashMap::new(); - pending.insert(story_path.clone(), "5_done".to_string()); - - flush_pending(&pending, tmp.path(), &tx); - - let contents = fs::read_to_string(&story_path).unwrap(); - assert!( - !contents.contains("merge_failure"), - "merge_failure should be stripped when story lands in 5_done" - ); -} - -#[test] -fn flush_pending_preserves_merge_failure_when_in_merge_stage() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - let stage_dir = make_stage_dir(tmp.path(), "4_merge"); - let story_path = stage_dir.join("53_story_merging.md"); - fs::write( - &story_path, - "---\nname: Merging Story\nmerge_failure: \"conflicts\"\n---\n# Story\n", - ) - .unwrap(); - - let (tx, _rx) = tokio::sync::broadcast::channel(16); - let mut pending = HashMap::new(); - pending.insert(story_path.clone(), "4_merge".to_string()); - - flush_pending(&pending, tmp.path(), &tx); - - let contents = fs::read_to_string(&story_path).unwrap(); - assert!( - contents.contains("merge_failure"), - "merge_failure should be preserved when story is in 4_merge" - ); -} - -#[test] -fn flush_pending_no_op_when_no_merge_failure() { - 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("54_story_clean.md"); - let original = "---\nname: Clean Story\n---\n# Story\n"; - fs::write(&story_path, original).unwrap(); - - let (tx, _rx) = tokio::sync::broadcast::channel(16); - let mut pending = HashMap::new(); - pending.insert(story_path.clone(), "2_current".to_string()); - - flush_pending(&pending, tmp.path(), &tx); - - let contents = fs::read_to_string(&story_path).unwrap(); - assert_eq!( - contents, original, - "file without merge_failure should be unchanged" - ); -} - -// ── flush_pending from_stage ───────────────────────────────────────────── - -/// AC3: when a pending map contains both a deletion (source stage) and a -/// creation (dest stage) for the same item_id, the broadcast event should -/// have `from_stage` set to the source stage key. -#[test] -fn flush_pending_sets_from_stage_for_move_operations() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - // Destination exists (file moved here). - let merge_dir = make_stage_dir(tmp.path(), "4_merge"); - let merge_path = merge_dir.join("42_story_foo.md"); - fs::write(&merge_path, "---\nname: test\n---\n").unwrap(); - - // Source path does NOT exist (file was moved away). - make_stage_dir(tmp.path(), "3_qa"); - let qa_path = tmp - .path() - .join(".huskies") - .join("work") - .join("3_qa") - .join("42_story_foo.md"); - - let (tx, mut rx) = tokio::sync::broadcast::channel(16); - let mut pending = HashMap::new(); - pending.insert(merge_path, "4_merge".to_string()); // addition - pending.insert(qa_path, "3_qa".to_string()); // deletion - - flush_pending(&pending, tmp.path(), &tx); - - let evt = rx.try_recv().expect("expected event"); - match evt { - WatcherEvent::WorkItem { - stage, from_stage, .. - } => { - assert_eq!(stage, "4_merge"); - assert_eq!(from_stage, Some("3_qa".to_string())); - } - other => panic!("unexpected event: {other:?}"), - } -} - -/// AC3: when a pending map has only an addition (creation, not a move), -/// `from_stage` should be `None`. -#[test] -fn flush_pending_sets_from_stage_to_none_for_creations() { - 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("55_story_new.md"); - fs::write(&story_path, "---\nname: New Story\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 event"); - match evt { - WatcherEvent::WorkItem { from_stage, .. } => { - assert_eq!(from_stage, None, "creation should have no from_stage"); - } - other => panic!("unexpected event: {other:?}"), - } -} - -// ── stage_for_path (additional edge cases) ──────────────────────────────── - -#[test] -fn stage_for_path_recognises_pipeline_dirs() { - let base = PathBuf::from("/proj/.huskies/work"); - assert_eq!( - stage_for_path(&base.join("2_current/42_story_foo.md")), - Some("2_current".to_string()) - ); - assert_eq!( - stage_for_path(&base.join("5_done/10_bug_bar.md")), - Some("5_done".to_string()) - ); - assert_eq!( - stage_for_path(&base.join("6_archived/10_bug_bar.md")), - Some("6_archived".to_string()) - ); - assert_eq!(stage_for_path(&base.join("other/file.md")), None); - assert_eq!( - stage_for_path(&base.join("2_current/42_story_foo.txt")), - None - ); -} - -#[test] -fn stage_for_path_ignores_worktree_paths() { - let worktrees = PathBuf::from("/proj/.huskies/worktrees"); - - // Code changes inside a worktree must be ignored. - assert_eq!( - stage_for_path(&worktrees.join("42_story_foo/server/src/main.rs")), - None, - ); - - // Even if a worktree happens to contain a path component that looks - // like a pipeline stage, it must still be ignored. - assert_eq!( - stage_for_path(&worktrees.join("42_story_foo/.huskies/work/2_current/42_story_foo.md")), - None, - ); - - // A path that only contains the word "worktrees" as part of a longer - // segment (not an exact component) must NOT be filtered out. - assert_eq!( - stage_for_path(&PathBuf::from( - "/proj/.huskies/work/2_current/not_worktrees_story.md" - )), - Some("2_current".to_string()), - ); -} - -#[test] -fn should_commit_stage_only_for_terminal_stages() { - // Terminal stages — should commit. - assert!(should_commit_stage("1_backlog")); - assert!(should_commit_stage("5_done")); - assert!(should_commit_stage("6_archived")); - // Intermediate stages — broadcast-only, no commit. - assert!(!should_commit_stage("2_current")); - assert!(!should_commit_stage("3_qa")); - assert!(!should_commit_stage("4_merge")); - // Unknown — no commit. - assert!(!should_commit_stage("unknown")); -} - -#[test] -fn stage_metadata_returns_correct_actions() { - let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap(); - assert_eq!(action, "start"); - assert_eq!(msg, "huskies: start 42_story_foo"); - - let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap(); - assert_eq!(action, "done"); - assert_eq!(msg, "huskies: done 42_story_foo"); - - let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap(); - assert_eq!(action, "accept"); - assert_eq!(msg, "huskies: accept 42_story_foo"); - - assert!(stage_metadata("unknown", "id").is_none()); -} +// ── is_config_file ──────────────────────────────────────────────────────── #[test] fn is_config_file_identifies_root_project_toml() { @@ -590,6 +46,25 @@ fn is_config_file_rejects_wrong_root() { assert!(!is_config_file(&other_root_config, &git_root)); } +// ── stage_metadata ──────────────────────────────────────────────────────── + +#[test] +fn stage_metadata_returns_correct_actions() { + let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap(); + assert_eq!(action, "start"); + assert_eq!(msg, "huskies: start 42_story_foo"); + + let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap(); + assert_eq!(action, "done"); + assert_eq!(msg, "huskies: done 42_story_foo"); + + let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap(); + assert_eq!(action, "accept"); + assert_eq!(msg, "huskies: accept 42_story_foo"); + + assert!(stage_metadata("unknown", "id").is_none()); +} + // ── sweep_done_to_archived (CRDT-based) ───────────────────────────────── // // The sweep function reads from `read_all_typed()` and checks