217 lines
6.6 KiB
Rust
217 lines
6.6 KiB
Rust
|
|
//! 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());
|
||
|
|
}
|
||
|
|
}
|