//! Handler for the `move` command. //! //! `{bot_name} move {number} {stage}` finds the work item by number across all //! pipeline stages, moves it to the specified stage, and returns a confirmation //! with the story title, old stage, and new stage. use super::CommandContext; use crate::agents::move_story_to_stage; /// Valid stage names accepted by the move command. const VALID_STAGES: &[&str] = &["backlog", "current", "qa", "merge", "done"]; /// All pipeline stage directories to search when finding a work item by number. const SEARCH_DIRS: &[&str] = &[ "1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived", ]; /// Handle the `move` command. /// /// Parses ` ` from `ctx.args`, locates the work item by its /// numeric prefix, moves it to the target stage using the shared lifecycle /// function, and returns a Markdown confirmation string. pub(super) fn handle_move(ctx: &CommandContext) -> Option { let args = ctx.args.trim(); // Parse `number stage` from args. let (num_str, stage_raw) = match args.split_once(char::is_whitespace) { Some((n, s)) => (n.trim(), s.trim()), None => { return Some(format!( "Usage: `{} move `\n\nValid stages: {}", ctx.bot_name, VALID_STAGES.join(", ") )); } }; if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) { return Some(format!( "Invalid story number: `{num_str}`. Usage: `{} move `", ctx.bot_name )); } let target_stage = stage_raw.to_ascii_lowercase(); if !VALID_STAGES.contains(&target_stage.as_str()) { return Some(format!( "Invalid stage: `{stage_raw}`. Valid stages: {}", VALID_STAGES.join(", ") )); } // Find the story file across all pipeline stages by numeric prefix. let mut found_story_id: Option = None; let mut found_name: Option = None; 'outer: for stage_dir in SEARCH_DIRS { let dir = ctx .project_root .join(".storkit") .join("work") .join(stage_dir); 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 == num_str { found_story_id = Some(stem.to_string()); found_name = std::fs::read_to_string(&path) .ok() .and_then(|contents| { crate::io::story_metadata::parse_front_matter(&contents) .ok() .and_then(|m| m.name) }); break 'outer; } } } } } let story_id = match found_story_id { Some(id) => id, None => { return Some(format!( "No story, bug, or spike with number **{num_str}** found." )); } }; let display_name = found_name.as_deref().unwrap_or(&story_id); match move_story_to_stage(ctx.project_root, &story_id, &target_stage) { Ok((from_stage, to_stage)) => Some(format!( "Moved **{display_name}** from **{from_stage}** to **{to_stage}**." )), Err(e) => Some(format!("Failed to move story {num_str}: {e}")), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use crate::agents::AgentPool; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use super::super::{CommandDispatch, try_handle_command}; fn move_cmd_with_root(root: &std::path::Path, args: &str) -> Option { let agents = Arc::new(AgentPool::new_test(3000)); let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); let room_id = "!test:example.com".to_string(); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, }; try_handle_command(&dispatch, &format!("@timmy move {args}")) } use crate::chat::test_helpers::write_story_file; #[test] fn move_command_is_registered() { use super::super::commands; let found = commands().iter().any(|c| c.name == "move"); assert!(found, "move command must be in the registry"); } #[test] fn move_command_appears_in_help() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy help", ); let output = result.unwrap(); assert!( output.contains("move"), "help should list move command: {output}" ); } #[test] fn move_command_no_args_returns_usage() { let tmp = tempfile::TempDir::new().unwrap(); let output = move_cmd_with_root(tmp.path(), "").unwrap(); assert!( output.contains("Usage"), "no args should show usage hint: {output}" ); } #[test] fn move_command_missing_stage_returns_usage() { let tmp = tempfile::TempDir::new().unwrap(); let output = move_cmd_with_root(tmp.path(), "42").unwrap(); assert!( output.contains("Usage"), "missing stage should show usage hint: {output}" ); } #[test] fn move_command_invalid_stage_returns_error() { let tmp = tempfile::TempDir::new().unwrap(); let output = move_cmd_with_root(tmp.path(), "42 invalid_stage").unwrap(); assert!( output.contains("Invalid stage"), "invalid stage should return error: {output}" ); } #[test] fn move_command_non_numeric_number_returns_error() { let tmp = tempfile::TempDir::new().unwrap(); let output = move_cmd_with_root(tmp.path(), "abc current").unwrap(); assert!( output.contains("Invalid story number"), "non-numeric number should return error: {output}" ); } #[test] fn move_command_not_found_returns_friendly_message() { let tmp = tempfile::TempDir::new().unwrap(); let output = move_cmd_with_root(tmp.path(), "999 current").unwrap(); assert!( output.contains("999") && output.contains("found"), "not-found message should include number and 'found': {output}" ); } #[test] fn move_command_moves_story_and_confirms() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "1_backlog", "42_story_some_feature.md", "---\nname: Some Feature\n---\n\n# Story 42\n", ); let output = move_cmd_with_root(tmp.path(), "42 current").unwrap(); assert!( output.contains("Some Feature"), "confirmation should include story name: {output}" ); assert!( output.contains("backlog"), "confirmation should include old stage: {output}" ); assert!( output.contains("current"), "confirmation should include new stage: {output}" ); // Verify the file was actually moved. let new_path = tmp .path() .join(".storkit/work/2_current/42_story_some_feature.md"); assert!(new_path.exists(), "story file should be in 2_current/"); } #[test] fn move_command_case_insensitive_stage() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "10_story_test.md", "---\nname: Test\n---\n", ); let output = move_cmd_with_root(tmp.path(), "10 BACKLOG").unwrap(); assert!( output.contains("Test") && output.contains("backlog"), "stage matching should be case-insensitive: {output}" ); } #[test] fn move_command_idempotent_when_already_in_target() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "5_story_already_current.md", "---\nname: Already Current\n---\n", ); // Moving to the stage it's already in should return a success message. let output = move_cmd_with_root(tmp.path(), "5 current").unwrap(); assert!( output.contains("Moved") || output.contains("current"), "idempotent move should succeed: {output}" ); } #[test] fn move_command_case_insensitive_command() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy MOVE 1 backlog", ); // Returns Some (the registry matched, regardless of result content) assert!(result.is_some(), "MOVE should match case-insensitively"); } }