Files
huskies/server/src/chat/commands/backlog.rs
T

238 lines
7.3 KiB
Rust
Raw Normal View History

//! 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<String> {
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: `⚪ <number> [type] — <name> *(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<String> = 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<u32>) -> 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}"
);
}
}