use std::path::{Path, PathBuf}; use std::process::Command; use crate::io::story_metadata::{clear_front_matter_field, write_rejection_notes}; use crate::slog; pub(super) fn item_type_from_id(item_id: &str) -> &'static str { // New format: {digits}_{type}_{slug} let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit()); if after_num.starts_with("_bug_") { "bug" } else if after_num.starts_with("_spike_") { "spike" } else { "story" } } /// Return the source directory path for a work item (always work/1_backlog/). fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf { project_root.join(".storkit").join("work").join("1_backlog") } /// Return the done directory path for a work item (always work/5_done/). fn item_archive_dir(project_root: &Path, _item_id: &str) -> PathBuf { project_root.join(".storkit").join("work").join("5_done") } /// Move a work item (story, bug, or spike) from `work/1_backlog/` to `work/2_current/`. /// /// Idempotent: if the item is already in `2_current/`, returns Ok without committing. /// If the item is not found in `1_backlog/`, logs a warning and returns Ok. pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> { let sk = project_root.join(".storkit").join("work"); let current_dir = sk.join("2_current"); let current_path = current_dir.join(format!("{story_id}.md")); if current_path.exists() { // Already in 2_current/ — idempotent, nothing to do. return Ok(()); } let source_dir = item_source_dir(project_root, story_id); let source_path = source_dir.join(format!("{story_id}.md")); if !source_path.exists() { slog!( "[lifecycle] Work item '{story_id}' not found in {}; skipping move to 2_current/", source_dir.display() ); return Ok(()); } std::fs::create_dir_all(¤t_dir) .map_err(|e| format!("Failed to create work/2_current/ directory: {e}"))?; std::fs::rename(&source_path, ¤t_path) .map_err(|e| format!("Failed to move '{story_id}' to 2_current/: {e}"))?; slog!( "[lifecycle] Moved '{story_id}' from {} to work/2_current/", source_dir.display() ); Ok(()) } /// Check whether a feature branch `feature/story-{story_id}` exists and has /// commits that are not yet on master. Returns `true` when there is unmerged /// work, `false` when there is no branch or all its commits are already /// reachable from master. pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str) -> bool { let branch = format!("feature/story-{story_id}"); // Check if the branch exists. let branch_check = Command::new("git") .args(["rev-parse", "--verify", &branch]) .current_dir(project_root) .output(); match branch_check { Ok(out) if out.status.success() => {} _ => return false, // No feature branch → nothing to merge. } // Check if the branch has commits not reachable from master. let log = Command::new("git") .args(["log", &format!("master..{branch}"), "--oneline"]) .current_dir(project_root) .output(); match log { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout); !stdout.trim().is_empty() } Err(_) => false, } } /// Move a story from `work/2_current/` to `work/5_done/` and auto-commit. /// /// * If the story is in `2_current/`, it is moved to `5_done/` and committed. /// * If the story is in `4_merge/`, it is moved to `5_done/` and committed. /// * If the story is already in `5_done/` or `6_archived/`, this is a no-op (idempotent). /// * If the story is not found in `2_current/`, `4_merge/`, `5_done/`, or `6_archived/`, an error is returned. pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> { let sk = project_root.join(".storkit").join("work"); let current_path = sk.join("2_current").join(format!("{story_id}.md")); let merge_path = sk.join("4_merge").join(format!("{story_id}.md")); let done_dir = sk.join("5_done"); let done_path = done_dir.join(format!("{story_id}.md")); let archived_path = sk.join("6_archived").join(format!("{story_id}.md")); if done_path.exists() || archived_path.exists() { // Already in done or archived — idempotent, nothing to do. return Ok(()); } // Check 2_current/ first, then 4_merge/ let source_path = if current_path.exists() { current_path.clone() } else if merge_path.exists() { merge_path.clone() } else { return Err(format!( "Story '{story_id}' not found in work/2_current/ or work/4_merge/. Cannot accept story." )); }; std::fs::create_dir_all(&done_dir) .map_err(|e| format!("Failed to create work/5_done/ directory: {e}"))?; std::fs::rename(&source_path, &done_path) .map_err(|e| format!("Failed to move story '{story_id}' to 5_done/: {e}"))?; // Strip stale pipeline fields from front matter now that the story is done. for field in &["merge_failure", "retry_count", "blocked"] { if let Err(e) = clear_front_matter_field(&done_path, field) { slog!("[lifecycle] Warning: could not clear {field} from '{story_id}': {e}"); } } let from_dir = if source_path == current_path { "work/2_current/" } else { "work/4_merge/" }; slog!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_done/"); Ok(()) } /// Move a story/bug from `work/2_current/` or `work/3_qa/` to `work/4_merge/`. /// /// This stages a work item as ready for the mergemaster to pick up and merge into master. /// Idempotent: if already in `4_merge/`, returns Ok without committing. pub fn move_story_to_merge(project_root: &Path, story_id: &str) -> Result<(), String> { let sk = project_root.join(".storkit").join("work"); let current_path = sk.join("2_current").join(format!("{story_id}.md")); let qa_path = sk.join("3_qa").join(format!("{story_id}.md")); let merge_dir = sk.join("4_merge"); let merge_path = merge_dir.join(format!("{story_id}.md")); if merge_path.exists() { // Already in 4_merge/ — idempotent, nothing to do. return Ok(()); } // Accept from 2_current/ (manual trigger) or 3_qa/ (pipeline advancement from QA stage). let source_path = if current_path.exists() { current_path.clone() } else if qa_path.exists() { qa_path.clone() } else { return Err(format!( "Work item '{story_id}' not found in work/2_current/ or work/3_qa/. Cannot move to 4_merge/." )); }; std::fs::create_dir_all(&merge_dir) .map_err(|e| format!("Failed to create work/4_merge/ directory: {e}"))?; std::fs::rename(&source_path, &merge_path) .map_err(|e| format!("Failed to move '{story_id}' to 4_merge/: {e}"))?; let from_dir = if source_path == current_path { "work/2_current/" } else { "work/3_qa/" }; // Reset retry count and blocked for the new stage. if let Err(e) = clear_front_matter_field(&merge_path, "retry_count") { slog!("[lifecycle] Warning: could not clear retry_count for '{story_id}': {e}"); } if let Err(e) = clear_front_matter_field(&merge_path, "blocked") { slog!("[lifecycle] Warning: could not clear blocked for '{story_id}': {e}"); } slog!("[lifecycle] Moved '{story_id}' from {from_dir} to work/4_merge/"); Ok(()) } /// Move a story/bug from `work/2_current/` to `work/3_qa/` and auto-commit. /// /// This stages a work item for QA review before merging to master. /// Idempotent: if already in `3_qa/`, returns Ok without committing. pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), String> { let sk = project_root.join(".storkit").join("work"); let current_path = sk.join("2_current").join(format!("{story_id}.md")); let qa_dir = sk.join("3_qa"); let qa_path = qa_dir.join(format!("{story_id}.md")); if qa_path.exists() { // Already in 3_qa/ — idempotent, nothing to do. return Ok(()); } if !current_path.exists() { return Err(format!( "Work item '{story_id}' not found in work/2_current/. Cannot move to 3_qa/." )); } std::fs::create_dir_all(&qa_dir) .map_err(|e| format!("Failed to create work/3_qa/ directory: {e}"))?; std::fs::rename(¤t_path, &qa_path) .map_err(|e| format!("Failed to move '{story_id}' to 3_qa/: {e}"))?; // Reset retry count for the new stage. if let Err(e) = clear_front_matter_field(&qa_path, "retry_count") { slog!("[lifecycle] Warning: could not clear retry_count for '{story_id}': {e}"); } if let Err(e) = clear_front_matter_field(&qa_path, "blocked") { slog!("[lifecycle] Warning: could not clear blocked for '{story_id}': {e}"); } slog!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/"); Ok(()) } /// Move a story from `work/3_qa/` back to `work/2_current/` and write rejection notes. /// /// Used when a human reviewer rejects a story during manual QA. /// Clears the `review_hold` front matter field and appends rejection notes to the story file. pub fn reject_story_from_qa( project_root: &Path, story_id: &str, notes: &str, ) -> Result<(), String> { let sk = project_root.join(".storkit").join("work"); let qa_path = sk.join("3_qa").join(format!("{story_id}.md")); let current_dir = sk.join("2_current"); let current_path = current_dir.join(format!("{story_id}.md")); if current_path.exists() { return Ok(()); // Already in 2_current — idempotent. } if !qa_path.exists() { return Err(format!( "Work item '{story_id}' not found in work/3_qa/. Cannot reject." )); } std::fs::create_dir_all(¤t_dir) .map_err(|e| format!("Failed to create work/2_current/ directory: {e}"))?; std::fs::rename(&qa_path, ¤t_path) .map_err(|e| format!("Failed to move '{story_id}' from 3_qa/ to 2_current/: {e}"))?; // Clear review_hold since the story is going back for rework. if let Err(e) = clear_front_matter_field(¤t_path, "review_hold") { slog!("[lifecycle] Warning: could not clear review_hold from '{story_id}': {e}"); } // Write rejection notes into the story file so the coder can see what needs fixing. if !notes.is_empty() && let Err(e) = write_rejection_notes(¤t_path, notes) { slog!("[lifecycle] Warning: could not write rejection notes to '{story_id}': {e}"); } slog!("[lifecycle] Rejected '{story_id}' from work/3_qa/ back to work/2_current/"); Ok(()) } /// Move any work item to an arbitrary pipeline stage by searching all stages. /// /// Accepts `target_stage` as one of: `backlog`, `current`, `qa`, `merge`, `done`. /// Idempotent: if the item is already in the target stage, returns Ok. /// Returns `(from_stage, to_stage)` on success. pub fn move_story_to_stage( project_root: &Path, story_id: &str, target_stage: &str, ) -> Result<(String, String), String> { let stage_dirs: &[(&str, &str)] = &[ ("backlog", "1_backlog"), ("current", "2_current"), ("qa", "3_qa"), ("merge", "4_merge"), ("done", "5_done"), ]; let target_dir_name = stage_dirs .iter() .find(|(name, _)| *name == target_stage) .map(|(_, dir)| *dir) .ok_or_else(|| { format!( "Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done" ) })?; let sk = project_root.join(".storkit").join("work"); let target_dir = sk.join(target_dir_name); let target_path = target_dir.join(format!("{story_id}.md")); if target_path.exists() { return Ok((target_stage.to_string(), target_stage.to_string())); } // Search all named stages plus the archive stage. let search_dirs: &[(&str, &str)] = &[ ("backlog", "1_backlog"), ("current", "2_current"), ("qa", "3_qa"), ("merge", "4_merge"), ("done", "5_done"), ("archived", "6_archived"), ]; let mut found_path: Option = None; let mut from_stage = ""; for (stage_name, dir_name) in search_dirs { let candidate = sk.join(dir_name).join(format!("{story_id}.md")); if candidate.exists() { found_path = Some(candidate); from_stage = stage_name; break; } } let source_path = found_path.ok_or_else(|| format!("Work item '{story_id}' not found in any pipeline stage."))?; std::fs::create_dir_all(&target_dir) .map_err(|e| format!("Failed to create work/{target_dir_name}/ directory: {e}"))?; std::fs::rename(&source_path, &target_path) .map_err(|e| format!("Failed to move '{story_id}' to work/{target_dir_name}/: {e}"))?; slog!( "[lifecycle] Moved '{story_id}' from work/{from_stage}/ to work/{target_dir_name}/" ); Ok((from_stage.to_string(), target_stage.to_string())) } /// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/` and auto-commit. /// /// * If the bug is in `2_current/`, it is moved to `5_done/` and committed. /// * If the bug is still in `1_backlog/` (never started), it is moved directly to `5_done/`. /// * If the bug is already in `5_done/`, this is a no-op (idempotent). /// * If the bug is not found anywhere, an error is returned. pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), String> { let sk = project_root.join(".storkit").join("work"); let current_path = sk.join("2_current").join(format!("{bug_id}.md")); let backlog_path = sk.join("1_backlog").join(format!("{bug_id}.md")); let archive_dir = item_archive_dir(project_root, bug_id); let archive_path = archive_dir.join(format!("{bug_id}.md")); if archive_path.exists() { return Ok(()); } let source_path = if current_path.exists() { current_path.clone() } else if backlog_path.exists() { backlog_path.clone() } else { return Err(format!( "Bug '{bug_id}' not found in work/2_current/ or work/1_backlog/. Cannot close bug." )); }; std::fs::create_dir_all(&archive_dir) .map_err(|e| format!("Failed to create work/5_done/ directory: {e}"))?; std::fs::rename(&source_path, &archive_path) .map_err(|e| format!("Failed to move bug '{bug_id}' to 5_done/: {e}"))?; slog!( "[lifecycle] Closed bug '{bug_id}' → work/5_done/" ); Ok(()) } #[cfg(test)] mod tests { use super::*; // ── move_story_to_current tests ──────────────────────────────────────────── #[test] fn move_story_to_current_moves_file() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let backlog = root.join(".storkit/work/1_backlog"); let current = root.join(".storkit/work/2_current"); fs::create_dir_all(&backlog).unwrap(); fs::create_dir_all(¤t).unwrap(); fs::write(backlog.join("10_story_foo.md"), "test").unwrap(); move_story_to_current(root, "10_story_foo").unwrap(); assert!(!backlog.join("10_story_foo.md").exists()); assert!(current.join("10_story_foo.md").exists()); } #[test] fn move_story_to_current_is_idempotent_when_already_current() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let current = root.join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("11_story_foo.md"), "test").unwrap(); move_story_to_current(root, "11_story_foo").unwrap(); assert!(current.join("11_story_foo.md").exists()); } #[test] fn move_story_to_current_noop_when_not_in_backlog() { let tmp = tempfile::tempdir().unwrap(); assert!(move_story_to_current(tmp.path(), "99_missing").is_ok()); } #[test] fn move_bug_to_current_moves_from_backlog() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let backlog = root.join(".storkit/work/1_backlog"); let current = root.join(".storkit/work/2_current"); fs::create_dir_all(&backlog).unwrap(); fs::create_dir_all(¤t).unwrap(); fs::write(backlog.join("1_bug_test.md"), "# Bug 1\n").unwrap(); move_story_to_current(root, "1_bug_test").unwrap(); assert!(!backlog.join("1_bug_test.md").exists()); assert!(current.join("1_bug_test.md").exists()); } // ── close_bug_to_archive tests ───────────────────────────────────────────── #[test] fn close_bug_moves_from_current_to_archive() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let current = root.join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("2_bug_test.md"), "# Bug 2\n").unwrap(); close_bug_to_archive(root, "2_bug_test").unwrap(); assert!(!current.join("2_bug_test.md").exists()); assert!(root.join(".storkit/work/5_done/2_bug_test.md").exists()); } #[test] fn close_bug_moves_from_backlog_when_not_started() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let backlog = root.join(".storkit/work/1_backlog"); fs::create_dir_all(&backlog).unwrap(); fs::write(backlog.join("3_bug_test.md"), "# Bug 3\n").unwrap(); close_bug_to_archive(root, "3_bug_test").unwrap(); assert!(!backlog.join("3_bug_test.md").exists()); assert!(root.join(".storkit/work/5_done/3_bug_test.md").exists()); } // ── item_type_from_id tests ──────────────────────────────────────────────── #[test] fn item_type_from_id_detects_types() { assert_eq!(item_type_from_id("1_bug_test"), "bug"); assert_eq!(item_type_from_id("1_spike_research"), "spike"); assert_eq!(item_type_from_id("50_story_my_story"), "story"); assert_eq!(item_type_from_id("1_story_simple"), "story"); } // ── move_story_to_merge tests ────────────────────────────────────────────── #[test] fn move_story_to_merge_moves_file() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let current = root.join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("20_story_foo.md"), "test").unwrap(); move_story_to_merge(root, "20_story_foo").unwrap(); assert!(!current.join("20_story_foo.md").exists()); assert!(root.join(".storkit/work/4_merge/20_story_foo.md").exists()); } #[test] fn move_story_to_merge_from_qa_dir() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let qa_dir = root.join(".storkit/work/3_qa"); fs::create_dir_all(&qa_dir).unwrap(); fs::write(qa_dir.join("40_story_test.md"), "test").unwrap(); move_story_to_merge(root, "40_story_test").unwrap(); assert!(!qa_dir.join("40_story_test.md").exists()); assert!(root.join(".storkit/work/4_merge/40_story_test.md").exists()); } #[test] fn move_story_to_merge_idempotent_when_already_in_merge() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let merge_dir = root.join(".storkit/work/4_merge"); fs::create_dir_all(&merge_dir).unwrap(); fs::write(merge_dir.join("21_story_test.md"), "test").unwrap(); move_story_to_merge(root, "21_story_test").unwrap(); assert!(merge_dir.join("21_story_test.md").exists()); } #[test] fn move_story_to_merge_errors_when_not_in_current_or_qa() { let tmp = tempfile::tempdir().unwrap(); let result = move_story_to_merge(tmp.path(), "99_nonexistent"); assert!(result.unwrap_err().contains("not found in work/2_current/ or work/3_qa/")); } // ── move_story_to_qa tests ──────────────────────────────────────────────── #[test] fn move_story_to_qa_moves_file() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let current = root.join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("30_story_qa.md"), "test").unwrap(); move_story_to_qa(root, "30_story_qa").unwrap(); assert!(!current.join("30_story_qa.md").exists()); assert!(root.join(".storkit/work/3_qa/30_story_qa.md").exists()); } #[test] fn move_story_to_qa_idempotent_when_already_in_qa() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let qa_dir = root.join(".storkit/work/3_qa"); fs::create_dir_all(&qa_dir).unwrap(); fs::write(qa_dir.join("31_story_test.md"), "test").unwrap(); move_story_to_qa(root, "31_story_test").unwrap(); assert!(qa_dir.join("31_story_test.md").exists()); } #[test] fn move_story_to_qa_errors_when_not_in_current() { let tmp = tempfile::tempdir().unwrap(); let result = move_story_to_qa(tmp.path(), "99_nonexistent"); assert!(result.unwrap_err().contains("not found in work/2_current/")); } // ── move_story_to_archived tests ────────────────────────────────────────── #[test] fn move_story_to_archived_finds_in_merge_dir() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let merge_dir = root.join(".storkit/work/4_merge"); fs::create_dir_all(&merge_dir).unwrap(); fs::write(merge_dir.join("22_story_test.md"), "test").unwrap(); move_story_to_archived(root, "22_story_test").unwrap(); assert!(!merge_dir.join("22_story_test.md").exists()); assert!(root.join(".storkit/work/5_done/22_story_test.md").exists()); } #[test] fn move_story_to_archived_error_when_not_in_current_or_merge() { let tmp = tempfile::tempdir().unwrap(); let result = move_story_to_archived(tmp.path(), "99_nonexistent"); assert!(result.unwrap_err().contains("4_merge")); } // ── feature_branch_has_unmerged_changes tests ──────────────────────────── fn init_git_repo(repo: &std::path::Path) { Command::new("git") .args(["init"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(repo) .output() .unwrap(); } /// Bug 226: feature_branch_has_unmerged_changes returns true when the /// feature branch has commits not on master. #[test] fn feature_branch_has_unmerged_changes_detects_unmerged_code() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create a feature branch with a code commit. Command::new("git") .args(["checkout", "-b", "feature/story-50_story_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("feature.rs"), "fn main() {}").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add feature"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); assert!( feature_branch_has_unmerged_changes(repo, "50_story_test"), "should detect unmerged changes on feature branch" ); } /// Bug 226: feature_branch_has_unmerged_changes returns false when no /// feature branch exists. #[test] fn feature_branch_has_unmerged_changes_false_when_no_branch() { use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); assert!( !feature_branch_has_unmerged_changes(repo, "99_nonexistent"), "should return false when no feature branch" ); } // ── reject_story_from_qa tests ──────────────────────────────────────────── #[test] fn reject_story_from_qa_moves_to_current() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let qa_dir = root.join(".storkit/work/3_qa"); let current_dir = root.join(".storkit/work/2_current"); fs::create_dir_all(&qa_dir).unwrap(); fs::create_dir_all(¤t_dir).unwrap(); fs::write( qa_dir.join("50_story_test.md"), "---\nname: Test\nreview_hold: true\n---\n# Story\n", ) .unwrap(); reject_story_from_qa(root, "50_story_test", "Button color wrong").unwrap(); assert!(!qa_dir.join("50_story_test.md").exists()); assert!(current_dir.join("50_story_test.md").exists()); let contents = fs::read_to_string(current_dir.join("50_story_test.md")).unwrap(); assert!(contents.contains("Button color wrong")); assert!(contents.contains("## QA Rejection Notes")); assert!(!contents.contains("review_hold")); } #[test] fn reject_story_from_qa_errors_when_not_in_qa() { let tmp = tempfile::tempdir().unwrap(); let result = reject_story_from_qa(tmp.path(), "99_nonexistent", "notes"); assert!(result.unwrap_err().contains("not found in work/3_qa/")); } #[test] fn reject_story_from_qa_idempotent_when_in_current() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let current_dir = root.join(".storkit/work/2_current"); fs::create_dir_all(¤t_dir).unwrap(); fs::write(current_dir.join("51_story_test.md"), "---\nname: Test\n---\n# Story\n").unwrap(); reject_story_from_qa(root, "51_story_test", "notes").unwrap(); assert!(current_dir.join("51_story_test.md").exists()); } // ── move_story_to_stage tests ───────────────────────────────── #[test] fn move_story_to_stage_moves_from_backlog_to_current() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let backlog = root.join(".storkit/work/1_backlog"); let current = root.join(".storkit/work/2_current"); fs::create_dir_all(&backlog).unwrap(); fs::create_dir_all(¤t).unwrap(); fs::write(backlog.join("60_story_move.md"), "test").unwrap(); let (from, to) = move_story_to_stage(root, "60_story_move", "current").unwrap(); assert_eq!(from, "backlog"); assert_eq!(to, "current"); assert!(!backlog.join("60_story_move.md").exists()); assert!(current.join("60_story_move.md").exists()); } #[test] fn move_story_to_stage_moves_from_current_to_backlog() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let current = root.join(".storkit/work/2_current"); let backlog = root.join(".storkit/work/1_backlog"); fs::create_dir_all(¤t).unwrap(); fs::create_dir_all(&backlog).unwrap(); fs::write(current.join("61_story_back.md"), "test").unwrap(); let (from, to) = move_story_to_stage(root, "61_story_back", "backlog").unwrap(); assert_eq!(from, "current"); assert_eq!(to, "backlog"); assert!(!current.join("61_story_back.md").exists()); assert!(backlog.join("61_story_back.md").exists()); } #[test] fn move_story_to_stage_idempotent_when_already_in_target() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let current = root.join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("62_story_idem.md"), "test").unwrap(); let (from, to) = move_story_to_stage(root, "62_story_idem", "current").unwrap(); assert_eq!(from, "current"); assert_eq!(to, "current"); assert!(current.join("62_story_idem.md").exists()); } #[test] fn move_story_to_stage_invalid_target_returns_error() { let tmp = tempfile::tempdir().unwrap(); let result = move_story_to_stage(tmp.path(), "1_story_test", "invalid"); assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid target_stage")); } #[test] fn move_story_to_stage_not_found_returns_error() { let tmp = tempfile::tempdir().unwrap(); let result = move_story_to_stage(tmp.path(), "99_story_ghost", "current"); assert!(result.is_err()); assert!(result.unwrap_err().contains("not found in any pipeline stage")); } #[test] fn move_story_to_stage_finds_in_qa_dir() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let qa_dir = root.join(".storkit/work/3_qa"); let backlog = root.join(".storkit/work/1_backlog"); fs::create_dir_all(&qa_dir).unwrap(); fs::create_dir_all(&backlog).unwrap(); fs::write(qa_dir.join("63_story_qa.md"), "test").unwrap(); let (from, to) = move_story_to_stage(root, "63_story_qa", "backlog").unwrap(); assert_eq!(from, "qa"); assert_eq!(to, "backlog"); assert!(!qa_dir.join("63_story_qa.md").exists()); assert!(backlog.join("63_story_qa.md").exists()); } }