//! Handler for the `backlog` command — shows only Stage::Backlog items. use super::CommandContext; use super::status::{story_short_label, unmet_deps_from_items}; use crate::pipeline_state::{PipelineItem, Stage}; pub(super) fn handle_backlog(_ctx: &CommandContext) -> Option { Some(build_backlog_output()) } /// Build the backlog listing text. /// /// Reads all items from CRDT via `read_all_typed`, filters to `Stage::Backlog`, /// and renders each with number, type, name, and dependency satisfaction status. pub(super) fn build_backlog_output() -> String { let items = crate::pipeline_state::read_all_typed(); build_backlog_from_items(&items) } /// Inner implementation that accepts pre-loaded items for testability. pub(super) fn build_backlog_from_items(items: &[PipelineItem]) -> String { let mut backlog: Vec<&PipelineItem> = items .iter() .filter(|i| matches!(i.stage, Stage::Backlog)) .collect(); backlog.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0)); let count = backlog.len(); let mut out = format!("**Backlog** ({count})\n"); if backlog.is_empty() { out.push_str(" *(none)*\n"); return out; } for item in &backlog { out.push_str(&render_backlog_line(item, items)); } out } /// Render a single backlog line: `⚪ [type] — *(waiting on: X, Y)*` fn render_backlog_line(item: &PipelineItem, all_items: &[PipelineItem]) -> String { let story_id = &item.story_id.0; let name_opt = if item.name.is_empty() { None } else { Some(item.name.as_str()) }; let display = story_short_label(story_id, name_opt); let unmet = unmet_deps_from_items(item, all_items); let dep_suffix = if unmet.is_empty() { String::new() } else { let nums: Vec = unmet.iter().map(|n| n.to_string()).collect(); format!(" *(waiting on: {})*", nums.join(", ")) }; format!(" \u{26AA} {display}{dep_suffix}\n") // ⚪ } #[cfg(test)] mod tests { use super::*; use crate::pipeline_state::{Stage, StoryId}; use chrono::Utc; fn make_item(id: &str, name: &str, stage: Stage) -> PipelineItem { PipelineItem { story_id: StoryId(id.to_string()), name: name.to_string(), stage, depends_on: Vec::new(), retry_count: 0, } } fn make_item_with_deps(id: &str, name: &str, stage: Stage, deps: Vec) -> PipelineItem { PipelineItem { story_id: StoryId(id.to_string()), name: name.to_string(), stage, depends_on: deps.iter().map(|n| StoryId(n.to_string())).collect(), retry_count: 0, } } // -- AC: shows only Stage::Backlog items ---------------------------------- #[test] fn backlog_shows_only_backlog_stage_items() { let items = vec![ make_item("10_story_in_backlog", "In Backlog", Stage::Backlog), make_item("20_story_in_progress", "In Progress", Stage::Coding), make_item("30_story_in_qa", "In QA", Stage::Qa), ]; let output = build_backlog_from_items(&items); assert!( output.contains("In Backlog"), "should show backlog item: {output}" ); assert!( !output.contains("In Progress"), "should not show coding items: {output}" ); assert!( !output.contains("In QA"), "should not show QA items: {output}" ); } // -- AC: shows number, type, name ----------------------------------------- #[test] fn backlog_shows_number_type_and_name() { let items = vec![make_item( "42_story_my_feature", "My Feature", Stage::Backlog, )]; let output = build_backlog_from_items(&items); assert!( output.contains("42 [story] — My Feature"), "should show number, type, and name: {output}" ); } // -- AC: shows depends_on with satisfaction status ------------------------ #[test] fn backlog_shows_waiting_on_for_unmet_deps() { let items = vec![ make_item_with_deps( "10_story_waiting", "Waiting Story", Stage::Backlog, vec![999], ), make_item("999_story_dep", "Dep Story", Stage::Backlog), ]; let output = build_backlog_from_items(&items); assert!( output.contains("waiting on: 999"), "should show unmet dep: {output}" ); } #[test] fn backlog_no_waiting_on_when_dep_is_done() { let items = vec![ make_item_with_deps("10_story_ready", "Ready Story", Stage::Backlog, vec![999]), make_item( "999_story_done", "Done Dep", Stage::Done { merged_at: Utc::now(), merge_commit: crate::pipeline_state::GitSha("abc".to_string()), }, ), ]; let output = build_backlog_from_items(&items); assert!( !output.contains("waiting on"), "should not show waiting-on when dep is done: {output}" ); } #[test] fn backlog_no_waiting_on_when_no_deps() { let items = vec![make_item("5_story_nodeps", "No Deps", Stage::Backlog)]; let output = build_backlog_from_items(&items); assert!( !output.contains("waiting on"), "no dep suffix when no deps: {output}" ); } // -- AC: command is registered in the registry ---------------------------- #[test] fn backlog_command_in_registry() { let found = super::super::commands().iter().any(|c| c.name == "backlog"); assert!(found, "backlog must be registered in commands()"); } #[test] fn backlog_command_appears_in_help() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy help", ); let output = result.unwrap_or_default(); assert!( output.contains("backlog"), "backlog should appear in help output: {output}" ); } #[test] fn backlog_command_matches() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy backlog", ); assert!( result.is_some(), "backlog command should match and return Some" ); } #[test] fn backlog_output_contains_backlog_header() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy backlog", ); let output = result.unwrap_or_default(); assert!( output.contains("Backlog"), "backlog output should contain Backlog header: {output}" ); } // -- empty backlog -------------------------------------------------------- #[test] fn backlog_shows_none_when_empty() { let items = vec![make_item("1_story_done", "Done", Stage::Coding)]; let output = build_backlog_from_items(&items); assert!( output.contains("*(none)*"), "should show none when no backlog items: {output}" ); } }