diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs index 09314a5..9697b4d 100644 --- a/server/src/matrix/commands.rs +++ b/server/src/matrix/commands.rs @@ -103,6 +103,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total", handler: handle_cost, }, + BotCommand { + name: "show", + description: "Display the full text of a work item: `show `", + handler: handle_show, + }, ] } @@ -553,6 +558,72 @@ fn extract_agent_type(agent_name: &str) -> String { agent_name.to_string() } +/// Display the full markdown text of a work item identified by its numeric ID. +/// +/// Searches all pipeline stages in order and returns the raw file contents of +/// the first matching story, bug, or spike. Returns a friendly message when +/// no match is found. +fn handle_show(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() { + return Some(format!( + "Usage: `{} show `\n\nDisplays the full text of a story, bug, or spike.", + ctx.bot_name + )); + } + if !num_str.chars().all(|c| c.is_ascii_digit()) { + return Some(format!( + "Invalid story number: `{num_str}`. Usage: `{} show `", + ctx.bot_name + )); + } + + let stages = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + + for stage in &stages { + let dir = ctx + .project_root + .join(".story_kit") + .join("work") + .join(stage); + 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 { + return match std::fs::read_to_string(&path) { + Ok(contents) => Some(contents), + Err(e) => Some(format!("Failed to read story {num_str}: {e}")), + }; + } + } + } + } + } + + Some(format!( + "No story, bug, or spike with number **{num_str}** found." + )) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1262,4 +1333,129 @@ mod tests { assert_eq!(extract_agent_type("mergemaster"), "mergemaster"); assert_eq!(extract_agent_type("coder-alpha"), "coder-alpha"); } + + // -- show command ------------------------------------------------------- + + fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option { + let agents = test_agents(); + let ambient_rooms = test_ambient_rooms(); + let room_id = make_room_id("!test:example.com"); + 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, + is_addressed: true, + }; + try_handle_command(&dispatch, &format!("@timmy show {args}")) + } + + fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) { + let dir = root.join(".story_kit/work").join(stage); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(filename), content).unwrap(); + } + + #[test] + fn show_command_is_registered() { + let found = commands().iter().any(|c| c.name == "show"); + assert!(found, "show command must be in the registry"); + } + + #[test] + fn show_command_appears_in_help() { + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); + let output = result.unwrap(); + assert!(output.contains("show"), "help should list show command: {output}"); + } + + #[test] + fn show_command_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = show_cmd_with_root(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage hint: {output}" + ); + } + + #[test] + fn show_command_non_numeric_args_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = show_cmd_with_root(tmp.path(), "abc").unwrap(); + assert!( + output.contains("Invalid"), + "non-numeric arg should return error message: {output}" + ); + } + + #[test] + fn show_command_not_found_returns_friendly_message() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = show_cmd_with_root(tmp.path(), "999").unwrap(); + assert!( + output.contains("999"), + "not-found message should include the queried number: {output}" + ); + assert!( + output.contains("found"), + "not-found message should say not found: {output}" + ); + } + + #[test] + fn show_command_finds_story_in_backlog() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "305_story_show_command.md", + "---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.", + ); + let output = show_cmd_with_root(tmp.path(), "305").unwrap(); + assert!( + output.contains("Full story text here."), + "show should return full story content: {output}" + ); + } + + #[test] + fn show_command_finds_story_in_current() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "42_story_do_something.md", + "---\nname: Do something\n---\n\n# Story 42\n\nIn progress.", + ); + let output = show_cmd_with_root(tmp.path(), "42").unwrap(); + assert!( + output.contains("In progress."), + "show should return story from current stage: {output}" + ); + } + + #[test] + fn show_command_finds_bug() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "7_bug_crash_on_login.md", + "---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.", + ); + let output = show_cmd_with_root(tmp.path(), "7").unwrap(); + assert!( + output.contains("Symptom"), + "show should return bug content: {output}" + ); + } + + #[test] + fn show_command_case_insensitive() { + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1"); + assert!(result.is_some(), "SHOW should match case-insensitively"); + } }