2026-03-18 14:53:47 +00:00
|
|
|
//! 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.
|
|
|
|
|
|
2026-03-19 09:14:04 +00:00
|
|
|
use crate::agents::{AgentPool, AgentStatus};
|
|
|
|
|
use crate::config::ProjectConfig;
|
|
|
|
|
use matrix_sdk::ruma::OwnedRoomId;
|
|
|
|
|
use std::collections::HashSet;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
|
|
|
|
|
use super::config::save_ambient_rooms;
|
|
|
|
|
|
2026-03-18 14:53:47 +00:00
|
|
|
/// 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,
|
2026-03-19 09:14:04 +00:00
|
|
|
/// 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.
|
|
|
|
|
pub struct CommandDispatch<'a> {
|
|
|
|
|
/// The bot's display name (e.g., "Timmy").
|
|
|
|
|
pub bot_name: &'a str,
|
|
|
|
|
/// The bot's full Matrix user ID (e.g., `"@timmy:homeserver.local"`).
|
|
|
|
|
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 rooms with ambient mode enabled (needed by ambient).
|
|
|
|
|
pub ambient_rooms: &'a Arc<Mutex<HashSet<OwnedRoomId>>>,
|
|
|
|
|
/// The room this message came from (needed by ambient).
|
|
|
|
|
pub room_id: &'a OwnedRoomId,
|
|
|
|
|
/// Whether the message directly addressed the bot (mention/reply).
|
|
|
|
|
/// Some commands (e.g. ambient) only operate when directly addressed.
|
|
|
|
|
pub is_addressed: bool,
|
2026-03-18 14:53:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 09:14:04 +00:00
|
|
|
/// Context passed to individual command handlers.
|
2026-03-18 14:53:47 +00:00
|
|
|
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,
|
2026-03-19 09:14:04 +00:00
|
|
|
/// Project root directory (needed by status, ambient).
|
|
|
|
|
pub project_root: &'a Path,
|
|
|
|
|
/// Agent pool (needed by status).
|
|
|
|
|
pub agents: &'a AgentPool,
|
|
|
|
|
/// Set of rooms with ambient mode enabled (needed by ambient).
|
|
|
|
|
pub ambient_rooms: &'a Arc<Mutex<HashSet<OwnedRoomId>>>,
|
|
|
|
|
/// The room this message came from (needed by ambient).
|
|
|
|
|
pub room_id: &'a OwnedRoomId,
|
|
|
|
|
/// Whether the message directly addressed the bot (mention/reply).
|
|
|
|
|
/// Some commands (e.g. ambient) only operate when directly addressed.
|
|
|
|
|
pub is_addressed: bool,
|
2026-03-18 14:53:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the full list of registered bot commands.
|
|
|
|
|
///
|
|
|
|
|
/// Add new commands here — they will automatically appear in `help` output.
|
|
|
|
|
pub fn commands() -> &'static [BotCommand] {
|
2026-03-19 09:14:04 +00:00
|
|
|
&[
|
|
|
|
|
BotCommand {
|
|
|
|
|
name: "help",
|
|
|
|
|
description: "Show this list of available commands",
|
|
|
|
|
handler: handle_help,
|
|
|
|
|
},
|
|
|
|
|
BotCommand {
|
|
|
|
|
name: "status",
|
|
|
|
|
description: "Show pipeline status and agent availability",
|
|
|
|
|
handler: handle_status,
|
|
|
|
|
},
|
|
|
|
|
BotCommand {
|
|
|
|
|
name: "ambient",
|
|
|
|
|
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
|
|
|
|
|
handler: handle_ambient,
|
|
|
|
|
},
|
2026-03-19 10:09:32 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "git",
|
|
|
|
|
description: "Show git status: branch, uncommitted changes, and ahead/behind remote",
|
|
|
|
|
handler: handle_git,
|
|
|
|
|
},
|
2026-03-19 09:14:04 +00:00
|
|
|
]
|
2026-03-18 14:53:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
|
|
///
|
2026-03-19 09:14:04 +00:00
|
|
|
/// 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);
|
2026-03-18 14:53:47 +00:00
|
|
|
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 {
|
2026-03-19 09:14:04 +00:00
|
|
|
bot_name: dispatch.bot_name,
|
2026-03-18 14:53:47 +00:00
|
|
|
args,
|
2026-03-19 09:14:04 +00:00
|
|
|
project_root: dispatch.project_root,
|
|
|
|
|
agents: dispatch.agents,
|
|
|
|
|
ambient_rooms: dispatch.ambient_rooms,
|
|
|
|
|
room_id: dispatch.room_id,
|
|
|
|
|
is_addressed: dispatch.is_addressed,
|
2026-03-18 14:53:47 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
commands()
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|c| c.name == cmd_lower)
|
2026-03-19 09:14:04 +00:00
|
|
|
.and_then(|c| (c.handler)(&ctx))
|
2026-03-18 14:53:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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> {
|
|
|
|
|
if text.len() < prefix.len() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
if !text[..prefix.len()].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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 09:14:04 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Pipeline status helpers (moved from bot.rs)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-03-19 09:36:58 +00:00
|
|
|
/// Format a short display label for a work item.
|
|
|
|
|
///
|
|
|
|
|
/// Extracts the leading numeric ID from the file stem (e.g. `"293"` from
|
|
|
|
|
/// `"293_story_register_all_bot_commands"`) and combines it with the human-
|
|
|
|
|
/// readable name from the front matter when available.
|
|
|
|
|
///
|
|
|
|
|
/// Examples:
|
|
|
|
|
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 — Register all bot commands"`
|
|
|
|
|
/// - `("293_story_foo", None)` → `"293"`
|
|
|
|
|
/// - `("no_number_here", None)` → `"no_number_here"`
|
|
|
|
|
fn story_short_label(stem: &str, name: Option<&str>) -> String {
|
|
|
|
|
let number = stem
|
|
|
|
|
.split('_')
|
|
|
|
|
.next()
|
|
|
|
|
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
|
|
|
|
.unwrap_or(stem);
|
|
|
|
|
match name {
|
|
|
|
|
Some(n) => format!("{number} — {n}"),
|
|
|
|
|
None => number.to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 09:14:04 +00:00
|
|
|
/// 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(".story_kit")
|
|
|
|
|
.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 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: std::collections::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();
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-03-19 09:36:58 +00:00
|
|
|
let display = story_short_label(story_id, name.as_deref());
|
2026-03-19 09:14:04 +00:00
|
|
|
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!(
|
2026-03-19 09:36:58 +00:00
|
|
|
" • {display} — {} ({model_str})\n",
|
|
|
|
|
agent.agent_name
|
2026-03-19 09:14:04 +00:00
|
|
|
));
|
|
|
|
|
} else {
|
|
|
|
|
out.push_str(&format!(" • {display}\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: std::collections::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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:53:47 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Built-in command handlers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-03-19 09:14:04 +00:00
|
|
|
fn handle_help(ctx: &CommandContext) -> Option<String> {
|
2026-03-18 14:53:47 +00:00
|
|
|
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
|
|
|
|
|
for cmd in commands() {
|
|
|
|
|
output.push_str(&format!("- **{}** — {}\n", cmd.name, cmd.description));
|
|
|
|
|
}
|
2026-03-19 09:14:04 +00:00
|
|
|
Some(output)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_status(ctx: &CommandContext) -> Option<String> {
|
|
|
|
|
Some(build_pipeline_status(ctx.project_root, ctx.agents))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Toggle ambient mode for this room.
|
|
|
|
|
///
|
|
|
|
|
/// Only acts when the message directly addressed the bot (`is_addressed=true`)
|
|
|
|
|
/// to prevent accidental toggling via ambient-mode traffic.
|
|
|
|
|
fn handle_ambient(ctx: &CommandContext) -> Option<String> {
|
|
|
|
|
if !ctx.is_addressed {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
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.clone());
|
|
|
|
|
} else {
|
|
|
|
|
ambient.remove(ctx.room_id);
|
|
|
|
|
}
|
|
|
|
|
ambient.iter().map(|r| r.to_string()).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())
|
2026-03-18 14:53:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 10:09:32 +00:00
|
|
|
/// Show compact git status: branch, uncommitted files, ahead/behind remote.
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:53:47 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-03-19 09:14:04 +00:00
|
|
|
use crate::agents::AgentPool;
|
|
|
|
|
|
|
|
|
|
// -- test helpers -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn make_room_id(s: &str) -> OwnedRoomId {
|
|
|
|
|
s.parse().unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn test_ambient_rooms() -> Arc<Mutex<HashSet<OwnedRoomId>>> {
|
|
|
|
|
Arc::new(Mutex::new(HashSet::new()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn test_agents() -> Arc<AgentPool> {
|
|
|
|
|
Arc::new(AgentPool::new_test(3000))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn try_cmd(
|
|
|
|
|
bot_name: &str,
|
|
|
|
|
bot_user_id: &str,
|
|
|
|
|
message: &str,
|
|
|
|
|
ambient_rooms: &Arc<Mutex<HashSet<OwnedRoomId>>>,
|
|
|
|
|
is_addressed: bool,
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
let agents = test_agents();
|
|
|
|
|
let room_id = make_room_id("!test:example.com");
|
|
|
|
|
let dispatch = CommandDispatch {
|
|
|
|
|
bot_name,
|
|
|
|
|
bot_user_id,
|
|
|
|
|
project_root: std::path::Path::new("/tmp"),
|
|
|
|
|
agents: &agents,
|
|
|
|
|
ambient_rooms,
|
|
|
|
|
room_id: &room_id,
|
|
|
|
|
is_addressed,
|
|
|
|
|
};
|
|
|
|
|
try_handle_command(&dispatch, message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(), true)
|
|
|
|
|
}
|
2026-03-18 14:53:47 +00:00
|
|
|
|
|
|
|
|
// -- 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 help_command_matches() {
|
2026-03-19 09:14:04 +00:00
|
|
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
2026-03-18 14:53:47 +00:00
|
|
|
assert!(result.is_some(), "help command should match");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn help_command_case_insensitive() {
|
2026-03-19 09:14:04 +00:00
|
|
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy HELP");
|
2026-03-18 14:53:47 +00:00
|
|
|
assert!(result.is_some(), "HELP should match case-insensitively");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unknown_command_returns_none() {
|
2026-03-19 09:14:04 +00:00
|
|
|
let result = try_cmd_addressed(
|
2026-03-18 14:53:47 +00:00
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:homeserver.local",
|
|
|
|
|
"@timmy what is the weather?",
|
|
|
|
|
);
|
|
|
|
|
assert!(result.is_none(), "non-command should return None");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn help_output_contains_all_commands() {
|
2026-03-19 09:14:04 +00:00
|
|
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
2026-03-18 14:53:47 +00:00
|
|
|
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() {
|
2026-03-19 09:14:04 +00:00
|
|
|
let result = try_cmd_addressed("HAL", "@hal:example.com", "@hal help");
|
2026-03-18 14:53:47 +00:00
|
|
|
let output = result.unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("HAL Commands"),
|
|
|
|
|
"help output should use bot name: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn help_output_formatted_as_markdown() {
|
2026-03-19 09:14:04 +00:00
|
|
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
2026-03-18 14:53:47 +00:00
|
|
|
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 empty_message_after_mention_returns_none() {
|
2026-03-19 09:14:04 +00:00
|
|
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy");
|
2026-03-18 14:53:47 +00:00
|
|
|
assert!(
|
|
|
|
|
result.is_none(),
|
|
|
|
|
"bare mention with no command should fall through to LLM"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 09:14:04 +00:00
|
|
|
// -- status command -----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn status_command_matches() {
|
|
|
|
|
let result = 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 = 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 = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS");
|
|
|
|
|
assert!(result.is_some(), "STATUS should match case-insensitively");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- ambient command ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ambient_on_requires_addressed() {
|
|
|
|
|
let ambient_rooms = test_ambient_rooms();
|
|
|
|
|
let room_id = make_room_id("!myroom:example.com");
|
|
|
|
|
let result = try_cmd(
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:homeserver.local",
|
|
|
|
|
"@timmy ambient on",
|
|
|
|
|
&ambient_rooms,
|
|
|
|
|
false, // not addressed
|
|
|
|
|
);
|
|
|
|
|
// Should fall through to LLM when not addressed
|
|
|
|
|
assert!(result.is_none(), "ambient should not fire in non-addressed mode");
|
|
|
|
|
assert!(
|
|
|
|
|
!ambient_rooms.lock().unwrap().contains(&room_id),
|
|
|
|
|
"ambient_rooms should not be modified when not addressed"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ambient_on_enables_ambient_mode() {
|
|
|
|
|
let ambient_rooms = test_ambient_rooms();
|
|
|
|
|
let agents = test_agents();
|
|
|
|
|
let room_id = make_room_id("!myroom:example.com");
|
|
|
|
|
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,
|
|
|
|
|
is_addressed: true,
|
|
|
|
|
};
|
|
|
|
|
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 = make_room_id("!myroom:example.com");
|
|
|
|
|
// 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,
|
|
|
|
|
is_addressed: true,
|
|
|
|
|
};
|
|
|
|
|
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 = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient");
|
|
|
|
|
let output = result.unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Usage"),
|
|
|
|
|
"invalid ambient args should show usage: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- help lists status and ambient --------------------------------------
|
|
|
|
|
|
|
|
|
|
#[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_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}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:53:47 +00:00
|
|
|
// -- 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(""));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 09:36:58 +00:00
|
|
|
// -- 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 — 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");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- 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(".story_kit/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 — Register all bot commands"),
|
|
|
|
|
"output must show number and title: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:53:47 +00:00
|
|
|
// -- 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-19 10:09:32 +00:00
|
|
|
|
|
|
|
|
// -- git command --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn git_command_is_registered() {
|
|
|
|
|
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 = 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 = make_room_id("!test:example.com");
|
|
|
|
|
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,
|
|
|
|
|
is_addressed: true,
|
|
|
|
|
};
|
|
|
|
|
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 = make_room_id("!test:example.com");
|
|
|
|
|
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,
|
|
|
|
|
is_addressed: true,
|
|
|
|
|
};
|
|
|
|
|
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 = make_room_id("!test:example.com");
|
|
|
|
|
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,
|
|
|
|
|
is_addressed: true,
|
|
|
|
|
};
|
|
|
|
|
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 = make_room_id("!test:example.com");
|
|
|
|
|
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,
|
|
|
|
|
is_addressed: true,
|
|
|
|
|
};
|
|
|
|
|
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 = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT");
|
|
|
|
|
assert!(result.is_some(), "GIT should match case-insensitively");
|
|
|
|
|
}
|
2026-03-18 14:53:47 +00:00
|
|
|
}
|