//! Handler for the `freeze` and `unfreeze` commands. //! //! `freeze ` sets `frozen: true` on the story, halting pipeline //! advancement and auto-assign until `unfreeze ` clears the flag. use super::CommandContext; use crate::io::story_metadata::{ clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field, }; use std::path::Path; /// Handle the `freeze` command. /// /// Parses `` from `ctx.args`, locates the work item, and sets /// `frozen: true` in its front matter. pub(super) fn handle_freeze(ctx: &CommandContext) -> Option { let num_str = ctx.args.trim(); if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) { return Some(format!( "Usage: `{} freeze ` (e.g. `freeze 42`)", ctx.bot_name )); } Some(freeze_by_number(ctx.project_root, num_str)) } /// Core freeze logic: find story by numeric prefix and set `frozen: true`. /// /// Returns a Markdown-formatted response string suitable for all transports. pub(crate) fn freeze_by_number(project_root: &Path, story_number: &str) -> String { let (story_id, _, _, _) = match crate::chat::lookup::find_story_by_number(project_root, story_number) { Some(found) => found, None => { return format!("No story, bug, or spike with number **{story_number}** found."); } }; freeze_by_story_id(&story_id) } fn freeze_by_story_id(story_id: &str) -> String { let contents = match crate::db::read_content(story_id) { Some(c) => c, None => return format!("Failed to read story content for **{story_id}**"), }; let meta = match parse_front_matter(&contents) { Ok(m) => m, Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"), }; let story_name = meta.name.as_deref().unwrap_or(story_id).to_string(); if meta.frozen == Some(true) { return format!("**{story_name}** ({story_id}) is already frozen."); } let updated = set_front_matter_field(&contents, "frozen", "true"); crate::db::write_content(story_id, &updated); let stage = crate::pipeline_state::read_typed(story_id) .ok() .flatten() .map(|i| i.stage.dir_name().to_string()) .unwrap_or_else(|| "2_current".to_string()); crate::db::write_item_with_content(story_id, &stage, &updated); format!( "Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen." ) } /// Handle the `unfreeze` command. /// /// Parses `` from `ctx.args`, locates the work item, and clears the /// `frozen` flag to resume normal pipeline behaviour. pub(super) fn handle_unfreeze(ctx: &CommandContext) -> Option { let num_str = ctx.args.trim(); if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) { return Some(format!( "Usage: `{} unfreeze ` (e.g. `unfreeze 42`)", ctx.bot_name )); } Some(unfreeze_by_number(ctx.project_root, num_str)) } /// Core unfreeze logic: find story by numeric prefix and clear `frozen` flag. pub(crate) fn unfreeze_by_number(project_root: &Path, story_number: &str) -> String { let (story_id, _, _, _) = match crate::chat::lookup::find_story_by_number(project_root, story_number) { Some(found) => found, None => { return format!("No story, bug, or spike with number **{story_number}** found."); } }; unfreeze_by_story_id(&story_id) } fn unfreeze_by_story_id(story_id: &str) -> String { let contents = match crate::db::read_content(story_id) { Some(c) => c, None => return format!("Failed to read story content for **{story_id}**"), }; let meta = match parse_front_matter(&contents) { Ok(m) => m, Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"), }; let story_name = meta.name.as_deref().unwrap_or(story_id).to_string(); if meta.frozen != Some(true) { return format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze."); } let updated = clear_front_matter_field_in_content(&contents, "frozen"); crate::db::write_content(story_id, &updated); let stage = crate::pipeline_state::read_typed(story_id) .ok() .flatten() .map(|i| i.stage.dir_name().to_string()) .unwrap_or_else(|| "2_current".to_string()); crate::db::write_item_with_content(story_id, &stage, &updated); format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.") } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use crate::agents::AgentPool; use crate::chat::test_helpers::write_story_file; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use super::super::{CommandDispatch, try_handle_command}; fn freeze_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 freeze {args}")) } fn unfreeze_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 unfreeze {args}")) } #[test] fn freeze_command_is_registered() { use super::super::commands; assert!( commands().iter().any(|c| c.name == "freeze"), "freeze command must be in the registry" ); } #[test] fn unfreeze_command_is_registered() { use super::super::commands; assert!( commands().iter().any(|c| c.name == "unfreeze"), "unfreeze command must be in the registry" ); } #[test] fn freeze_command_no_args_returns_usage() { let tmp = tempfile::TempDir::new().unwrap(); let output = freeze_cmd_with_root(tmp.path(), "").unwrap(); assert!( output.contains("Usage"), "no args should show usage: {output}" ); } #[test] fn unfreeze_command_no_args_returns_usage() { let tmp = tempfile::TempDir::new().unwrap(); let output = unfreeze_cmd_with_root(tmp.path(), "").unwrap(); assert!( output.contains("Usage"), "no args should show usage: {output}" ); } #[test] fn freeze_command_not_found_returns_error() { let tmp = tempfile::TempDir::new().unwrap(); let output = freeze_cmd_with_root(tmp.path(), "9988").unwrap(); assert!( output.contains("9988") && output.contains("found"), "not-found message should include number and 'found': {output}" ); } #[test] fn freeze_command_sets_frozen_flag() { let tmp = tempfile::TempDir::new().unwrap(); crate::db::ensure_content_store(); write_story_file( tmp.path(), "2_current", "9940_story_freezeme.md", "---\nname: Freeze Me\n---\n# Story\n", ); let output = freeze_cmd_with_root(tmp.path(), "9940").unwrap(); assert!( output.contains("Frozen") && output.contains("Freeze Me"), "should confirm freeze with story name: {output}" ); let contents = crate::db::read_content("9940_story_freezeme") .expect("story content should be readable after freeze"); assert!( contents.contains("frozen: true"), "frozen flag should be set: {contents}" ); } #[test] fn unfreeze_command_clears_frozen_flag() { let tmp = tempfile::TempDir::new().unwrap(); crate::db::ensure_content_store(); write_story_file( tmp.path(), "2_current", "9941_story_frozen.md", "---\nname: Frozen Story\nfrozen: true\n---\n# Story\n", ); let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap(); assert!( output.contains("Unfrozen") && output.contains("Frozen Story"), "should confirm unfreeze with story name: {output}" ); let contents = crate::db::read_content("9941_story_frozen") .expect("story content should be readable after unfreeze"); assert!( !contents.contains("frozen:"), "frozen flag should be removed: {contents}" ); } #[test] fn unfreeze_command_not_frozen_returns_error() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "9942_story_notfrozen.md", "---\nname: Not Frozen\n---\n# Story\n", ); let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap(); assert!( output.contains("not frozen"), "should return not-frozen error: {output}" ); } #[test] fn freeze_command_already_frozen_returns_message() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "9943_story_alreadyfrozen.md", "---\nname: Already Frozen\nfrozen: true\n---\n# Story\n", ); let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap(); assert!( output.contains("already frozen"), "should say already frozen: {output}" ); } }