diff --git a/server/src/http/bot_command.rs b/server/src/http/bot_command.rs index 3e7f7e8c..d9004b32 100644 --- a/server/src/http/bot_command.rs +++ b/server/src/http/bot_command.rs @@ -3,19 +3,16 @@ //! `POST /api/bot/command` lets the web UI invoke the same deterministic bot //! commands available in Matrix without going through the LLM. //! -//! Synchronous commands (status, git, cost, move, show, overview, help) are -//! dispatched directly through the matrix command registry. -//! Asynchronous commands (assign, start, delete, rebuild) are dispatched to -//! their dedicated async handlers. The `reset` command is handled by the frontend -//! (it clears local session state and message history) and is not routed here. +//! Dispatches to [`crate::service::bot_command::execute`], which owns all +//! parsing and routing logic. This handler is a thin OpenAPI adapter: it +//! receives JSON, calls the service, and maps typed errors to HTTP status codes. -use crate::chat::commands::CommandDispatch; use crate::http::context::{AppContext, OpenApiResult}; +use crate::service::bot_command as svc; use poem::http::StatusCode; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; #[derive(Tags)] enum BotCommandTags { @@ -50,6 +47,11 @@ impl BotCommandApi { /// Dispatches to the same handlers used by the Matrix and Slack bots. /// Returns a markdown-formatted response that the frontend can display /// directly in the chat panel. + /// + /// # Errors + /// - `400 Bad Request` — project root not set, or invalid command arguments. + /// - `404 Not Found` — unrecognised command keyword. + /// - `500 Internal Server Error` — command execution failed. #[oai(path = "/bot/command", method = "post")] async fn run_command( &self, @@ -63,221 +65,23 @@ impl BotCommandApi { let cmd = body.command.trim().to_ascii_lowercase(); let args = body.args.trim(); - let response = dispatch_command(&cmd, args, &project_root, &self.ctx.agents).await; + + let response = svc::execute(&cmd, args, &project_root, &self.ctx.agents) + .await + .map_err(|e| match e { + svc::Error::UnknownCommand(msg) => { + poem::Error::from_string(msg, StatusCode::NOT_FOUND) + } + svc::Error::BadArgs(msg) => poem::Error::from_string(msg, StatusCode::BAD_REQUEST), + svc::Error::CommandFailed(msg) => { + poem::Error::from_string(msg, StatusCode::INTERNAL_SERVER_ERROR) + } + })?; Ok(Json(BotCommandResponse { response })) } } -/// Dispatch a command keyword + args to the appropriate handler. -async fn dispatch_command( - cmd: &str, - args: &str, - project_root: &std::path::Path, - agents: &Arc, -) -> String { - match cmd { - "assign" => dispatch_assign(args, project_root, agents).await, - "start" => dispatch_start(args, project_root, agents).await, - "delete" => dispatch_delete(args, project_root, agents).await, - "rebuild" => dispatch_rebuild(project_root, agents).await, - "rmtree" => dispatch_rmtree(args, project_root, agents).await, - "timer" => dispatch_timer(args, project_root).await, - "htop" => dispatch_htop(args, agents).await, - // All other commands go through the synchronous command registry. - _ => dispatch_sync(cmd, args, project_root, agents), - } -} - -fn dispatch_sync( - cmd: &str, - args: &str, - project_root: &std::path::Path, - agents: &Arc, -) -> String { - let ambient_rooms: Arc>> = Arc::new(Mutex::new(HashSet::new())); - // Use a synthetic bot name/id so strip_bot_mention passes through. - let bot_name = "__web_ui__"; - let bot_user_id = "@__web_ui__:localhost"; - let room_id = "__web_ui__"; - - let dispatch = CommandDispatch { - bot_name, - bot_user_id, - project_root, - agents, - ambient_rooms: &ambient_rooms, - room_id, - }; - - // Build a synthetic message that the registry can parse. - let synthetic = if args.is_empty() { - format!("{bot_name} {cmd}") - } else { - format!("{bot_name} {cmd} {args}") - }; - - match crate::chat::commands::try_handle_command(&dispatch, &synthetic) { - Some(response) => response, - None => { - // Command exists in the registry but its fallback handler returns None - // (start, delete, rebuild, reset, htop — handled elsewhere or in - // the frontend). Should not be reached for those since we intercept - // them above. For genuinely unknown commands, tell the user. - format!("Unknown command: `/{cmd}`. Type `/help` to see available commands.") - } - } -} - -async fn dispatch_assign( - args: &str, - project_root: &std::path::Path, - agents: &Arc, -) -> String { - // args: " " - let mut parts = args.splitn(2, char::is_whitespace); - let number_str = parts.next().unwrap_or("").trim(); - let model_str = parts.next().unwrap_or("").trim(); - - if number_str.is_empty() - || !number_str.chars().all(|c| c.is_ascii_digit()) - || model_str.is_empty() - { - return "Usage: `/assign ` (e.g. `/assign 42 opus`)".to_string(); - } - - crate::chat::transport::matrix::assign::handle_assign( - "web-ui", - number_str, - model_str, - project_root, - agents, - ) - .await -} - -async fn dispatch_start( - args: &str, - project_root: &std::path::Path, - agents: &Arc, -) -> String { - // args: "" or " " - let mut parts = args.splitn(2, char::is_whitespace); - let number_str = parts.next().unwrap_or("").trim(); - let hint_str = parts.next().unwrap_or("").trim(); - - if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) { - return "Usage: `/start ` or `/start ` (e.g. `/start 42 opus`)" - .to_string(); - } - - let agent_hint = if hint_str.is_empty() { - None - } else { - Some(hint_str) - }; - - crate::chat::transport::matrix::start::handle_start( - "web-ui", - number_str, - agent_hint, - project_root, - agents, - ) - .await -} - -async fn dispatch_delete( - args: &str, - project_root: &std::path::Path, - agents: &Arc, -) -> String { - let number_str = args.trim(); - if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) { - return "Usage: `/delete ` (e.g. `/delete 42`)".to_string(); - } - crate::chat::transport::matrix::delete::handle_delete( - "web-ui", - number_str, - project_root, - agents, - ) - .await -} - -async fn dispatch_rmtree( - args: &str, - project_root: &std::path::Path, - agents: &Arc, -) -> String { - let number_str = args.trim(); - if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) { - return "Usage: `/rmtree ` (e.g. `/rmtree 42`)".to_string(); - } - crate::chat::transport::matrix::rmtree::handle_rmtree( - "web-ui", - number_str, - project_root, - agents, - ) - .await -} - -async fn dispatch_rebuild( - project_root: &std::path::Path, - agents: &Arc, -) -> String { - crate::chat::transport::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await -} - -async fn dispatch_timer(args: &str, project_root: &std::path::Path) -> String { - // Re-use the existing parser by constructing a synthetic message that - // looks like a bot-addressed timer command. - let synthetic = format!("__web_ui__ timer {args}"); - let timer_cmd = match crate::chat::timer::extract_timer_command( - &synthetic, - "__web_ui__", - "@__web_ui__:localhost", - ) { - Some(cmd) => cmd, - None => { - return "Usage: `/timer list`, `/timer `, or `/timer cancel `" - .to_string(); - } - }; - let store = - crate::chat::timer::TimerStore::load(project_root.join(".huskies").join("timers.json")); - crate::chat::timer::handle_timer_command(timer_cmd, &store, project_root).await -} - -/// Handle the `htop` command from the web UI. -/// -/// The web UI uses a one-shot HTTP request, so live updates are not possible -/// here. Returns a static snapshot of the process dashboard. For `htop stop`, -/// returns a helpful message (no persistent session state exists in the web UI). -async fn dispatch_htop(args: &str, agents: &Arc) -> String { - use crate::chat::transport::matrix::htop::{HtopCommand, build_htop_message}; - - // Re-use the existing parser by constructing a synthetic message. - let synthetic = if args.is_empty() { - "__web_ui__ htop".to_string() - } else { - format!("__web_ui__ htop {args}") - }; - - match crate::chat::transport::matrix::htop::extract_htop_command( - &synthetic, - "__web_ui__", - "@__web_ui__:localhost", - ) { - Some(HtopCommand::Stop) => "No active htop session in the web UI. \ - Live sessions are only supported in chat transports (Matrix, Slack, Discord)." - .to_string(), - Some(HtopCommand::Start { duration_secs }) => build_htop_message(agents, 0, duration_secs), - None => build_htop_message(agents, 0, 300), - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -316,13 +120,7 @@ mod tests { args: String::new(), }; let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; - assert!( - resp.response.contains("Unknown command"), - "expected 'Unknown command' in: {}", - resp.response - ); + assert!(result.is_err(), "unknown command should return HTTP 404"); } #[tokio::test] @@ -334,13 +132,7 @@ mod tests { args: String::new(), }; let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; - assert!( - resp.response.contains("Usage"), - "expected usage hint in: {}", - resp.response - ); + assert!(result.is_err(), "start with no args should return HTTP 400"); } #[tokio::test] @@ -352,12 +144,9 @@ mod tests { args: String::new(), }; let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; assert!( - resp.response.contains("Usage"), - "expected usage hint in: {}", - resp.response + result.is_err(), + "delete with no args should return HTTP 400" ); } @@ -388,7 +177,11 @@ mod tests { args: "list".to_string(), }; let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); + assert!( + result.is_ok(), + "timer list should succeed, got err: {:?}", + result.err().map(|e| e.to_string()) + ); let resp = result.unwrap().0; assert!( !resp.response.contains("Unknown command"), @@ -469,12 +262,9 @@ mod tests { args: String::new(), }; let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; assert!( - resp.response.contains("Usage"), - "expected usage hint for bare /rmtree: {}", - resp.response + result.is_err(), + "rmtree with no args should return HTTP 400" ); } @@ -487,12 +277,9 @@ mod tests { args: "foo".to_string(), }; let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; assert!( - resp.response.contains("Usage"), - "expected usage hint for /rmtree foo: {}", - resp.response + result.is_err(), + "rmtree with non-numeric arg should return HTTP 400" ); } diff --git a/server/src/service/bot_command/io.rs b/server/src/service/bot_command/io.rs new file mode 100644 index 00000000..db0ec70b --- /dev/null +++ b/server/src/service/bot_command/io.rs @@ -0,0 +1,158 @@ +//! Bot command I/O — the ONLY place in `service/bot_command/` that may call +//! transport handlers, load stores, spawn tasks, or interact with the agent +//! pool. +//! +//! Every function here is a thin adapter over the underlying matrix/timer/htop +//! handlers. No argument parsing or business logic lives here — that belongs in +//! `parse.rs` or `mod.rs`. + +use crate::agents::AgentPool; +use std::path::Path; +use std::sync::Arc; + +use super::parse::{AssignArgs, StartArgs}; + +/// Call the Matrix `assign` handler with pre-validated arguments. +pub(super) async fn call_assign( + args: &AssignArgs, + project_root: &Path, + agents: &Arc, +) -> String { + crate::chat::transport::matrix::assign::handle_assign( + "web-ui", + &args.number, + &args.model, + project_root, + agents, + ) + .await +} + +/// Call the Matrix `start` handler with pre-validated arguments. +pub(super) async fn call_start( + args: &StartArgs, + project_root: &Path, + agents: &Arc, +) -> String { + crate::chat::transport::matrix::start::handle_start( + "web-ui", + &args.number, + args.hint.as_deref(), + project_root, + agents, + ) + .await +} + +/// Call the Matrix `delete` handler with a pre-validated story number. +pub(super) async fn call_delete( + number: &str, + project_root: &Path, + agents: &Arc, +) -> String { + crate::chat::transport::matrix::delete::handle_delete("web-ui", number, project_root, agents) + .await +} + +/// Call the Matrix `rmtree` handler with a pre-validated story number. +pub(super) async fn call_rmtree( + number: &str, + project_root: &Path, + agents: &Arc, +) -> String { + crate::chat::transport::matrix::rmtree::handle_rmtree("web-ui", number, project_root, agents) + .await +} + +/// Call the Matrix `rebuild` handler. +pub(super) async fn call_rebuild(project_root: &Path, agents: &Arc) -> String { + crate::chat::transport::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await +} + +/// Parse and execute a `timer` command. +/// +/// Returns `Err` with a usage string if the timer arguments cannot be parsed. +pub(super) async fn call_timer(args: &str, project_root: &Path) -> Result { + let synthetic = format!("__web_ui__ timer {args}"); + let timer_cmd = match crate::chat::timer::extract_timer_command( + &synthetic, + "__web_ui__", + "@__web_ui__:localhost", + ) { + Some(cmd) => cmd, + None => { + return Err( + "Usage: `/timer list`, `/timer `, or `/timer cancel `" + .to_string(), + ); + } + }; + let store = + crate::chat::timer::TimerStore::load(project_root.join(".huskies").join("timers.json")); + Ok(crate::chat::timer::handle_timer_command(timer_cmd, &store, project_root).await) +} + +/// Build an `htop` snapshot for the web UI. +/// +/// The web UI uses one-shot HTTP requests, so live-updating sessions are not +/// supported. `htop stop` returns a helpful explanation instead of an error. +pub(super) fn call_htop(args: &str, agents: &Arc) -> String { + use crate::chat::transport::matrix::htop::{HtopCommand, build_htop_message}; + + let synthetic = if args.is_empty() { + "__web_ui__ htop".to_string() + } else { + format!("__web_ui__ htop {args}") + }; + + match crate::chat::transport::matrix::htop::extract_htop_command( + &synthetic, + "__web_ui__", + "@__web_ui__:localhost", + ) { + Some(HtopCommand::Stop) => "No active htop session in the web UI. \ + Live sessions are only supported in chat transports (Matrix, Slack, Discord)." + .to_string(), + Some(HtopCommand::Start { duration_secs }) => build_htop_message(agents, 0, duration_secs), + None => build_htop_message(agents, 0, 300), + } +} + +/// Dispatch through the synchronous command registry. +/// +/// Returns `Some(response)` if the command keyword is registered, or `None` +/// if the keyword is unknown. +pub(super) fn call_sync( + cmd: &str, + args: &str, + project_root: &Path, + agents: &Arc, +) -> Option { + use crate::chat::commands::CommandDispatch; + use std::collections::HashSet; + use std::sync::Mutex; + + let ambient_rooms: Arc>> = Arc::new(Mutex::new(HashSet::new())); + let bot_name = "__web_ui__"; + let bot_user_id = "@__web_ui__:localhost"; + let room_id = "__web_ui__"; + + let dispatch = CommandDispatch { + bot_name, + bot_user_id, + project_root, + agents, + ambient_rooms: &ambient_rooms, + room_id, + }; + + // Build a synthetic bot-addressed message so the registry parses it + // identically to messages from chat transports. + let synthetic = if args.is_empty() { + format!("{bot_name} {cmd}") + } else { + format!("{bot_name} {cmd} {args}") + }; + + crate::chat::commands::try_handle_command(&dispatch, &synthetic) +} diff --git a/server/src/service/bot_command/mod.rs b/server/src/service/bot_command/mod.rs new file mode 100644 index 00000000..b26222b6 --- /dev/null +++ b/server/src/service/bot_command/mod.rs @@ -0,0 +1,97 @@ +//! Bot command service — domain logic for dispatching slash commands. +//! +//! Extracted from `http/bot_command.rs` so that argument parsing and dispatch +//! are independently testable without an HTTP layer. +//! +//! Conventions: `docs/architecture/service-modules.md` +//! +//! # Structure +//! - `mod.rs` (this file) — public API and typed `Error` type +//! - `parse.rs` — pure argument parsing, no I/O +//! - `io.rs` — all side-effectful calls (transport handlers, stores, agent pool) + +pub(super) mod io; +pub mod parse; + +use crate::agents::AgentPool; +use std::path::Path; +use std::sync::Arc; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Typed errors returned by `service::bot_command::execute`. +/// +/// HTTP handlers map these to specific status codes: +/// - [`Error::UnknownCommand`] → 404 Not Found +/// - [`Error::BadArgs`] → 400 Bad Request +/// - [`Error::CommandFailed`] → 500 Internal Server Error +#[derive(Debug)] +#[allow(dead_code)] // CommandFailed is part of the public API contract; not yet reachable +pub enum Error { + /// The command keyword does not match any registered command. + UnknownCommand(String), + /// The command exists but the provided arguments are invalid. + BadArgs(String), + /// The command ran but failed with an internal error. + CommandFailed(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownCommand(msg) | Self::BadArgs(msg) | Self::CommandFailed(msg) => { + write!(f, "{msg}") + } + } + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Execute a bot command and return the markdown response. +/// +/// Dispatches to the same handlers used by the Matrix and Slack bots. The +/// `cmd` argument is the lower-cased command keyword (e.g. `"status"`, +/// `"start"`). The `args` argument is any text after the keyword, already +/// trimmed. +/// +/// # Errors +/// - [`Error::UnknownCommand`] if the command keyword is not registered. +/// - [`Error::BadArgs`] if the arguments fail validation. +/// - [`Error::CommandFailed`] if command execution raises an internal error. +pub async fn execute( + cmd: &str, + args: &str, + project_root: &Path, + agents: &Arc, +) -> Result { + match cmd { + "assign" => { + let parsed = parse::parse_assign(args).map_err(Error::BadArgs)?; + Ok(io::call_assign(&parsed, project_root, agents).await) + } + "start" => { + let parsed = parse::parse_start(args).map_err(Error::BadArgs)?; + Ok(io::call_start(&parsed, project_root, agents).await) + } + "delete" => { + let number = parse::parse_number("delete", args).map_err(Error::BadArgs)?; + Ok(io::call_delete(&number, project_root, agents).await) + } + "rmtree" => { + let number = parse::parse_number("rmtree", args).map_err(Error::BadArgs)?; + Ok(io::call_rmtree(&number, project_root, agents).await) + } + "rebuild" => Ok(io::call_rebuild(project_root, agents).await), + "timer" => io::call_timer(args, project_root) + .await + .map_err(Error::BadArgs), + "htop" => Ok(io::call_htop(args, agents)), + _ => match io::call_sync(cmd, args, project_root, agents) { + Some(response) => Ok(response), + None => Err(Error::UnknownCommand(format!( + "Unknown command: `/{cmd}`. Type `/help` to see available commands." + ))), + }, + } +} diff --git a/server/src/service/bot_command/parse.rs b/server/src/service/bot_command/parse.rs new file mode 100644 index 00000000..fba1c781 --- /dev/null +++ b/server/src/service/bot_command/parse.rs @@ -0,0 +1,216 @@ +//! Pure argument parsing for bot commands. +//! +//! Every function in this module is synchronous and free of I/O. All +//! filesystem, network, and agent-pool access belongs in `io.rs`. + +// ── Parsed argument types ───────────────────────────────────────────────────── + +/// Parsed arguments for the `assign` command. +#[derive(Debug)] +pub struct AssignArgs { + /// The numeric story identifier (as a string, e.g. `"42"`). + pub number: String, + /// The model / agent name (e.g. `"opus"`, `"coder-sonnet"`). + pub model: String, +} + +/// Parsed arguments for the `start` command. +#[derive(Debug)] +pub struct StartArgs { + /// The numeric story identifier. + pub number: String, + /// Optional model hint (e.g. `"opus"` → resolved to `"coder-opus"`). + pub hint: Option, +} + +// ── Parsing functions ───────────────────────────────────────────────────────── + +/// Parse `assign` arguments: ` `. +/// +/// Returns `Err` with a user-visible usage string if the arguments are missing +/// or invalid (non-numeric number, empty model). +pub fn parse_assign(args: &str) -> Result { + let mut parts = args.splitn(2, char::is_whitespace); + let number = parts.next().unwrap_or("").trim().to_string(); + let model = parts.next().unwrap_or("").trim().to_string(); + + if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) || model.is_empty() { + return Err("Usage: `/assign ` (e.g. `/assign 42 opus`)".to_string()); + } + + Ok(AssignArgs { number, model }) +} + +/// Parse `start` arguments: `` or ` `. +/// +/// Returns `Err` with a user-visible usage string if the number is missing +/// or non-numeric. +pub fn parse_start(args: &str) -> Result { + let mut parts = args.splitn(2, char::is_whitespace); + let number = parts.next().unwrap_or("").trim().to_string(); + let hint_str = parts.next().unwrap_or("").trim(); + + if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) { + return Err( + "Usage: `/start ` or `/start ` (e.g. `/start 42 opus`)" + .to_string(), + ); + } + + let hint = if hint_str.is_empty() { + None + } else { + Some(hint_str.to_string()) + }; + + Ok(StartArgs { number, hint }) +} + +/// Parse a single numeric argument for commands like `delete` and `rmtree`. +/// +/// `cmd_name` is used only in the error message (e.g. `"delete"` or `"rmtree"`). +/// Returns `Err` with a user-visible usage string if the argument is missing +/// or non-numeric. +pub fn parse_number(cmd_name: &str, args: &str) -> Result { + let number = args.trim().to_string(); + if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) { + return Err(format!( + "Usage: `/{cmd_name} ` (e.g. `/{cmd_name} 42`)" + )); + } + Ok(number) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // -- parse_assign ---------------------------------------------------------- + + #[test] + fn assign_valid() { + let r = parse_assign("42 opus").unwrap(); + assert_eq!(r.number, "42"); + assert_eq!(r.model, "opus"); + } + + #[test] + fn assign_valid_model_with_spaces() { + // splitn(2): everything after first whitespace goes into `model`. + let r = parse_assign("42 claude-opus-4").unwrap(); + assert_eq!(r.number, "42"); + assert_eq!(r.model, "claude-opus-4"); + } + + #[test] + fn assign_missing_all_args() { + assert!(parse_assign("").is_err()); + } + + #[test] + fn assign_missing_model() { + let err = parse_assign("42").unwrap_err(); + assert!( + err.contains("Usage"), + "error should contain usage hint: {err}" + ); + } + + #[test] + fn assign_non_numeric_number() { + let err = parse_assign("foo opus").unwrap_err(); + assert!( + err.contains("Usage"), + "error should contain usage hint: {err}" + ); + } + + #[test] + fn assign_number_with_letters_is_invalid() { + assert!(parse_assign("42x opus").is_err()); + } + + // -- parse_start ----------------------------------------------------------- + + #[test] + fn start_valid_number_only() { + let r = parse_start("42").unwrap(); + assert_eq!(r.number, "42"); + assert!(r.hint.is_none()); + } + + #[test] + fn start_valid_with_hint() { + let r = parse_start("42 opus").unwrap(); + assert_eq!(r.number, "42"); + assert_eq!(r.hint.as_deref(), Some("opus")); + } + + #[test] + fn start_missing_number() { + let err = parse_start("").unwrap_err(); + assert!( + err.contains("Usage"), + "error should contain usage hint: {err}" + ); + } + + #[test] + fn start_non_numeric_number() { + let err = parse_start("foo").unwrap_err(); + assert!( + err.contains("Usage"), + "error should contain usage hint: {err}" + ); + } + + #[test] + fn start_non_numeric_with_hint() { + assert!(parse_start("foo opus").is_err()); + } + + // -- parse_number ---------------------------------------------------------- + + #[test] + fn number_valid() { + assert_eq!(parse_number("delete", "99").unwrap(), "99"); + } + + #[test] + fn number_missing() { + let err = parse_number("delete", "").unwrap_err(); + assert!( + err.contains("Usage"), + "error should contain usage hint: {err}" + ); + assert!( + err.contains("delete"), + "error should mention the command: {err}" + ); + } + + #[test] + fn number_non_numeric() { + let err = parse_number("delete", "abc").unwrap_err(); + assert!( + err.contains("Usage"), + "error should contain usage hint: {err}" + ); + } + + #[test] + fn number_usage_contains_cmd_name() { + let err = parse_number("rmtree", "").unwrap_err(); + assert!( + err.contains("rmtree"), + "usage should mention the command: {err}" + ); + } + + #[test] + fn number_whitespace_only_is_invalid() { + assert!(parse_number("delete", " ").is_err()); + } +} diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 32609f66..01ec9d17 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -6,6 +6,7 @@ //! - `io.rs` is the only file that performs side effects //! - Topic-named pure files contain branching logic with no I/O pub mod agents; +pub mod bot_command; pub mod events; pub mod health; pub mod project;