//! 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()); } }