huskies: merge 607_story_extract_bot_command_service
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user