2026-03-20 07:26:44 +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.
|
|
|
|
|
|
|
|
|
|
mod ambient;
|
2026-03-20 18:49:20 +00:00
|
|
|
mod assign;
|
2026-03-20 07:26:44 +00:00
|
|
|
mod cost;
|
|
|
|
|
mod git;
|
|
|
|
|
mod help;
|
2026-03-20 08:36:51 +00:00
|
|
|
mod move_story;
|
2026-03-20 07:26:44 +00:00
|
|
|
mod overview;
|
|
|
|
|
mod show;
|
|
|
|
|
mod status;
|
|
|
|
|
|
|
|
|
|
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::transport::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] {
|
|
|
|
|
&[
|
2026-03-20 18:49:20 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "assign",
|
|
|
|
|
description: "Pre-assign a model to a story: `assign <number> <model>` (e.g. `assign 42 opus`)",
|
|
|
|
|
handler: assign::handle_assign,
|
|
|
|
|
},
|
2026-03-20 07:26:44 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "help",
|
|
|
|
|
description: "Show this list of available commands",
|
|
|
|
|
handler: help::handle_help,
|
|
|
|
|
},
|
|
|
|
|
BotCommand {
|
|
|
|
|
name: "status",
|
|
|
|
|
description: "Show pipeline status and agent availability",
|
|
|
|
|
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,
|
|
|
|
|
},
|
2026-03-20 08:36:51 +00:00
|
|
|
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,
|
|
|
|
|
},
|
2026-03-20 07:26:44 +00:00
|
|
|
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,
|
|
|
|
|
},
|
2026-03-20 08:27:07 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "start",
|
|
|
|
|
description: "Start a coder on a story: `start <number>` or `start <number> opus`",
|
|
|
|
|
handler: handle_start_fallback,
|
|
|
|
|
},
|
2026-03-20 07:26:44 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "delete",
|
|
|
|
|
description: "Remove a work item from the pipeline: `delete <number>`",
|
|
|
|
|
handler: handle_delete_fallback,
|
|
|
|
|
},
|
2026-03-20 11:13:52 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "reset",
|
|
|
|
|
description: "Clear the current Claude Code session and start fresh",
|
|
|
|
|
handler: handle_reset_fallback,
|
|
|
|
|
},
|
2026-03-20 15:27:36 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "rebuild",
|
|
|
|
|
description: "Rebuild the server binary and restart",
|
|
|
|
|
handler: handle_rebuild_fallback,
|
|
|
|
|
},
|
2026-03-20 07:26:44 +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.
|
|
|
|
|
///
|
|
|
|
|
/// 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> {
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 08:27:07 +00:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 07:26:44 +00:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 11:13:52 +00:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 15:27:36 +00:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 07:26:44 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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> {
|
2026-03-20 11:16:48 +00:00
|
|
|
try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms())
|
2026-03-20 07:26:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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(""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|