huskies: merge 607_story_extract_bot_command_service

This commit is contained in:
dave
2026-04-24 15:23:54 +00:00
parent 1910365321
commit aba3120388
5 changed files with 507 additions and 248 deletions
+216
View File
@@ -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<String>,
}
// ── Parsing functions ─────────────────────────────────────────────────────────
/// Parse `assign` arguments: `<number> <model>`.
///
/// 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<AssignArgs, String> {
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 <number> <model>` (e.g. `/assign 42 opus`)".to_string());
}
Ok(AssignArgs { number, model })
}
/// Parse `start` arguments: `<number>` or `<number> <model_hint>`.
///
/// Returns `Err` with a user-visible usage string if the number is missing
/// or non-numeric.
pub fn parse_start(args: &str) -> Result<StartArgs, String> {
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 <number>` or `/start <number> <model>` (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<String, String> {
let number = args.trim().to_string();
if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"Usage: `/{cmd_name} <number>` (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());
}
}