2026-03-22 19:07:07 +00:00
|
|
|
//! Handler for the `show` command.
|
|
|
|
|
|
|
|
|
|
use super::CommandContext;
|
|
|
|
|
|
2026-04-16 08:59:39 +00:00
|
|
|
/// Strip YAML front matter and return a summary of useful fields + the remaining body.
|
2026-05-12 17:49:44 +00:00
|
|
|
#[allow(clippy::string_slice)] // indices from find("\n---") on ASCII delimiter; "---" and "\n---" are ASCII-only
|
2026-04-16 08:59:39 +00:00
|
|
|
fn strip_front_matter(text: &str) -> (String, String) {
|
|
|
|
|
let trimmed = text.trim_start();
|
|
|
|
|
if !trimmed.starts_with("---") {
|
|
|
|
|
return (String::new(), text.to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find the closing ---
|
|
|
|
|
if let Some(end) = trimmed[3..].find("\n---") {
|
|
|
|
|
let yaml_block = &trimmed[3..3 + end].trim();
|
|
|
|
|
let body = &trimmed[3 + end + 4..]; // skip past closing ---
|
|
|
|
|
|
|
|
|
|
// Extract useful fields from YAML (simple line-based parsing)
|
|
|
|
|
let mut parts = Vec::new();
|
|
|
|
|
for line in yaml_block.lines() {
|
|
|
|
|
let line = line.trim();
|
|
|
|
|
if line.starts_with("depends_on:") {
|
|
|
|
|
let val = line.trim_start_matches("depends_on:").trim();
|
|
|
|
|
if !val.is_empty() && val != "[]" {
|
|
|
|
|
parts.push(format!("**Depends on:** {val}"));
|
|
|
|
|
}
|
|
|
|
|
} else if line.starts_with("agent:") {
|
|
|
|
|
let val = line.trim_start_matches("agent:").trim().trim_matches('"');
|
|
|
|
|
if !val.is_empty() {
|
|
|
|
|
parts.push(format!("**Agent:** {val}"));
|
|
|
|
|
}
|
|
|
|
|
} else if line.starts_with("blocked:") {
|
|
|
|
|
let val = line.trim_start_matches("blocked:").trim();
|
|
|
|
|
if val == "true" {
|
|
|
|
|
parts.push("**Blocked:** yes".to_string());
|
|
|
|
|
}
|
|
|
|
|
} else if line.starts_with("retry_count:") {
|
|
|
|
|
let val = line.trim_start_matches("retry_count:").trim();
|
|
|
|
|
if val != "0" && !val.is_empty() {
|
|
|
|
|
parts.push(format!("**Retries:** {val}"));
|
|
|
|
|
}
|
|
|
|
|
} else if line.starts_with("qa:") {
|
|
|
|
|
let val = line.trim_start_matches("qa:").trim().trim_matches('"');
|
|
|
|
|
if val == "human" {
|
|
|
|
|
parts.push("**QA:** human review required".to_string());
|
|
|
|
|
}
|
|
|
|
|
} else if line.starts_with("merge_failure:") {
|
2026-04-16 11:37:30 +00:00
|
|
|
let val = line
|
|
|
|
|
.trim_start_matches("merge_failure:")
|
|
|
|
|
.trim()
|
|
|
|
|
.trim_matches('"');
|
2026-04-16 08:59:39 +00:00
|
|
|
if !val.is_empty() {
|
|
|
|
|
parts.push(format!("**Merge failure:** {val}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(parts.join(" · "), body.to_string())
|
|
|
|
|
} else {
|
|
|
|
|
// No closing ---, return as-is
|
|
|
|
|
(String::new(), text.to_string())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// Display the full markdown text of a work item identified by its numeric ID.
|
|
|
|
|
///
|
2026-04-09 23:00:01 +00:00
|
|
|
/// Lookup priority: CRDT → content store → filesystem (Story 512).
|
|
|
|
|
/// Returns a friendly message when no match is found.
|
2026-03-22 19:07:07 +00:00
|
|
|
pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
|
|
|
|
let num_str = ctx.args.trim();
|
|
|
|
|
if num_str.is_empty() {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"Usage: `{} show <number>`\n\nDisplays the full text of a story, bug, or spike.",
|
2026-04-25 20:37:10 +00:00
|
|
|
ctx.services.bot_name
|
2026-03-22 19:07:07 +00:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"Invalid story number: `{num_str}`. Usage: `{} show <number>`",
|
2026-04-25 20:37:10 +00:00
|
|
|
ctx.services.bot_name
|
2026-03-22 19:07:07 +00:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 17:23:22 +00:00
|
|
|
// Find the story by numeric prefix: CRDT → content store.
|
|
|
|
|
let (story_id, _stage_dir, _path, content) =
|
2026-04-25 20:37:10 +00:00
|
|
|
match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) {
|
2026-04-09 23:00:01 +00:00
|
|
|
Some(found) => found,
|
|
|
|
|
None => {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"No story, bug, or spike with number **{num_str}** found."
|
|
|
|
|
));
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
2026-04-09 23:00:01 +00:00
|
|
|
};
|
|
|
|
|
|
2026-04-15 17:23:22 +00:00
|
|
|
// `content` comes from the CRDT / content store. If unavailable, report
|
|
|
|
|
// it rather than silently reading a stale on-disk copy.
|
2026-04-16 08:47:41 +00:00
|
|
|
let text = content.unwrap_or_else(|| {
|
2026-04-15 17:23:22 +00:00
|
|
|
format!("Story {story_id} found in pipeline but its content is unavailable.")
|
2026-04-16 08:47:41 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-16 08:59:39 +00:00
|
|
|
// Strip front matter block and extract useful metadata to show inline.
|
|
|
|
|
let (front_matter_summary, body) = strip_front_matter(&text);
|
|
|
|
|
|
2026-04-16 08:47:41 +00:00
|
|
|
// Convert markdown headings to bold text for consistent rendering across
|
|
|
|
|
// Matrix clients. Element X doesn't style <h2> tags distinctly, but bold
|
|
|
|
|
// text renders consistently everywhere.
|
2026-04-16 08:59:39 +00:00
|
|
|
let formatted = body
|
2026-04-16 08:47:41 +00:00
|
|
|
.lines()
|
|
|
|
|
.map(|line| {
|
|
|
|
|
let trimmed = line.trim_start();
|
|
|
|
|
if let Some(rest) = trimmed.strip_prefix("### ") {
|
|
|
|
|
format!("\n**{}**", rest)
|
|
|
|
|
} else if let Some(rest) = trimmed.strip_prefix("## ") {
|
|
|
|
|
format!("\n**{}**", rest)
|
|
|
|
|
} else if let Some(rest) = trimmed.strip_prefix("# ") {
|
|
|
|
|
format!("\n**{}**", rest)
|
|
|
|
|
} else {
|
|
|
|
|
line.to_string()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
2026-04-16 08:59:39 +00:00
|
|
|
if front_matter_summary.is_empty() {
|
|
|
|
|
Some(formatted.trim().to_string())
|
|
|
|
|
} else {
|
|
|
|
|
Some(format!("{front_matter_summary}\n{}", formatted.trim()))
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::super::{CommandDispatch, try_handle_command};
|
|
|
|
|
|
|
|
|
|
fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
2026-04-25 20:37:10 +00:00
|
|
|
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
|
2026-03-22 19:07:07 +00:00
|
|
|
let room_id = "!test:example.com".to_string();
|
|
|
|
|
let dispatch = CommandDispatch {
|
2026-04-25 20:37:10 +00:00
|
|
|
services: &services,
|
|
|
|
|
project_root: &services.project_root,
|
2026-03-22 19:07:07 +00:00
|
|
|
bot_user_id: "@timmy:homeserver.local",
|
|
|
|
|
room_id: &room_id,
|
|
|
|
|
};
|
|
|
|
|
try_handle_command(&dispatch, &format!("@timmy show {args}"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 19:47:59 +00:00
|
|
|
use crate::chat::test_helpers::write_story_file;
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn show_command_is_registered() {
|
|
|
|
|
use super::super::commands;
|
|
|
|
|
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() {
|
2026-04-13 14:07:08 +00:00
|
|
|
let result = super::super::tests::try_cmd_addressed(
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:homeserver.local",
|
|
|
|
|
"@timmy help",
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
let output = result.unwrap();
|
2026-04-13 14:07:08 +00:00
|
|
|
assert!(
|
|
|
|
|
output.contains("show"),
|
|
|
|
|
"help should list show command: {output}"
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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.",
|
2026-05-12 20:55:25 +01:00
|
|
|
None,
|
2026-03-22 19:07:07 +00:00
|
|
|
);
|
|
|
|
|
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.",
|
2026-05-12 20:55:25 +01:00
|
|
|
None,
|
2026-03-22 19:07:07 +00:00
|
|
|
);
|
|
|
|
|
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.",
|
2026-05-12 20:55:25 +01:00
|
|
|
None,
|
2026-03-22 19:07:07 +00:00
|
|
|
);
|
|
|
|
|
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() {
|
2026-04-13 14:07:08 +00:00
|
|
|
let result = super::super::tests::try_cmd_addressed(
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:homeserver.local",
|
|
|
|
|
"@timmy SHOW 1",
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
assert!(result.is_some(), "SHOW should match case-insensitively");
|
|
|
|
|
}
|
|
|
|
|
}
|