use std::path::{Path, PathBuf}; use std::process::Command; use crate::io::story_metadata::clear_front_matter_field; use crate::slog; #[allow(dead_code)] 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_upcoming/). fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf { project_root.join(".story_kit").join("work").join("1_upcoming") } /// 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(".story_kit").join("work").join("5_done") } /// Move a work item (story, bug, or spike) from `work/1_upcoming/` 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_upcoming/`, 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(".story_kit").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(".story_kit").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 merge_failure from front matter now that the story is done. if let Err(e) = clear_front_matter_field(&done_path, "merge_failure") { slog!("[lifecycle] Warning: could not clear merge_failure 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(".story_kit").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/" }; 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(".story_kit").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}"))?; slog!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/"); Ok(()) } /// Move a bug from `work/2_current/` or `work/1_upcoming/` 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_upcoming/` (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(".story_kit").join("work"); let current_path = sk.join("2_current").join(format!("{bug_id}.md")); let upcoming_path = sk.join("1_upcoming").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 upcoming_path.exists() { upcoming_path.clone() } else { return Err(format!( "Bug '{bug_id}' not found in work/2_current/ or work/1_upcoming/. 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 upcoming = root.join(".story_kit/work/1_upcoming"); let current = root.join(".story_kit/work/2_current"); fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(¤t).unwrap(); fs::write(upcoming.join("10_story_foo.md"), "test").unwrap(); move_story_to_current(root, "10_story_foo").unwrap(); assert!(!upcoming.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(".story_kit/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_upcoming() { let tmp = tempfile::tempdir().unwrap(); assert!(move_story_to_current(tmp.path(), "99_missing").is_ok()); } #[test] fn move_bug_to_current_moves_from_upcoming() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let upcoming = root.join(".story_kit/work/1_upcoming"); let current = root.join(".story_kit/work/2_current"); fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(¤t).unwrap(); fs::write(upcoming.join("1_bug_test.md"), "# Bug 1\n").unwrap(); move_story_to_current(root, "1_bug_test").unwrap(); assert!(!upcoming.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(".story_kit/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(".story_kit/work/5_done/2_bug_test.md").exists()); } #[test] fn close_bug_moves_from_upcoming_when_not_started() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let upcoming = root.join(".story_kit/work/1_upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write(upcoming.join("3_bug_test.md"), "# Bug 3\n").unwrap(); close_bug_to_archive(root, "3_bug_test").unwrap(); assert!(!upcoming.join("3_bug_test.md").exists()); assert!(root.join(".story_kit/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(".story_kit/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(".story_kit/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(".story_kit/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(".story_kit/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(".story_kit/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(".story_kit/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(".story_kit/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(".story_kit/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(".story_kit/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(".story_kit/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" ); } }