storkit: merge 392_refactor_extract_shared_transport_utilities_from_matrix_module_into_chat_submodule

This commit is contained in:
dave
2026-03-25 14:43:28 +00:00
parent 580ab1ce68
commit 077288e7b7
21 changed files with 344 additions and 326 deletions
+2 -177
View File
@@ -1,8 +1,9 @@
use crate::agents::AgentPool;
use crate::chat::ChatTransport;
use crate::chat::util::drain_complete_paragraphs;
use crate::http::context::{PermissionDecision, PermissionForward};
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use crate::slog;
use crate::chat::ChatTransport;
use matrix_sdk::{
Client,
config::SyncSettings,
@@ -1362,59 +1363,6 @@ pub fn markdown_to_html(markdown: &str) -> String {
html_output
}
// ---------------------------------------------------------------------------
// Paragraph buffering helper
// ---------------------------------------------------------------------------
/// Returns `true` when `text` ends while inside an open fenced code block.
///
/// A fenced code block opens and closes on lines that start with ` ``` `
/// (three or more backticks). We count the fence markers and return `true`
/// when the count is odd (a fence was opened but not yet closed).
fn is_inside_code_fence(text: &str) -> bool {
let mut in_fence = false;
for line in text.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
}
}
in_fence
}
/// Drain all complete paragraphs from `buffer` and return them.
///
/// A paragraph boundary is a double newline (`\n\n`). Each drained paragraph
/// is trimmed of surrounding whitespace; empty paragraphs are discarded.
/// The buffer is left with only the remaining incomplete text.
///
/// **Code-fence awareness:** a `\n\n` that occurs *inside* a fenced code
/// block (delimited by ` ``` ` lines) is **not** treated as a paragraph
/// boundary. This prevents a blank line inside a code block from splitting
/// the fence across multiple Matrix messages, which would corrupt the
/// rendering of the second half.
pub fn drain_complete_paragraphs(buffer: &mut String) -> Vec<String> {
let mut paragraphs = Vec::new();
let mut search_from = 0;
loop {
let Some(pos) = buffer[search_from..].find("\n\n") else {
break;
};
let abs_pos = search_from + pos;
// Only split at this boundary when we are NOT inside a code fence.
if is_inside_code_fence(&buffer[..abs_pos]) {
// Skip past this \n\n and keep looking for the next boundary.
search_from = abs_pos + 2;
} else {
let chunk = buffer[..abs_pos].trim().to_string();
*buffer = buffer[abs_pos + 2..].to_string();
search_from = 0;
if !chunk.is_empty() {
paragraphs.push(chunk);
}
}
}
paragraphs
}
// ---------------------------------------------------------------------------
// Tests
@@ -1623,129 +1571,6 @@ mod tests {
let _cloned = ctx.clone();
}
// -- drain_complete_paragraphs ------------------------------------------
#[test]
fn drain_complete_paragraphs_no_boundary_returns_empty() {
let mut buf = "Hello World".to_string();
let paras = drain_complete_paragraphs(&mut buf);
assert!(paras.is_empty());
assert_eq!(buf, "Hello World");
}
#[test]
fn drain_complete_paragraphs_single_boundary() {
let mut buf = "Paragraph one.\n\nParagraph two.".to_string();
let paras = drain_complete_paragraphs(&mut buf);
assert_eq!(paras, vec!["Paragraph one."]);
assert_eq!(buf, "Paragraph two.");
}
#[test]
fn drain_complete_paragraphs_multiple_boundaries() {
let mut buf = "A\n\nB\n\nC".to_string();
let paras = drain_complete_paragraphs(&mut buf);
assert_eq!(paras, vec!["A", "B"]);
assert_eq!(buf, "C");
}
#[test]
fn drain_complete_paragraphs_trailing_boundary() {
let mut buf = "A\n\nB\n\n".to_string();
let paras = drain_complete_paragraphs(&mut buf);
assert_eq!(paras, vec!["A", "B"]);
assert_eq!(buf, "");
}
#[test]
fn drain_complete_paragraphs_empty_input() {
let mut buf = String::new();
let paras = drain_complete_paragraphs(&mut buf);
assert!(paras.is_empty());
assert_eq!(buf, "");
}
#[test]
fn drain_complete_paragraphs_skips_empty_chunks() {
// Consecutive double-newlines produce no empty paragraphs.
let mut buf = "\n\n\n\nHello".to_string();
let paras = drain_complete_paragraphs(&mut buf);
assert!(paras.is_empty());
assert_eq!(buf, "Hello");
}
#[test]
fn drain_complete_paragraphs_trims_whitespace() {
let mut buf = " Hello \n\n World ".to_string();
let paras = drain_complete_paragraphs(&mut buf);
assert_eq!(paras, vec!["Hello"]);
assert_eq!(buf, " World ");
}
// -- drain_complete_paragraphs: code-fence awareness -------------------
#[test]
fn drain_complete_paragraphs_code_fence_blank_line_not_split() {
// A blank line inside a fenced code block must NOT trigger a split.
// Before the fix the function would split at the blank line and the
// second half would be sent without the opening fence, breaking rendering.
let mut buf =
"```rust\nfn foo() {\n let x = 1;\n\n let y = 2;\n}\n```\n\nNext paragraph."
.to_string();
let paras = drain_complete_paragraphs(&mut buf);
assert_eq!(
paras.len(),
1,
"code fence with blank line should not be split into multiple messages: {paras:?}"
);
assert!(
paras[0].starts_with("```rust"),
"first paragraph should be the code fence: {:?}",
paras[0]
);
assert!(
paras[0].contains("let y = 2;"),
"code fence should contain content from both sides of the blank line: {:?}",
paras[0]
);
assert_eq!(buf, "Next paragraph.");
}
#[test]
fn drain_complete_paragraphs_text_before_and_after_fenced_block() {
// Text paragraph, then a code block with an internal blank line, then more text.
let mut buf = "Before\n\n```\ncode\n\nmore code\n```\n\nAfter".to_string();
let paras = drain_complete_paragraphs(&mut buf);
assert_eq!(paras.len(), 2, "expected two paragraphs: {paras:?}");
assert_eq!(paras[0], "Before");
assert!(
paras[1].starts_with("```"),
"second paragraph should be the code fence: {:?}",
paras[1]
);
assert!(
paras[1].contains("more code"),
"code fence content must include the part after the blank line: {:?}",
paras[1]
);
assert_eq!(buf, "After");
}
#[test]
fn drain_complete_paragraphs_incremental_simulation() {
// Simulate tokens arriving one character at a time.
let mut buf = String::new();
let mut all_paragraphs = Vec::new();
for ch in "First para.\n\nSecond para.\n\nThird.".chars() {
buf.push(ch);
all_paragraphs.extend(drain_complete_paragraphs(&mut buf));
}
assert_eq!(all_paragraphs, vec!["First para.", "Second para."]);
assert_eq!(buf, "Third.");
}
// -- format_user_prompt -------------------------------------------------
#[test]
@@ -0,0 +1,7 @@
//! Re-exports from `crate::chat::commands`.
//!
//! The command dispatch infrastructure has moved to `crate::chat::commands` so
//! it can be shared by all transports. This module re-exports everything for
//! backwards compatibility with in-tree references.
pub use crate::chat::commands::*;
@@ -1,171 +0,0 @@
//! Handler for the `ambient` command.
use super::CommandContext;
use crate::chat::transport::matrix::config::save_ambient_rooms;
/// Toggle ambient mode for this room.
///
/// Works whether or not the message directly addressed the bot — the user can
/// say "timmy ambient on", "@timmy ambient on", or just "ambient on" in an
/// ambient-mode room. The command is specific enough (must be the first word
/// after any bot-mention prefix) that accidental triggering is very unlikely.
pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<String> {
let enable = match ctx.args {
"on" => true,
"off" => false,
_ => return Some("Usage: `ambient on` or `ambient off`".to_string()),
};
let room_ids: Vec<String> = {
let mut ambient = ctx.ambient_rooms.lock().unwrap();
if enable {
ambient.insert(ctx.room_id.to_string());
} else {
ambient.remove(ctx.room_id);
}
ambient.iter().cloned().collect()
};
save_ambient_rooms(ctx.project_root, &room_ids);
let msg = if enable {
"Ambient mode on. I'll respond to all messages in this room."
} else {
"Ambient mode off. I'll only respond when mentioned."
};
Some(msg.to_string())
}
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
// Bug 352: ambient commands were being forwarded to LLM after refactors
// 328/330 because handle_ambient required is_addressed=true, but
// mentions_bot() only matches @-prefixed mentions, not bare bot names.
// "timmy ambient off" sets is_addressed=false even though it names the bot.
#[test]
fn ambient_on_works_when_unaddressed() {
let ambient_rooms = test_ambient_rooms();
let room_id = "!myroom:example.com".to_string();
let agents = test_agents();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
// "timmy ambient on" — bot name mentioned but not @-prefixed, so
// is_addressed is false; strip_bot_mention still strips "timmy ".
let result = try_handle_command(&dispatch, "timmy ambient on");
assert!(result.is_some(), "ambient on should fire even when is_addressed=false");
assert!(
ambient_rooms.lock().unwrap().contains(&room_id),
"room should be in ambient_rooms after ambient on"
);
}
#[test]
fn ambient_off_works_bare_in_ambient_room() {
let ambient_rooms = test_ambient_rooms();
let room_id = "!myroom:example.com".to_string();
ambient_rooms.lock().unwrap().insert(room_id.clone());
let agents = test_agents();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
// Bare "ambient off" in an ambient room (is_addressed=false).
let result = try_handle_command(&dispatch, "ambient off");
assert!(result.is_some(), "bare ambient off should be handled without LLM");
let output = result.unwrap();
assert!(
output.contains("Ambient mode off"),
"response should confirm ambient off: {output}"
);
assert!(
!ambient_rooms.lock().unwrap().contains(&room_id),
"room should be removed from ambient_rooms after ambient off"
);
}
#[test]
fn ambient_on_enables_ambient_mode() {
let ambient_rooms = test_ambient_rooms();
let agents = test_agents();
let room_id = "!myroom:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy ambient on");
assert!(result.is_some(), "ambient on should produce a response");
let output = result.unwrap();
assert!(
output.contains("Ambient mode on"),
"response should confirm ambient on: {output}"
);
assert!(
ambient_rooms.lock().unwrap().contains(&room_id),
"room should be in ambient_rooms after ambient on"
);
}
#[test]
fn ambient_off_disables_ambient_mode() {
let ambient_rooms = test_ambient_rooms();
let agents = test_agents();
let room_id = "!myroom:example.com".to_string();
// Pre-insert the room
ambient_rooms.lock().unwrap().insert(room_id.clone());
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy ambient off");
assert!(result.is_some(), "ambient off should produce a response");
let output = result.unwrap();
assert!(
output.contains("Ambient mode off"),
"response should confirm ambient off: {output}"
);
assert!(
!ambient_rooms.lock().unwrap().contains(&room_id),
"room should be removed from ambient_rooms after ambient off"
);
}
#[test]
fn ambient_invalid_args_returns_usage() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient");
let output = result.unwrap();
assert!(
output.contains("Usage"),
"invalid ambient args should show usage: {output}"
);
}
}
@@ -1,57 +0,0 @@
//! Handler stub for the `assign` command.
//!
//! The real implementation lives in `crate::chat::transport::matrix::assign` (async). This
//! stub exists only so that `assign` appears in the help registry — the
//! handler always returns `None` so the bot's message loop falls through to
//! the async handler in `bot.rs`.
use super::CommandContext;
pub(super) fn handle_assign(_ctx: &CommandContext) -> Option<String> {
// Handled asynchronously in bot.rs / crate::chat::transport::matrix::assign.
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
// -- registration / help ------------------------------------------------
#[test]
fn assign_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "assign");
assert!(found, "assign command must be in the registry");
}
#[test]
fn assign_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("assign"),
"help should list assign command: {output}"
);
}
#[test]
fn assign_command_falls_through_to_none_in_registry() {
// The assign handler in the registry returns None (handled async in bot.rs).
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy assign 42 opus",
);
assert!(
result.is_none(),
"assign should not produce a sync response (handled async): {result:?}"
);
}
}
@@ -1,271 +0,0 @@
//! Handler for the `cost` command.
use std::collections::HashMap;
use super::status::story_short_label;
use super::CommandContext;
/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
/// all-time total.
pub(super) fn handle_cost(ctx: &CommandContext) -> Option<String> {
let records = match crate::agents::token_usage::read_all(ctx.project_root) {
Ok(r) => r,
Err(e) => return Some(format!("Failed to read token usage: {e}")),
};
if records.is_empty() {
return Some("**Token Spend**\n\nNo usage records found.".to_string());
}
let now = chrono::Utc::now();
let cutoff = now - chrono::Duration::hours(24);
// Partition into 24h window and all-time
let mut recent = Vec::new();
let mut all_time_cost = 0.0;
for r in &records {
all_time_cost += r.usage.total_cost_usd;
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&r.timestamp)
&& ts >= cutoff
{
recent.push(r);
}
}
// 24h total
let recent_cost: f64 = recent.iter().map(|r| r.usage.total_cost_usd).sum();
let mut out = String::from("**Token Spend**\n\n");
out.push_str(&format!("**Last 24h:** ${:.2}\n", recent_cost));
out.push_str(&format!("**All-time:** ${:.2}\n\n", all_time_cost));
// Top 5 most expensive stories (last 24h)
let mut story_costs: HashMap<&str, f64> = HashMap::new();
for r in &recent {
*story_costs.entry(r.story_id.as_str()).or_default() += r.usage.total_cost_usd;
}
let mut story_list: Vec<(&str, f64)> = story_costs.into_iter().collect();
story_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
story_list.truncate(5);
out.push_str("**Top Stories (24h)**\n");
if story_list.is_empty() {
out.push_str(" *(none)*\n");
} else {
for (story_id, cost) in &story_list {
let label = story_short_label(story_id, None);
out.push_str(&format!("{label} — ${cost:.2}\n"));
}
}
out.push('\n');
// Breakdown by agent type (last 24h)
// Agent names follow pattern "coder-1", "qa-1", "mergemaster" — extract
// the type as everything before the last '-' digit, or the full name.
let mut type_costs: HashMap<String, f64> = HashMap::new();
for r in &recent {
let agent_type = extract_agent_type(&r.agent_name);
*type_costs.entry(agent_type).or_default() += r.usage.total_cost_usd;
}
let mut type_list: Vec<(String, f64)> = type_costs.into_iter().collect();
type_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
out.push_str("**By Agent Type (24h)**\n");
if type_list.is_empty() {
out.push_str(" *(none)*\n");
} else {
for (agent_type, cost) in &type_list {
out.push_str(&format!("{agent_type} — ${cost:.2}\n"));
}
}
Some(out)
}
/// Extract the agent type from an agent name.
///
/// Agent names like "coder-1", "qa-2", "mergemaster" map to types "coder",
/// "qa", "mergemaster". If the name ends with `-<digits>`, strip the suffix.
pub(super) fn extract_agent_type(agent_name: &str) -> String {
if let Some(pos) = agent_name.rfind('-') {
let suffix = &agent_name[pos + 1..];
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
return agent_name[..pos].to_string();
}
}
agent_name.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::sync::Arc;
fn write_token_records(root: &std::path::Path, records: &[crate::agents::token_usage::TokenUsageRecord]) {
for r in records {
crate::agents::token_usage::append_record(root, r).unwrap();
}
}
fn make_usage(cost: f64) -> crate::agents::TokenUsage {
crate::agents::TokenUsage {
input_tokens: 100,
output_tokens: 200,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: cost,
}
}
fn make_record(story_id: &str, agent_name: &str, cost: f64, hours_ago: i64) -> crate::agents::token_usage::TokenUsageRecord {
let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339();
crate::agents::token_usage::TokenUsageRecord {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
timestamp: ts,
model: None,
usage: make_usage(cost),
}
}
fn cost_cmd_with_root(root: &std::path::Path) -> Option<String> {
use super::super::{CommandDispatch, try_handle_command};
use std::collections::HashSet;
use std::sync::Mutex;
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, "@timmy cost")
}
#[test]
fn cost_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "cost");
assert!(found, "cost command must be in the registry");
}
#[test]
fn cost_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("cost"), "help should list cost command: {output}");
}
#[test]
fn cost_command_no_records() {
let tmp = tempfile::TempDir::new().unwrap();
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("No usage records found"), "should show empty message: {output}");
}
#[test]
fn cost_command_shows_24h_total() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 1.50, 2),
make_record("42_story_foo", "coder-1", 0.50, 5),
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("**Last 24h:** $2.00"), "should show 24h total: {output}");
}
#[test]
fn cost_command_excludes_old_from_24h() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
make_record("43_story_bar", "coder-1", 5.00, 48), // older
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("**Last 24h:** $1.00"), "should only count recent: {output}");
assert!(output.contains("**All-time:** $6.00"), "all-time should include everything: {output}");
}
#[test]
fn cost_command_shows_top_stories() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 3.00, 1),
make_record("43_story_bar", "coder-1", 1.00, 1),
make_record("42_story_foo", "qa-1", 2.00, 1),
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("Top Stories"), "should have top stories section: {output}");
// Story 42 ($5.00) should appear before story 43 ($1.00)
let pos_42 = output.find("42").unwrap();
let pos_43 = output.find("43").unwrap();
assert!(pos_42 < pos_43, "story 42 should appear before 43 (sorted by cost): {output}");
}
#[test]
fn cost_command_limits_to_5_stories() {
let tmp = tempfile::TempDir::new().unwrap();
let mut records = Vec::new();
for i in 1..=7 {
records.push(make_record(&format!("{i}_story_s{i}"), "coder-1", i as f64, 1));
}
write_token_records(tmp.path(), &records);
let output = cost_cmd_with_root(tmp.path()).unwrap();
// The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded.
let top_section = output.split("**By Agent Type").next().unwrap();
assert!(!top_section.contains("• 1 —"), "story 1 should not be in top 5: {output}");
assert!(!top_section.contains("• 2 —"), "story 2 should not be in top 5: {output}");
}
#[test]
fn cost_command_shows_agent_type_breakdown() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 2.00, 1),
make_record("42_story_foo", "qa-1", 1.50, 1),
make_record("42_story_foo", "mergemaster", 0.50, 1),
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("By Agent Type"), "should have agent type section: {output}");
assert!(output.contains("coder"), "should show coder type: {output}");
assert!(output.contains("qa"), "should show qa type: {output}");
assert!(output.contains("mergemaster"), "should show mergemaster type: {output}");
}
#[test]
fn cost_command_shows_all_time_total() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 1.00, 2),
make_record("43_story_bar", "coder-1", 9.00, 100),
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("**All-time:** $10.00"), "should show all-time total: {output}");
}
#[test]
fn cost_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy COST");
assert!(result.is_some(), "COST should match case-insensitively");
}
// -- extract_agent_type -------------------------------------------------
#[test]
fn extract_agent_type_strips_numeric_suffix() {
assert_eq!(extract_agent_type("coder-1"), "coder");
assert_eq!(extract_agent_type("qa-2"), "qa");
}
#[test]
fn extract_agent_type_keeps_non_numeric_suffix() {
assert_eq!(extract_agent_type("mergemaster"), "mergemaster");
assert_eq!(extract_agent_type("coder-alpha"), "coder-alpha");
}
}
@@ -1,203 +0,0 @@
//! Handler for the `git` command.
use super::CommandContext;
/// Show compact git status: branch, uncommitted files, ahead/behind remote.
pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
use std::process::Command;
// Current branch
let branch = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(ctx.project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
// Porcelain status for staged + unstaged changes
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(ctx.project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let changed_files: Vec<&str> = status_output.lines().filter(|l| !l.is_empty()).collect();
let change_count = changed_files.len();
// Ahead/behind: --left-right gives "N\tM" (ahead\tbehind)
let ahead_behind = Command::new("git")
.args(["rev-list", "--count", "--left-right", "HEAD...@{u}"])
.current_dir(ctx.project_root)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
let mut parts = s.split_whitespace();
let ahead: u32 = parts.next()?.parse().ok()?;
let behind: u32 = parts.next()?.parse().ok()?;
Some((ahead, behind))
});
let mut out = format!("**Branch:** `{branch}`\n");
if change_count == 0 {
out.push_str("**Changes:** clean\n");
} else {
out.push_str(&format!("**Changes:** {change_count} file(s)\n"));
for line in &changed_files {
// Porcelain format: "XY filename" (2-char status + space + path)
if line.len() > 3 {
let codes = &line[..2];
let name = line[3..].trim();
out.push_str(&format!(" • `{codes}` {name}\n"));
} else {
out.push_str(&format!("{line}\n"));
}
}
}
match ahead_behind {
Some((0, 0)) => out.push_str("**Remote:** up to date\n"),
Some((ahead, 0)) => out.push_str(&format!("**Remote:** ↑{ahead} ahead\n")),
Some((0, behind)) => out.push_str(&format!("**Remote:** ↓{behind} behind\n")),
Some((ahead, behind)) => {
out.push_str(&format!("**Remote:** ↑{ahead} ahead, ↓{behind} behind\n"));
}
None => out.push_str("**Remote:** no tracking branch\n"),
}
Some(out)
}
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
#[test]
fn git_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "git");
assert!(found, "git command must be in the registry");
}
#[test]
fn git_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("git"), "help should list git command: {output}");
}
#[test]
fn git_command_returns_some() {
// Run from the actual repo root so git commands have a real repo to query.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy git");
assert!(result.is_some(), "git command should always return Some");
}
#[test]
fn git_command_output_contains_branch() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
assert!(
output.contains("**Branch:**"),
"git output should contain branch info: {output}"
);
}
#[test]
fn git_command_output_contains_changes() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
assert!(
output.contains("**Changes:**"),
"git output should contain changes section: {output}"
);
}
#[test]
fn git_command_output_contains_remote() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
assert!(
output.contains("**Remote:**"),
"git output should contain remote section: {output}"
);
}
#[test]
fn git_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT");
assert!(result.is_some(), "GIT should match case-insensitively");
}
}
@@ -1,113 +0,0 @@
//! Handler for the `help` command.
use super::{commands, CommandContext};
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
let mut sorted: Vec<_> = commands().iter().collect();
sorted.sort_by_key(|c| c.name);
for cmd in sorted {
output.push_str(&format!("- **{}** — {}\n", cmd.name, cmd.description));
}
Some(output)
}
#[cfg(test)]
mod tests {
use super::super::tests::{try_cmd_addressed, commands};
#[test]
fn help_command_matches() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
assert!(result.is_some(), "help command should match");
}
#[test]
fn help_command_case_insensitive() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy HELP");
assert!(result.is_some(), "HELP should match case-insensitively");
}
#[test]
fn help_output_contains_all_commands() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
for cmd in commands() {
assert!(
output.contains(cmd.name),
"help output must include command '{}'",
cmd.name
);
assert!(
output.contains(cmd.description),
"help output must include description for '{}'",
cmd.name
);
}
}
#[test]
fn help_output_uses_bot_name() {
let result = try_cmd_addressed("HAL", "@hal:example.com", "@hal help");
let output = result.unwrap();
assert!(
output.contains("HAL Commands"),
"help output should use bot name: {output}"
);
}
#[test]
fn help_output_formatted_as_markdown() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(
output.contains("**help**"),
"command name should be bold: {output}"
);
assert!(
output.contains("- **"),
"commands should be in a list: {output}"
);
}
#[test]
fn help_output_includes_status() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("status"), "help should list status command: {output}");
}
#[test]
fn help_output_is_alphabetical() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
// Search for **name** (bold markdown) to avoid substring matches in descriptions.
let mut positions: Vec<(usize, &str)> = commands()
.iter()
.map(|c| {
let marker = format!("**{}**", c.name);
let pos = output.find(&marker).expect("command must appear in help as **name**");
(pos, c.name)
})
.collect();
positions.sort_by_key(|(pos, _)| *pos);
let names_in_order: Vec<&str> = positions.iter().map(|(_, n)| *n).collect();
let mut sorted = names_in_order.clone();
sorted.sort();
assert_eq!(names_in_order, sorted, "commands must appear in alphabetical order");
}
#[test]
fn help_output_includes_ambient() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("ambient"), "help should list ambient command: {output}");
}
#[test]
fn help_output_includes_htop() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("htop"), "help should list htop command: {output}");
}
}
@@ -1,500 +0,0 @@
//! Bot-level command registry for the Matrix bot.
//!
//! Commands registered here are handled directly by the bot without invoking
//! the LLM. The registry is the single source of truth — the `help` command
//! iterates it automatically so new commands appear in the help output as soon
//! as they are added.
mod ambient;
mod assign;
mod cost;
mod git;
mod help;
mod move_story;
mod overview;
mod show;
mod status;
mod triage;
mod unreleased;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::path::Path;
use std::sync::{Arc, Mutex};
/// A bot-level command that is handled without LLM invocation.
pub struct BotCommand {
/// The command keyword (e.g., `"help"`). Always lowercase.
pub name: &'static str,
/// Short description shown in help output.
pub description: &'static str,
/// Handler that produces the response text (Markdown), or `None` to fall
/// through to the LLM (e.g. when a command requires direct addressing but
/// the message arrived via ambient mode).
pub handler: fn(&CommandContext) -> Option<String>,
}
/// Dispatch parameters passed to `try_handle_command`.
///
/// Groups all the caller-supplied context needed to dispatch and execute bot
/// commands. Construct one per incoming message and pass it alongside the raw
/// message body.
///
/// All identifiers are platform-agnostic strings so this struct works with
/// any [`ChatTransport`](crate::chat::ChatTransport) implementation.
pub struct CommandDispatch<'a> {
/// The bot's display name (e.g., "Timmy").
pub bot_name: &'a str,
/// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix).
pub bot_user_id: &'a str,
/// Project root directory (needed by status, ambient).
pub project_root: &'a Path,
/// Agent pool (needed by status).
pub agents: &'a AgentPool,
/// Set of room IDs with ambient mode enabled (needed by ambient).
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
}
/// Context passed to individual command handlers.
pub struct CommandContext<'a> {
/// The bot's display name (e.g., "Timmy").
pub bot_name: &'a str,
/// Any text after the command keyword, trimmed.
pub args: &'a str,
/// Project root directory (needed by status, ambient).
pub project_root: &'a Path,
/// Agent pool (needed by status).
pub agents: &'a AgentPool,
/// Set of room IDs with ambient mode enabled (needed by ambient).
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
}
/// Returns the full list of registered bot commands.
///
/// Add new commands here — they will automatically appear in `help` output.
pub fn commands() -> &'static [BotCommand] {
&[
BotCommand {
name: "assign",
description: "Pre-assign a model to a story: `assign <number> <model>` (e.g. `assign 42 opus`)",
handler: assign::handle_assign,
},
BotCommand {
name: "help",
description: "Show this list of available commands",
handler: help::handle_help,
},
BotCommand {
name: "status",
description: "Show pipeline status and agent availability; or `status <number>` for a story triage dump",
handler: status::handle_status,
},
BotCommand {
name: "ambient",
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
handler: ambient::handle_ambient,
},
BotCommand {
name: "git",
description: "Show git status: branch, uncommitted changes, and ahead/behind remote",
handler: git::handle_git,
},
BotCommand {
name: "htop",
description: "Show live system and agent process dashboard (`htop`, `htop 10m`, `htop stop`)",
handler: handle_htop_fallback,
},
BotCommand {
name: "cost",
description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total",
handler: cost::handle_cost,
},
BotCommand {
name: "move",
description: "Move a work item to a pipeline stage: `move <number> <stage>` (stages: backlog, current, qa, merge, done)",
handler: move_story::handle_move,
},
BotCommand {
name: "show",
description: "Display the full text of a work item: `show <number>`",
handler: show::handle_show,
},
BotCommand {
name: "overview",
description: "Show implementation summary for a merged story: `overview <number>`",
handler: overview::handle_overview,
},
BotCommand {
name: "start",
description: "Start a coder on a story: `start <number>` or `start <number> opus`",
handler: handle_start_fallback,
},
BotCommand {
name: "delete",
description: "Remove a work item from the pipeline: `delete <number>`",
handler: handle_delete_fallback,
},
BotCommand {
name: "rmtree",
description: "Delete the worktree for a story without removing it from the pipeline: `rmtree <number>`",
handler: handle_rmtree_fallback,
},
BotCommand {
name: "reset",
description: "Clear the current Claude Code session and start fresh",
handler: handle_reset_fallback,
},
BotCommand {
name: "rebuild",
description: "Rebuild the server binary and restart",
handler: handle_rebuild_fallback,
},
BotCommand {
name: "unreleased",
description: "Show stories merged to master since the last release tag",
handler: unreleased::handle_unreleased,
},
]
}
/// Try to match a user message against a registered bot command.
///
/// The message is expected to be the raw body text from Matrix (e.g.,
/// `"@timmy help"`). The bot mention prefix is stripped before matching.
///
/// Returns `Some(response)` if a command matched and was handled, `None`
/// otherwise (the caller should fall through to the LLM).
pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option<String> {
let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
let trimmed = command_text.trim();
if trimmed.is_empty() {
return None;
}
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
let cmd_lower = cmd_name.to_ascii_lowercase();
let ctx = CommandContext {
bot_name: dispatch.bot_name,
args,
project_root: dispatch.project_root,
agents: dispatch.agents,
ambient_rooms: dispatch.ambient_rooms,
room_id: dispatch.room_id,
};
commands()
.iter()
.find(|c| c.name == cmd_lower)
.and_then(|c| (c.handler)(&ctx))
}
/// Strip the bot mention prefix from a raw message body.
///
/// Handles these forms (case-insensitive where applicable):
/// - `@bot_localpart:server.com rest` → `rest`
/// - `@bot_localpart rest` → `rest`
/// - `DisplayName rest` → `rest`
fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
// Try @localpart (e.g. "@timmy")
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
// Try display name (e.g. "Timmy")
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
/// Case-insensitive prefix strip that also requires the match to end at a
/// word boundary (whitespace, punctuation, or end-of-string).
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
let candidate = text.get(..prefix.len())?;
if !candidate.eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
// Must be at end or followed by non-alphanumeric
match rest.chars().next() {
None => Some(rest), // exact match, empty remainder
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, // not a word boundary
_ => Some(rest),
}
}
/// Fallback handler for the `htop` command when it is not intercepted by the
/// async handler in `on_room_message`. In practice this is never called —
/// htop is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "htop" as a prompt.
fn handle_htop_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `start` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// start is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "start" as a prompt.
fn handle_start_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `rmtree` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// rmtree is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "rmtree" as a prompt.
fn handle_rmtree_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `delete` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// delete is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "delete" as a prompt.
fn handle_delete_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `reset` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// reset is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "reset" as a prompt.
fn handle_reset_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `rebuild` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// rebuild is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "rebuild" as a prompt.
fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::agents::AgentPool;
// -- test helpers (shared with submodule tests) -------------------------
pub fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
pub fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
pub fn try_cmd(
bot_name: &str,
bot_user_id: &str,
message: &str,
ambient_rooms: &Arc<Mutex<HashSet<String>>>,
) -> Option<String> {
let agents = test_agents();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name,
bot_user_id,
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, message)
}
pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option<String> {
try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms())
}
// Re-export commands() for submodule tests
pub use super::commands;
// -- strip_bot_mention --------------------------------------------------
#[test]
fn strip_mention_full_user_id() {
let rest = strip_bot_mention(
"@timmy:homeserver.local help",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(rest.trim(), "help");
}
#[test]
fn strip_mention_localpart() {
let rest = strip_bot_mention("@timmy help me", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim(), "help me");
}
#[test]
fn strip_mention_display_name() {
let rest = strip_bot_mention("Timmy help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim(), "help");
}
#[test]
fn strip_mention_display_name_case_insensitive() {
let rest = strip_bot_mention("timmy help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim(), "help");
}
#[test]
fn strip_mention_no_match_returns_original() {
let rest = strip_bot_mention("hello world", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest, "hello world");
}
#[test]
fn strip_mention_does_not_match_longer_name() {
// "@timmybot" should NOT match "@timmy"
let rest = strip_bot_mention("@timmybot help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest, "@timmybot help");
}
#[test]
fn strip_mention_comma_after_name() {
let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim().trim_start_matches(',').trim(), "help");
}
// -- try_handle_command -------------------------------------------------
#[test]
fn unknown_command_returns_none() {
let result = try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy what is the weather?",
);
assert!(result.is_none(), "non-command should return None");
}
#[test]
fn empty_message_after_mention_returns_none() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy");
assert!(
result.is_none(),
"bare mention with no command should fall through to LLM"
);
}
#[test]
fn htop_command_falls_through_to_none() {
// The htop handler returns None so the message is handled asynchronously
// in on_room_message, not here. try_handle_command must return None.
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy htop");
assert!(
result.is_none(),
"htop should not produce a sync response (handled async): {result:?}"
);
}
// -- strip_prefix_ci ----------------------------------------------------
#[test]
fn strip_prefix_ci_basic() {
assert_eq!(strip_prefix_ci("Hello world", "hello"), Some(" world"));
}
#[test]
fn strip_prefix_ci_no_match() {
assert_eq!(strip_prefix_ci("goodbye", "hello"), None);
}
#[test]
fn strip_prefix_ci_word_boundary_required() {
assert_eq!(strip_prefix_ci("helloworld", "hello"), None);
}
#[test]
fn strip_prefix_ci_exact_match() {
assert_eq!(strip_prefix_ci("hello", "hello"), Some(""));
}
#[test]
fn strip_prefix_ci_multibyte_no_panic_smart_quote() {
// "abcde\u{2019}xyz" — U+2019 is 3 bytes starting at byte 5.
// A prefix of length 6 (e.g. "abcdef") lands inside the 3-byte char.
// Previously this caused: "byte index 6 is not a char boundary".
let text = "abcde\u{2019}xyz";
assert_eq!(strip_prefix_ci(text, "abcdef"), None);
}
#[test]
fn strip_prefix_ci_multibyte_no_panic_emoji() {
// U+1F600 is 4 bytes starting at byte 3. Prefix length 4 lands inside it.
let text = "abc\u{1F600}def";
assert_eq!(strip_prefix_ci(text, "abcd"), None);
}
// -- commands registry --------------------------------------------------
#[test]
fn commands_registry_is_not_empty() {
assert!(
!commands().is_empty(),
"command registry must contain at least one command"
);
}
#[test]
fn all_command_names_are_lowercase() {
for cmd in commands() {
assert_eq!(
cmd.name,
cmd.name.to_ascii_lowercase(),
"command name '{}' must be lowercase",
cmd.name
);
}
}
#[test]
fn all_commands_have_descriptions() {
for cmd in commands() {
assert!(
!cmd.description.is_empty(),
"command '{}' must have a description",
cmd.name
);
}
}
}
@@ -1,296 +0,0 @@
//! Handler for the `move` command.
//!
//! `{bot_name} move {number} {stage}` finds the work item by number across all
//! pipeline stages, moves it to the specified stage, and returns a confirmation
//! with the story title, old stage, and new stage.
use super::CommandContext;
use crate::agents::move_story_to_stage;
/// Valid stage names accepted by the move command.
const VALID_STAGES: &[&str] = &["backlog", "current", "qa", "merge", "done"];
/// All pipeline stage directories to search when finding a work item by number.
const SEARCH_DIRS: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
/// Handle the `move` command.
///
/// Parses `<number> <stage>` from `ctx.args`, locates the work item by its
/// numeric prefix, moves it to the target stage using the shared lifecycle
/// function, and returns a Markdown confirmation string.
pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
let args = ctx.args.trim();
// Parse `number stage` from args.
let (num_str, stage_raw) = match args.split_once(char::is_whitespace) {
Some((n, s)) => (n.trim(), s.trim()),
None => {
return Some(format!(
"Usage: `{} move <number> <stage>`\n\nValid stages: {}",
ctx.bot_name,
VALID_STAGES.join(", ")
));
}
};
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} move <number> <stage>`",
ctx.bot_name
));
}
let target_stage = stage_raw.to_ascii_lowercase();
if !VALID_STAGES.contains(&target_stage.as_str()) {
return Some(format!(
"Invalid stage: `{stage_raw}`. Valid stages: {}",
VALID_STAGES.join(", ")
));
}
// Find the story file across all pipeline stages by numeric prefix.
let mut found_story_id: Option<String> = None;
let mut found_name: Option<String> = None;
'outer: for stage_dir in SEARCH_DIRS {
let dir = ctx
.project_root
.join(".storkit")
.join("work")
.join(stage_dir);
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 {
found_story_id = Some(stem.to_string());
found_name = std::fs::read_to_string(&path)
.ok()
.and_then(|contents| {
crate::io::story_metadata::parse_front_matter(&contents)
.ok()
.and_then(|m| m.name)
});
break 'outer;
}
}
}
}
}
let story_id = match found_story_id {
Some(id) => id,
None => {
return Some(format!(
"No story, bug, or spike with number **{num_str}** found."
));
}
};
let display_name = found_name.as_deref().unwrap_or(&story_id);
match move_story_to_stage(ctx.project_root, &story_id, &target_stage) {
Ok((from_stage, to_stage)) => Some(format!(
"Moved **{display_name}** from **{from_stage}** to **{to_stage}**."
)),
Err(e) => Some(format!("Failed to move story {num_str}: {e}")),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn move_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
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 move {args}"))
}
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
#[test]
fn move_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "move");
assert!(found, "move command must be in the registry");
}
#[test]
fn move_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("move"),
"help should list move command: {output}"
);
}
#[test]
fn move_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage hint: {output}"
);
}
#[test]
fn move_command_missing_stage_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "42").unwrap();
assert!(
output.contains("Usage"),
"missing stage should show usage hint: {output}"
);
}
#[test]
fn move_command_invalid_stage_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "42 invalid_stage").unwrap();
assert!(
output.contains("Invalid stage"),
"invalid stage should return error: {output}"
);
}
#[test]
fn move_command_non_numeric_number_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "abc current").unwrap();
assert!(
output.contains("Invalid story number"),
"non-numeric number should return error: {output}"
);
}
#[test]
fn move_command_not_found_returns_friendly_message() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "999 current").unwrap();
assert!(
output.contains("999") && output.contains("found"),
"not-found message should include number and 'found': {output}"
);
}
#[test]
fn move_command_moves_story_and_confirms() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"42_story_some_feature.md",
"---\nname: Some Feature\n---\n\n# Story 42\n",
);
let output = move_cmd_with_root(tmp.path(), "42 current").unwrap();
assert!(
output.contains("Some Feature"),
"confirmation should include story name: {output}"
);
assert!(
output.contains("backlog"),
"confirmation should include old stage: {output}"
);
assert!(
output.contains("current"),
"confirmation should include new stage: {output}"
);
// Verify the file was actually moved.
let new_path = tmp
.path()
.join(".storkit/work/2_current/42_story_some_feature.md");
assert!(new_path.exists(), "story file should be in 2_current/");
}
#[test]
fn move_command_case_insensitive_stage() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"10_story_test.md",
"---\nname: Test\n---\n",
);
let output = move_cmd_with_root(tmp.path(), "10 BACKLOG").unwrap();
assert!(
output.contains("Test") && output.contains("backlog"),
"stage matching should be case-insensitive: {output}"
);
}
#[test]
fn move_command_idempotent_when_already_in_target() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"5_story_already_current.md",
"---\nname: Already Current\n---\n",
);
// Moving to the stage it's already in should return a success message.
let output = move_cmd_with_root(tmp.path(), "5 current").unwrap();
assert!(
output.contains("Moved") || output.contains("current"),
"idempotent move should succeed: {output}"
);
}
#[test]
fn move_command_case_insensitive_command() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy MOVE 1 backlog",
);
// Returns Some (the registry matched, regardless of result content)
assert!(result.is_some(), "MOVE should match case-insensitively");
}
}
@@ -1,380 +0,0 @@
//! Handler for the `overview` command.
use super::CommandContext;
/// Show implementation summary for a story identified by its number.
///
/// Finds the `storkit: merge {story_id}` commit on master, displays the
/// git diff --stat (files changed with line counts), and extracts key
/// function/struct/type names added or modified in the implementation.
/// Returns a friendly message when no merge commit is found.
pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} overview <number>`\n\nShows the implementation summary for a story.",
ctx.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} overview <number>`",
ctx.bot_name
));
}
let commit_hash = match find_story_merge_commit(ctx.project_root, num_str) {
Some(h) => h,
None => {
return Some(format!(
"No implementation found for story **{num_str}**. \
It may still be in the backlog or was never merged."
));
}
};
let stat_output = get_commit_stat(ctx.project_root, &commit_hash);
let symbols = extract_diff_symbols(ctx.project_root, &commit_hash);
let story_name = find_story_name(ctx.project_root, num_str);
let short_hash = &commit_hash[..commit_hash.len().min(8)];
let mut out = match story_name {
Some(name) => format!("**Overview: Story {num_str}{name}**\n\n"),
None => format!("**Overview: Story {num_str}**\n\n"),
};
out.push_str(&format!("Commit: `{short_hash}`\n\n"));
// Parse stat output: collect per-file lines and the summary line.
let mut file_lines: Vec<String> = Vec::new();
let mut summary_line = String::new();
for line in stat_output.lines() {
if line.contains("changed") && (line.contains("insertion") || line.contains("deletion")) {
summary_line = line.trim().to_string();
} else if !line.trim().is_empty() && line.contains('|') {
file_lines.push(line.trim().to_string());
}
}
if !summary_line.is_empty() {
out.push_str(&format!("**Changes:** {summary_line}\n"));
}
if !file_lines.is_empty() {
out.push_str("**Files:**\n");
for f in file_lines.iter().take(8) {
out.push_str(&format!(" • `{f}`\n"));
}
if file_lines.len() > 8 {
out.push_str(&format!(" … and {} more\n", file_lines.len() - 8));
}
}
if !symbols.is_empty() {
out.push_str("\n**Key symbols:**\n");
for sym in &symbols {
out.push_str(&format!("{sym}\n"));
}
}
Some(out)
}
/// Find the merge commit hash for a story by its numeric ID.
///
/// Searches git log for a commit whose subject matches
/// `storkit: merge {num}_*` or the legacy `story-kit: merge {num}_*`.
fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<String> {
use std::process::Command;
// Match both the current prefix and the legacy one from before the rename.
let grep_pattern = format!("(storkit|story-kit): merge {num_str}_");
let output = Command::new("git")
.args([
"log",
"--format=%H",
"--all",
"--extended-regexp",
"--grep",
&grep_pattern,
])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())?;
let text = String::from_utf8_lossy(&output.stdout);
let hash = text.lines().next()?.trim().to_string();
if hash.is_empty() { None } else { Some(hash) }
}
/// Find the human-readable name of a story by searching all pipeline stages.
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
let stages = [
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in &stages {
let dir = root.join(".storkit").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 std::fs::read_to_string(&path).ok().and_then(|c| {
crate::io::story_metadata::parse_front_matter(&c)
.ok()
.and_then(|m| m.name)
});
}
}
}
}
}
None
}
/// Return the `git show --stat` output for a commit.
fn get_commit_stat(root: &std::path::Path, hash: &str) -> String {
use std::process::Command;
Command::new("git")
.args(["show", "--stat", hash])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
}
/// Extract up to 12 unique top-level symbol definitions from a commit diff.
///
/// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`,
/// and `impl` declarations and returns them formatted as `` `Name` (kind) ``.
fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec<String> {
use std::process::Command;
let output = Command::new("git")
.args(["show", hash])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let mut symbols: Vec<String> = Vec::new();
for line in output.lines() {
if !line.starts_with('+') || line.starts_with("+++") {
continue;
}
if let Some(sym) = parse_symbol_definition(&line[1..]) {
if !symbols.contains(&sym) {
symbols.push(sym);
}
if symbols.len() >= 12 {
break;
}
}
}
symbols
}
/// Parse a single line of code and return a formatted symbol if it opens a
/// top-level Rust definition (`fn`, `struct`, `enum`, `type`, `trait`, `impl`).
fn parse_symbol_definition(code: &str) -> Option<String> {
let t = code.trim();
let patterns: &[(&str, &str)] = &[
("pub async fn ", "fn"),
("async fn ", "fn"),
("pub fn ", "fn"),
("fn ", "fn"),
("pub struct ", "struct"),
("struct ", "struct"),
("pub enum ", "enum"),
("enum ", "enum"),
("pub type ", "type"),
("type ", "type"),
("pub trait ", "trait"),
("trait ", "trait"),
("impl ", "impl"),
];
for (prefix, kind) in patterns {
if let Some(rest) = t.strip_prefix(prefix) {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() {
return Some(format!("`{name}` ({kind})"));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
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 overview {args}"))
}
#[test]
fn overview_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "overview");
assert!(found, "overview command must be in the registry");
}
#[test]
fn overview_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("overview"),
"help should list overview command: {output}"
);
}
#[test]
fn overview_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = overview_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage hint: {output}"
);
}
#[test]
fn overview_command_non_numeric_arg_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = overview_cmd_with_root(tmp.path(), "abc").unwrap();
assert!(
output.contains("Invalid"),
"non-numeric arg should return error: {output}"
);
}
#[test]
fn overview_command_not_found_returns_friendly_message() {
// Use the real repo root but a story number that was never merged.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let output = overview_cmd_with_root(repo_root, "99999").unwrap();
assert!(
output.contains("99999"),
"not-found message should include the story number: {output}"
);
assert!(
output.contains("backlog") || output.contains("No implementation"),
"not-found message should explain why: {output}"
);
}
#[test]
fn overview_command_found_shows_commit_and_stat() {
// Story 324 has a real merge commit in master.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let output = overview_cmd_with_root(repo_root, "324").unwrap();
assert!(
output.contains("**Overview: Story 324"),
"output should show story header: {output}"
);
assert!(
output.contains("Commit:"),
"output should show commit hash: {output}"
);
assert!(
output.contains("**Changes:**") || output.contains("**Files:**"),
"output should show file changes: {output}"
);
}
#[test]
fn overview_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy OVERVIEW 1",
);
assert!(result.is_some(), "OVERVIEW should match case-insensitively");
}
// -- parse_symbol_definition --------------------------------------------
#[test]
fn parse_symbol_pub_fn() {
let result =
parse_symbol_definition("pub fn handle_foo(ctx: &Context) -> Option<String> {");
assert_eq!(result, Some("`handle_foo` (fn)".to_string()));
}
#[test]
fn parse_symbol_pub_struct() {
let result = parse_symbol_definition("pub struct SlackTransport {");
assert_eq!(result, Some("`SlackTransport` (struct)".to_string()));
}
#[test]
fn parse_symbol_impl() {
let result = parse_symbol_definition("impl ChatTransport for SlackTransport {");
assert_eq!(result, Some("`ChatTransport` (impl)".to_string()));
}
#[test]
fn parse_symbol_no_match() {
let result = parse_symbol_definition(" let x = 42;");
assert_eq!(result, None);
}
#[test]
fn parse_symbol_pub_enum() {
let result = parse_symbol_definition("pub enum QaMode {");
assert_eq!(result, Some("`QaMode` (enum)".to_string()));
}
#[test]
fn parse_symbol_pub_type() {
let result = parse_symbol_definition(
"pub type SlackHistory = Arc<Mutex<HashMap<String, Vec<u8>>>>;",
);
assert_eq!(result, Some("`SlackHistory` (type)".to_string()));
}
}
@@ -1,201 +0,0 @@
//! Handler for the `show` command.
use super::CommandContext;
/// 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.
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.",
ctx.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} show <number>`",
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(".storkit")
.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."
))
}
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
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 show {args}"))
}
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
#[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() {
let result = super::super::tests::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 = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1");
assert!(result.is_some(), "SHOW should match case-insensitively");
}
}
@@ -1,402 +0,0 @@
//! Handler for the `status` command and pipeline status helpers.
use crate::agents::{AgentPool, AgentStatus};
use crate::config::ProjectConfig;
use std::collections::{HashMap, HashSet};
use super::CommandContext;
pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
if ctx.args.trim().is_empty() {
Some(build_pipeline_status(ctx.project_root, ctx.agents))
} else {
super::triage::handle_triage(ctx)
}
}
/// Format a short display label for a work item.
///
/// Extracts the leading numeric ID and optional type tag from the file stem
/// (e.g. `"293"` and `"story"` from `"293_story_register_all_bot_commands"`)
/// and combines them with the human-readable name from the front matter when
/// available. Known types (`story`, `bug`, `spike`, `refactor`) are shown as
/// bracketed labels; unknown or missing types are omitted silently.
///
/// Examples:
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 [story] — Register all bot commands"`
/// - `("375_bug_foo", None)` → `"375 [bug]"`
/// - `("293_story_foo", None)` → `"293 [story]"`
/// - `("no_number_here", None)` → `"no_number_here"`
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
let mut parts = stem.splitn(3, '_');
let first = parts.next().unwrap_or(stem);
let (number, type_label) = if !first.is_empty() && first.chars().all(|c| c.is_ascii_digit()) {
let t = parts.next().and_then(|t| match t {
"story" | "bug" | "spike" | "refactor" => Some(t),
_ => None,
});
(first, t)
} else {
(stem, None)
};
let prefix = match type_label {
Some(t) => format!("{number} [{t}]"),
None => number.to_string(),
};
match name {
Some(n) => format!("{prefix}{n}"),
None => prefix,
}
}
/// Read all story IDs and names from a pipeline stage directory.
fn read_stage_items(
project_root: &std::path::Path,
stage_dir: &str,
) -> Vec<(String, Option<String>)> {
let dir = project_root
.join(".storkit")
.join("work")
.join(stage_dir);
if !dir.exists() {
return Vec::new();
}
let mut items = Vec::new();
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 name = std::fs::read_to_string(&path)
.ok()
.and_then(|contents| {
crate::io::story_metadata::parse_front_matter(&contents)
.ok()
.and_then(|m| m.name)
});
items.push((stem.to_string(), name));
}
}
}
items.sort_by(|a, b| a.0.cmp(&b.0));
items
}
/// Build the full pipeline status text formatted for Matrix (markdown).
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
// Build a map from story_id → active AgentInfo for quick lookup.
let active_agents = agents.list_agents().unwrap_or_default();
let active_map: HashMap<String, &crate::agents::AgentInfo> = active_agents
.iter()
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
.map(|a| (a.story_id.clone(), a))
.collect();
// Read token usage once for all stories to avoid repeated file I/O.
let cost_by_story: HashMap<String, f64> =
crate::agents::token_usage::read_all(project_root)
.unwrap_or_default()
.into_iter()
.fold(HashMap::new(), |mut map, r| {
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
map
});
let config = ProjectConfig::load(project_root).ok();
let mut out = String::from("**Pipeline Status**\n\n");
let stages = [
("1_backlog", "Backlog"),
("2_current", "In Progress"),
("3_qa", "QA"),
("4_merge", "Merge"),
("5_done", "Done"),
];
for (dir, label) in &stages {
let items = read_stage_items(project_root, dir);
let count = items.len();
out.push_str(&format!("**{label}** ({count})\n"));
if items.is_empty() {
out.push_str(" *(none)*\n");
} else {
for (story_id, name) in &items {
let display = story_short_label(story_id, name.as_deref());
let cost_suffix = cost_by_story
.get(story_id)
.filter(|&&c| c > 0.0)
.map(|c| format!(" — ${c:.2}"))
.unwrap_or_default();
if let Some(agent) = active_map.get(story_id) {
let model_str = config
.as_ref()
.and_then(|cfg| cfg.find_agent(&agent.agent_name))
.and_then(|ac| ac.model.as_deref())
.unwrap_or("?");
out.push_str(&format!(
" • {display}{cost_suffix} — {} ({model_str})\n",
agent.agent_name
));
} else {
out.push_str(&format!("{display}{cost_suffix}\n"));
}
}
}
out.push('\n');
}
// Free agents: configured agents not currently running or pending.
out.push_str("**Free Agents**\n");
if let Some(cfg) = &config {
let busy_names: HashSet<String> = active_agents
.iter()
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
.map(|a| a.agent_name.clone())
.collect();
let free: Vec<String> = cfg
.agent
.iter()
.filter(|a| !busy_names.contains(&a.name))
.map(|a| match &a.model {
Some(m) => format!("{} ({})", a.name, m),
None => a.name.clone(),
})
.collect();
if free.is_empty() {
out.push_str(" *(none — all agents busy)*\n");
} else {
for name in &free {
out.push_str(&format!("{name}\n"));
}
}
} else {
out.push_str(" *(no agent config found)*\n");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
#[test]
fn status_command_matches() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
assert!(result.is_some(), "status command should match");
}
#[test]
fn status_command_returns_pipeline_text() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
let output = result.unwrap();
assert!(
output.contains("Pipeline Status"),
"status output should contain pipeline info: {output}"
);
}
#[test]
fn status_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS");
assert!(result.is_some(), "STATUS should match case-insensitively");
}
// -- story_short_label --------------------------------------------------
#[test]
fn short_label_extracts_number_and_name() {
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
assert_eq!(label, "293 [story] — Register all bot commands");
}
#[test]
fn short_label_number_only_when_no_name() {
let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
assert_eq!(label, "297 [story]");
}
#[test]
fn short_label_falls_back_to_stem_when_no_numeric_prefix() {
let label = story_short_label("no_number_here", None);
assert_eq!(label, "no_number_here");
}
#[test]
fn short_label_does_not_include_underscore_slug() {
let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands"));
assert!(
!label.contains("story_register"),
"label should not contain the slug portion: {label}"
);
}
#[test]
fn short_label_shows_bug_type() {
let label = story_short_label("375_bug_default_project_toml", Some("Default project.toml issue"));
assert_eq!(label, "375 [bug] — Default project.toml issue");
}
#[test]
fn short_label_shows_spike_type() {
let label = story_short_label("61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture"));
assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
}
#[test]
fn short_label_shows_refactor_type() {
let label = story_short_label("260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys"));
assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
}
#[test]
fn short_label_omits_unknown_type() {
let label = story_short_label("42_task_do_something", Some("Do something"));
assert_eq!(label, "42 — Do something");
}
#[test]
fn short_label_no_type_when_only_id() {
// Stem with only a numeric ID and no type segment
let label = story_short_label("42", Some("Some item"));
assert_eq!(label, "42 — Some item");
}
// -- build_pipeline_status formatting -----------------------------------
#[test]
fn status_does_not_show_full_filename_stem() {
use std::io::Write;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let stage_dir = tmp.path().join(".storkit/work/2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
// Write a story file with a front-matter name
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
let mut f = std::fs::File::create(&story_path).unwrap();
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
let agents = AgentPool::new_test(3000);
let output = build_pipeline_status(tmp.path(), &agents);
assert!(
!output.contains("293_story_register_all_bot_commands"),
"output must not show full filename stem: {output}"
);
assert!(
output.contains("293 [story] — Register all bot commands"),
"output must show number, type, and title: {output}"
);
}
// -- token cost in status output ----------------------------------------
#[test]
fn status_shows_cost_when_token_usage_exists() {
use std::io::Write;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let stage_dir = tmp.path().join(".storkit/work/2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
let mut f = std::fs::File::create(&story_path).unwrap();
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
// Write token usage for this story.
let usage = crate::agents::TokenUsage {
input_tokens: 100,
output_tokens: 200,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: 0.29,
};
let record = crate::agents::token_usage::build_record(
"293_story_register_all_bot_commands",
"coder-1",
None,
usage,
);
crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
let agents = AgentPool::new_test(3000);
let output = build_pipeline_status(tmp.path(), &agents);
assert!(
output.contains("293 [story] — Register all bot commands — $0.29"),
"output must show cost next to story: {output}"
);
}
#[test]
fn status_no_cost_when_no_usage() {
use std::io::Write;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let stage_dir = tmp.path().join(".storkit/work/2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
let mut f = std::fs::File::create(&story_path).unwrap();
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
// No token usage written.
let agents = AgentPool::new_test(3000);
let output = build_pipeline_status(tmp.path(), &agents);
assert!(
!output.contains("$"),
"output must not show cost when no usage exists: {output}"
);
}
#[test]
fn status_aggregates_multiple_records_per_story() {
use std::io::Write;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let stage_dir = tmp.path().join(".storkit/work/2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
let mut f = std::fs::File::create(&story_path).unwrap();
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
// Write two records for the same story — costs should be summed.
for cost in [0.10, 0.19] {
let usage = crate::agents::TokenUsage {
input_tokens: 50,
output_tokens: 100,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: cost,
};
let record = crate::agents::token_usage::build_record(
"293_story_register_all_bot_commands",
"coder-1",
None,
usage,
);
crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
}
let agents = AgentPool::new_test(3000);
let output = build_pipeline_status(tmp.path(), &agents);
assert!(
output.contains("293 [story] — Register all bot commands — $0.29"),
"output must show aggregated cost: {output}"
);
}
}
@@ -1,548 +0,0 @@
//! Handler for the story triage dump subcommand of `status`.
//!
//! Produces a triage dump for a story that is currently in-progress
//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state,
//! git diff, recent commits, and the tail of the agent log.
//!
//! The command is handled entirely at the bot level — no LLM invocation.
use super::CommandContext;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Handle `{bot_name} status {number}`.
pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} status <number>`\n\nShows a triage dump for a story currently in progress.",
ctx.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} status <number>`",
ctx.bot_name
));
}
let current_dir = ctx
.project_root
.join(".storkit")
.join("work")
.join("2_current");
match find_story_in_dir(&current_dir, num_str) {
Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)),
None => Some(format!(
"Story **{num_str}** is not currently in progress (not found in `work/2_current/`)."
)),
}
}
/// Find a `.md` file whose numeric prefix matches `num_str` in `dir`.
///
/// Returns `(path, file_stem)` for the first match.
fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> {
let entries = std::fs::read_dir(dir).ok()?;
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 Some((path.clone(), stem.to_string()));
}
}
}
None
}
/// Build the full triage dump for a story.
fn build_triage_dump(
ctx: &CommandContext,
story_path: &Path,
story_id: &str,
num_str: &str,
) -> String {
let contents = match std::fs::read_to_string(story_path) {
Ok(c) => c,
Err(e) => return format!("Failed to read story {num_str}: {e}"),
};
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)");
let mut out = String::new();
// ---- Header ----
out.push_str(&format!("## Story {num_str}{name}\n"));
out.push_str("**Stage:** In Progress (`2_current`)\n\n");
// ---- Front matter fields ----
if let Some(ref m) = meta {
let mut fields: Vec<String> = Vec::new();
if let Some(true) = m.blocked {
fields.push("**blocked:** true".to_string());
}
if let Some(ref agent) = m.agent {
fields.push(format!("**agent:** {agent}"));
}
if let Some(ref qa) = m.qa {
fields.push(format!("**qa:** {qa}"));
}
if let Some(true) = m.review_hold {
fields.push("**review_hold:** true".to_string());
}
if let Some(rc) = m.retry_count
&& rc > 0
{
fields.push(format!("**retry_count:** {rc}"));
}
if let Some(ref cb) = m.coverage_baseline {
fields.push(format!("**coverage_baseline:** {cb}"));
}
if let Some(ref mf) = m.merge_failure {
fields.push(format!("**merge_failure:** {mf}"));
}
if !fields.is_empty() {
out.push_str("**Front matter:**\n");
for f in &fields {
out.push_str(&format!("{f}\n"));
}
out.push('\n');
}
}
// ---- Acceptance criteria ----
let criteria = parse_acceptance_criteria(&contents);
if !criteria.is_empty() {
out.push_str("**Acceptance Criteria:**\n");
for (checked, text) in &criteria {
let mark = if *checked { "" } else { "" };
out.push_str(&format!(" {mark} {text}\n"));
}
let total = criteria.len();
let done = criteria.iter().filter(|(c, _)| *c).count();
out.push_str(&format!(" *{done}/{total} complete*\n"));
out.push('\n');
}
// ---- Worktree and branch ----
let wt_path = crate::worktree::worktree_path(ctx.project_root, story_id);
let branch = format!("feature/story-{story_id}");
if wt_path.is_dir() {
out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display()));
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
// ---- git diff --stat ----
let diff_stat = run_git(
&wt_path,
&["diff", "--stat", "master...HEAD"],
);
if !diff_stat.is_empty() {
out.push_str("**Diff stat (vs master):**\n```\n");
out.push_str(&diff_stat);
out.push_str("```\n\n");
} else {
out.push_str("**Diff stat (vs master):** *(no changes)*\n\n");
}
// ---- Last 5 commits on feature branch ----
let log = run_git(
&wt_path,
&[
"log",
"master..HEAD",
"--pretty=format:%h %s",
"-5",
],
);
if !log.is_empty() {
out.push_str("**Recent commits (branch only):**\n```\n");
out.push_str(&log);
out.push_str("\n```\n\n");
} else {
out.push_str("**Recent commits (branch only):** *(none yet)*\n\n");
}
} else {
out.push_str(&format!("**Branch:** `{branch}`\n"));
out.push_str("**Worktree:** *(not yet created)*\n\n");
}
// ---- Agent log tail ----
let log_dir = ctx
.project_root
.join(".storkit")
.join("logs")
.join(story_id);
match latest_log_file(&log_dir) {
Some(log_path) => {
let tail = read_log_tail(&log_path, 20);
let filename = log_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("agent.log");
if tail.is_empty() {
out.push_str(&format!("**Agent log** (`{filename}`):** *(empty)*\n"));
} else {
out.push_str(&format!("**Agent log tail** (`{filename}`):\n```\n"));
out.push_str(&tail);
out.push_str("\n```\n");
}
}
None => {
out.push_str("**Agent log:** *(no log found)*\n");
}
}
out
}
/// Parse acceptance criteria from story markdown.
///
/// Returns a list of `(checked, text)` for every `- [ ] ...` and `- [x] ...` line.
fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
contents
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
Some((true, text.to_string()))
} else {
trimmed.strip_prefix("- [ ] ").map(|text| (false, text.to_string()))
}
})
.collect()
}
/// Run a git command in the given directory, returning trimmed stdout (or empty on error).
fn run_git(dir: &Path, args: &[&str]) -> String {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
/// Find the most recently modified `.log` file in the given directory,
/// regardless of agent name.
fn latest_log_file(log_dir: &Path) -> Option<PathBuf> {
if !log_dir.is_dir() {
return None;
}
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
for entry in std::fs::read_dir(log_dir).ok()?.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("log") {
continue;
}
let modified = match entry.metadata().and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => continue,
};
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
best = Some((path, modified));
}
}
best.map(|(p, _)| p)
}
/// Read the last `n` non-empty lines from a file as a single string.
fn read_log_tail(path: &Path, n: usize) -> String {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
};
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn status_triage_cmd(root: &Path, args: &str) -> Option<String> {
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 status {args}"))
}
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
// -- registration -------------------------------------------------------
#[test]
fn whatsup_command_is_not_registered() {
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
assert!(!found, "whatsup command must not be in the registry (renamed to status)");
}
#[test]
fn status_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("status"),
"help should list status command: {output}"
);
}
// -- input validation ---------------------------------------------------
#[test]
fn whatsup_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = status_triage_cmd(tmp.path(), "").unwrap();
assert!(
output.contains("Pipeline Status"),
"no args should show pipeline status: {output}"
);
}
#[test]
fn whatsup_non_numeric_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = status_triage_cmd(tmp.path(), "abc").unwrap();
assert!(
output.contains("Invalid"),
"non-numeric arg should return error: {output}"
);
}
// -- not found ----------------------------------------------------------
#[test]
fn whatsup_story_not_in_current_returns_friendly_message() {
let tmp = tempfile::TempDir::new().unwrap();
// Create the directory but put the story in backlog, not current
write_story_file(
tmp.path(),
"1_backlog",
"42_story_not_in_current.md",
"---\nname: Not in current\n---\n",
);
let output = status_triage_cmd(tmp.path(), "42").unwrap();
assert!(
output.contains("42"),
"message should include story number: {output}"
);
assert!(
output.contains("not") || output.contains("Not"),
"message should say not found/in progress: {output}"
);
}
// -- found in 2_current -------------------------------------------------
#[test]
fn whatsup_shows_story_name_and_stage() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"99_story_my_feature.md",
"---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
);
let output = status_triage_cmd(tmp.path(), "99").unwrap();
assert!(output.contains("99"), "should show story number: {output}");
assert!(
output.contains("My Feature"),
"should show story name: {output}"
);
assert!(
output.contains("In Progress") || output.contains("2_current"),
"should show pipeline stage: {output}"
);
}
#[test]
fn whatsup_shows_acceptance_criteria() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"99_story_criteria_test.md",
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
);
let output = status_triage_cmd(tmp.path(), "99").unwrap();
assert!(
output.contains("First thing"),
"should show unchecked criterion: {output}"
);
assert!(
output.contains("Done thing"),
"should show checked criterion: {output}"
);
// 1 of 3 done
assert!(
output.contains("1/3"),
"should show checked/total count: {output}"
);
}
#[test]
fn whatsup_shows_blocked_field() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"55_story_blocked_story.md",
"---\nname: Blocked Story\nblocked: true\n---\n",
);
let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!(
output.contains("blocked"),
"should show blocked field: {output}"
);
}
#[test]
fn whatsup_shows_agent_field() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"55_story_agent_story.md",
"---\nname: Agent Story\nagent: coder-1\n---\n",
);
let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!(
output.contains("coder-1"),
"should show agent field: {output}"
);
}
#[test]
fn whatsup_no_worktree_shows_not_created() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"77_story_no_worktree.md",
"---\nname: No Worktree\n---\n",
);
let output = status_triage_cmd(tmp.path(), "77").unwrap();
// Branch name should still appear
assert!(
output.contains("feature/story-77"),
"should show branch name: {output}"
);
}
#[test]
fn whatsup_no_log_shows_no_log_message() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"77_story_no_log.md",
"---\nname: No Log\n---\n",
);
let output = status_triage_cmd(tmp.path(), "77").unwrap();
assert!(
output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"),
"should indicate no log exists: {output}"
);
}
// -- parse_acceptance_criteria ------------------------------------------
#[test]
fn parse_criteria_mixed() {
let input = "## AC\n- [ ] First\n- [x] Done\n- [X] Also done\n- [ ] Last\n";
let result = parse_acceptance_criteria(input);
assert_eq!(result.len(), 4);
assert_eq!(result[0], (false, "First".to_string()));
assert_eq!(result[1], (true, "Done".to_string()));
assert_eq!(result[2], (true, "Also done".to_string()));
assert_eq!(result[3], (false, "Last".to_string()));
}
#[test]
fn parse_criteria_empty() {
let input = "# Story\nNo checkboxes here.\n";
let result = parse_acceptance_criteria(input);
assert!(result.is_empty());
}
// -- read_log_tail -------------------------------------------------------
#[test]
fn read_log_tail_returns_last_n_lines() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("test.log");
let content = (1..=30).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
std::fs::write(&path, &content).unwrap();
let tail = read_log_tail(&path, 5);
let lines: Vec<&str> = tail.lines().collect();
assert_eq!(lines.len(), 5);
assert_eq!(lines[0], "line 26");
assert_eq!(lines[4], "line 30");
}
#[test]
fn read_log_tail_fewer_lines_than_n() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("short.log");
std::fs::write(&path, "line A\nline B\n").unwrap();
let tail = read_log_tail(&path, 20);
assert!(tail.contains("line A"));
assert!(tail.contains("line B"));
}
// -- latest_log_file ----------------------------------------------------
#[test]
fn latest_log_file_returns_none_for_missing_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let result = latest_log_file(&tmp.path().join("nonexistent"));
assert!(result.is_none());
}
#[test]
fn latest_log_file_finds_log() {
let tmp = tempfile::TempDir::new().unwrap();
let log_path = tmp.path().join("coder-1-sess-abc.log");
std::fs::write(&log_path, "some log content\n").unwrap();
let result = latest_log_file(tmp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), log_path);
}
}
@@ -1,308 +0,0 @@
//! Handler for the `unreleased` command.
//!
//! Shows a list of stories merged to master since the last release tag.
use super::CommandContext;
/// Show stories merged since the last release tag.
///
/// Finds the most recent git tag, then lists all story merge commits between
/// that tag and HEAD on master. Each entry shows the story number and name.
/// Returns a clear message when there are no unreleased stories or no tags.
pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
let root = ctx.project_root;
let tag = find_last_release_tag(root);
let commits = list_merge_commits_since(root, tag.as_deref());
if commits.is_empty() {
let msg = match &tag {
Some(t) => format!(
"No unreleased stories since the last release tag **{t}**."
),
None => "No release tags found and no story merge commits on master.".to_string(),
};
return Some(msg);
}
let mut stories: Vec<(u64, String)> = commits
.iter()
.filter_map(|subject| parse_story_from_subject(subject))
.collect();
// Sort by story number, deduplicate.
stories.sort_by_key(|(n, _)| *n);
stories.dedup_by_key(|(n, _)| *n);
if stories.is_empty() {
let msg = match &tag {
Some(t) => format!(
"No unreleased stories since the last release tag **{t}**."
),
None => "No release tags found and no story merge commits on master.".to_string(),
};
return Some(msg);
}
// Look up human-readable names for each story.
let mut out = match &tag {
Some(t) => format!("**Unreleased stories since {t}:**\n\n"),
None => "**Unreleased stories (no prior release tag):**\n\n".to_string(),
};
for (num, slug) in &stories {
let name = find_story_name(root, &num.to_string())
.unwrap_or_else(|| slug_to_name(slug));
out.push_str(&format!("- **{num}** — {name}\n"));
}
Some(out)
}
// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------
/// Return the most recent release tag, or `None` if there are no tags.
///
/// Uses `git tag --sort=-creatordate` to get the newest tag first.
fn find_last_release_tag(root: &std::path::Path) -> Option<String> {
use std::process::Command;
let output = Command::new("git")
.args(["tag", "--sort=-creatordate"])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())?;
let text = String::from_utf8_lossy(&output.stdout);
let tag = text.lines().next()?.trim().to_string();
if tag.is_empty() { None } else { Some(tag) }
}
/// Return the subjects of all `storkit: merge …` commits reachable from HEAD
/// but not from `since_tag` (or all commits when `since_tag` is `None`).
fn list_merge_commits_since(
root: &std::path::Path,
since_tag: Option<&str>,
) -> Vec<String> {
use std::process::Command;
let range = match since_tag {
Some(tag) => format!("{tag}..HEAD"),
None => "HEAD".to_string(),
};
let output = Command::new("git")
.args([
"log",
&range,
"--format=%s",
"--extended-regexp",
"--grep",
"(storkit|story-kit): merge [0-9]+_",
])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success());
match output {
Some(o) => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect(),
None => Vec::new(),
}
}
/// Parse a story number and slug from a merge commit subject like
/// `storkit: merge 386_story_unreleased_command`.
///
/// Returns `(story_number, slug_remainder)` or `None` if the subject doesn't
/// match the expected pattern.
fn parse_story_from_subject(subject: &str) -> Option<(u64, String)> {
// Match "storkit: merge NNN_rest" or "story-kit: merge NNN_rest"
let rest = subject
.strip_prefix("storkit: merge ")
.or_else(|| subject.strip_prefix("story-kit: merge "))?;
let (num_str, slug) = rest.split_once('_')?;
let num: u64 = num_str.parse().ok()?;
Some((num, slug.to_string()))
}
/// Convert an underscore-separated slug to a title-case name.
///
/// Used as a fallback when no pipeline file is found.
fn slug_to_name(slug: &str) -> String {
let words: Vec<String> = slug
.split('_')
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect();
words.join(" ")
}
/// Find the human-readable name of a story by searching all pipeline stages.
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
const STAGES: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in STAGES {
let dir = root.join(".storkit").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 std::fs::read_to_string(&path).ok().and_then(|c| {
crate::io::story_metadata::parse_front_matter(&c)
.ok()
.and_then(|m| m.name)
});
}
}
}
}
}
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn unreleased_cmd_with_root(root: &std::path::Path) -> Option<String> {
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, "@timmy unreleased")
}
#[test]
fn unreleased_command_is_registered() {
let found = super::super::commands().iter().any(|c| c.name == "unreleased");
assert!(found, "unreleased command must be in the registry");
}
#[test]
fn unreleased_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("unreleased"),
"help should list unreleased command: {output}"
);
}
#[test]
fn unreleased_command_no_tags_returns_message() {
// A temp dir that is not a git repo — git commands will fail gracefully.
let tmp = tempfile::TempDir::new().unwrap();
let output = unreleased_cmd_with_root(tmp.path()).unwrap();
// Should return some message (not panic), either about no tags or no commits.
assert!(!output.is_empty(), "should return a non-empty message: {output}");
}
#[test]
fn unreleased_command_real_repo_returns_response() {
// Run against the actual repo root to exercise the git path.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let output = unreleased_cmd_with_root(repo_root).unwrap();
// The response should mention "unreleased" or "no unreleased" — just make
// sure it's non-empty and doesn't panic.
assert!(!output.is_empty(), "should return a non-empty message: {output}");
}
#[test]
fn unreleased_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy UNRELEASED",
);
assert!(result.is_some(), "UNRELEASED should match case-insensitively");
}
// -- parse_story_from_subject ------------------------------------------
#[test]
fn parse_story_storkit_prefix() {
let result = parse_story_from_subject("storkit: merge 386_story_unreleased_command");
assert_eq!(result, Some((386, "story_unreleased_command".to_string())));
}
#[test]
fn parse_story_legacy_prefix() {
let result = parse_story_from_subject("story-kit: merge 42_story_add_feature");
assert_eq!(result, Some((42, "story_add_feature".to_string())));
}
#[test]
fn parse_story_no_match() {
let result = parse_story_from_subject("fix: typo in README");
assert_eq!(result, None);
}
#[test]
fn parse_story_no_underscore_after_number() {
let result = parse_story_from_subject("storkit: merge 123");
assert_eq!(result, None);
}
// -- slug_to_name --------------------------------------------------
#[test]
fn slug_to_name_basic() {
assert_eq!(slug_to_name("story_add_feature"), "Story Add Feature");
}
#[test]
fn slug_to_name_single_word() {
assert_eq!(slug_to_name("feature"), "Feature");
}
}
+2 -2
View File
@@ -18,7 +18,7 @@
pub mod assign;
mod bot;
pub mod commands;
mod config;
pub(crate) mod config;
pub mod delete;
pub mod htop;
pub mod rebuild;
@@ -28,7 +28,7 @@ pub mod start;
pub mod notifications;
pub mod transport_impl;
pub use bot::{ConversationEntry, ConversationRole, RoomConversation, drain_complete_paragraphs};
pub use bot::{ConversationEntry, ConversationRole, RoomConversation};
pub use config::BotConfig;
use crate::agents::AgentPool;
+3 -3
View File
@@ -356,14 +356,14 @@ mod tests {
#[test]
fn start_command_is_registered() {
use crate::chat::transport::matrix::commands::commands;
use crate::chat::commands::commands;
let found = commands().iter().any(|c| c.name == "start");
assert!(found, "start command must be in the registry");
}
#[test]
fn start_command_appears_in_help() {
let result = crate::chat::transport::matrix::commands::tests::try_cmd_addressed(
let result = crate::chat::commands::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
@@ -378,7 +378,7 @@ mod tests {
#[test]
fn start_command_falls_through_to_none_in_registry() {
// The start handler in the registry returns None (handled async in bot.rs).
let result = crate::chat::transport::matrix::commands::tests::try_cmd_addressed(
let result = crate::chat::commands::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy start 42",