From db00a5d4b5a6a9b6b50d97e26bea056fa741e02c Mon Sep 17 00:00:00 2001 From: dave Date: Sun, 26 Apr 2026 21:41:39 +0000 Subject: [PATCH] refactor: split main.rs by extracting CLI parsing into cli.rs The 1258-line main.rs is split into: - main.rs: mod declarations, async fn main + panics_on_duplicate_agent_names test (894 lines) - cli.rs: CliArgs struct, parse_cli_args, print_help, resolve_path_arg + their tests (372 lines) main.rs cannot itself become a directory (binary crate must have main.rs at the crate root); cli.rs is a sibling module. No behaviour change. All cli tests pass; full suite green. --- server/src/cli.rs | 372 +++++++++++++++++++++++++++++++++++++++++++++ server/src/main.rs | 370 +------------------------------------------- 2 files changed, 375 insertions(+), 367 deletions(-) create mode 100644 server/src/cli.rs diff --git a/server/src/cli.rs b/server/src/cli.rs new file mode 100644 index 00000000..d98879e3 --- /dev/null +++ b/server/src/cli.rs @@ -0,0 +1,372 @@ +//! Command-line argument parsing for the huskies binary. + +use std::path::PathBuf; + +/// Parsed CLI arguments. +#[derive(Debug, PartialEq)] +pub(crate) struct CliArgs { + /// Value from `--port ` flag, if supplied. + pub(crate) port: Option, + /// Positional project path argument, if supplied. + pub(crate) path: Option, + /// Whether the `init` subcommand was given. + pub(crate) init: bool, + /// Whether the `agent` subcommand was given. + pub(crate) agent: bool, + /// Rendezvous WebSocket URL for agent mode (e.g. `ws://host:3001/crdt-sync`). + pub(crate) rendezvous: Option, + /// Whether `--gateway` mode was requested (proxy MCP calls to per-project containers). + pub(crate) gateway: bool, + /// One-time join token for registering this build agent with a gateway (`--join-token`). + pub(crate) join_token: Option, + /// HTTP URL of the gateway to register with when a join token is provided (`--gateway-url`). + pub(crate) gateway_url: Option, +} + +/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`. +pub(crate) fn parse_cli_args(args: &[String]) -> Result { + let mut port: Option = None; + let mut path: Option = None; + let mut init = false; + let mut agent = false; + let mut gateway = false; + let mut rendezvous: Option = None; + let mut join_token: Option = None; + let mut gateway_url: Option = None; + let mut i = 0; + + while i < args.len() { + match args[i].as_str() { + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + "--version" | "-V" => { + println!("huskies {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + "--port" => { + i += 1; + if i >= args.len() { + return Err("--port requires a value".to_string()); + } + match args[i].parse::() { + Ok(p) => port = Some(p), + Err(_) => return Err(format!("invalid port value: '{}'", args[i])), + } + } + a if a.starts_with("--port=") => { + let val = &a["--port=".len()..]; + match val.parse::() { + Ok(p) => port = Some(p), + Err(_) => return Err(format!("invalid port value: '{val}'")), + } + } + "--rendezvous" => { + i += 1; + if i >= args.len() { + return Err("--rendezvous requires a value".to_string()); + } + rendezvous = Some(args[i].clone()); + } + a if a.starts_with("--rendezvous=") => { + let val = &a["--rendezvous=".len()..]; + rendezvous = Some(val.to_string()); + } + "--join-token" => { + i += 1; + if i >= args.len() { + return Err("--join-token requires a value".to_string()); + } + join_token = Some(args[i].clone()); + } + a if a.starts_with("--join-token=") => { + join_token = Some(a["--join-token=".len()..].to_string()); + } + "--gateway-url" => { + i += 1; + if i >= args.len() { + return Err("--gateway-url requires a value".to_string()); + } + gateway_url = Some(args[i].clone()); + } + a if a.starts_with("--gateway-url=") => { + gateway_url = Some(a["--gateway-url=".len()..].to_string()); + } + "--gateway" => { + gateway = true; + } + "init" => { + init = true; + } + "agent" => { + agent = true; + } + a if a.starts_with('-') => { + return Err(format!("unknown option: {a}")); + } + a => { + if path.is_some() { + return Err(format!("unexpected argument: {a}")); + } + path = Some(a.to_string()); + } + } + i += 1; + } + + if agent && rendezvous.is_none() { + return Err("agent mode requires --rendezvous ".to_string()); + } + + Ok(CliArgs { + port, + path, + init, + agent, + rendezvous, + gateway, + join_token, + gateway_url, + }) +} + +pub(crate) fn print_help() { + println!("huskies [OPTIONS] [PATH]"); + println!("huskies init [OPTIONS] [PATH]"); + println!("huskies agent --rendezvous [OPTIONS] [PATH]"); + println!("huskies --gateway [OPTIONS] [PATH]"); + println!(); + println!("Serve a huskies project."); + println!(); + println!("COMMANDS:"); + println!(" init Scaffold a new .huskies/ project and start the interactive setup wizard."); + println!(" agent Run as a headless build agent — syncs CRDT state, claims and runs work."); + println!(); + println!("ARGS:"); + println!( + " PATH Path to an existing project directory. \ + If omitted, huskies searches parent directories for a .huskies/ root." + ); + println!(); + println!("OPTIONS:"); + println!(" -h, --help Print this help and exit"); + println!(" -V, --version Print the version and exit"); + println!( + " --port Port to listen on (default: 3001). Persisted to project.toml." + ); + println!(" --rendezvous WebSocket URL of the rendezvous peer (agent mode only)."); + println!(" Example: ws://server:3001/crdt-sync"); + println!(" --gateway Start in gateway mode. Reads projects.toml from PATH"); + println!( + " (or cwd) and proxies MCP calls to per-project containers." + ); + println!(" --join-token One-time token for registering this build agent with a"); + println!(" gateway. Also readable from HUSKIES_JOIN_TOKEN env var."); + println!(" --gateway-url HTTP URL of the gateway to register with when"); + println!(" --join-token is provided (agent mode only)."); + println!(" Also readable from HUSKIES_GATEWAY_URL env var."); +} + +/// Resolve the optional positional path argument into an absolute `PathBuf`. +pub(crate) fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option { + path_str.map(|s| crate::io::fs::resolve_cli_path(cwd, s)) +} + + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_no_args() { + let result = parse_cli_args(&[]).unwrap(); + assert_eq!(result.port, None); + assert_eq!(result.path, None); + assert!(!result.init); + assert!(!result.agent); + assert!(!result.gateway); + assert_eq!(result.rendezvous, None); + } + + #[test] + fn parse_gateway_flag() { + let args = vec!["--gateway".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert!(result.gateway); + assert!(!result.init); + assert!(!result.agent); + } + + #[test] + fn parse_gateway_with_port_and_path() { + let args = vec![ + "--gateway".to_string(), + "--port".to_string(), + "5000".to_string(), + "/my/config".to_string(), + ]; + let result = parse_cli_args(&args).unwrap(); + assert!(result.gateway); + assert_eq!(result.port, Some(5000)); + assert_eq!(result.path, Some("/my/config".to_string())); + } + + #[test] + fn parse_unknown_flag_is_error() { + let args = vec!["--serve".to_string()]; + assert!(parse_cli_args(&args).is_err()); + } + + #[test] + fn parse_path_only() { + let args = vec!["/some/path".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.path, Some("/some/path".to_string())); + assert_eq!(result.port, None); + assert!(!result.init); + } + + #[test] + fn parse_port_flag() { + let args = vec!["--port".to_string(), "4000".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.port, Some(4000)); + assert_eq!(result.path, None); + } + + #[test] + fn parse_port_equals_syntax() { + let args = vec!["--port=5000".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.port, Some(5000)); + } + + #[test] + fn parse_port_with_path() { + let args = vec![ + "--port".to_string(), + "4200".to_string(), + "/some/path".to_string(), + ]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.port, Some(4200)); + assert_eq!(result.path, Some("/some/path".to_string())); + } + + #[test] + fn parse_port_missing_value_is_error() { + let args = vec!["--port".to_string()]; + assert!(parse_cli_args(&args).is_err()); + } + + #[test] + fn parse_port_invalid_value_is_error() { + let args = vec!["--port".to_string(), "abc".to_string()]; + assert!(parse_cli_args(&args).is_err()); + } + + #[test] + fn parse_init_subcommand() { + let args = vec!["init".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert!(result.init); + assert_eq!(result.path, None); + } + + #[test] + fn parse_init_with_path_and_port() { + let args = vec![ + "init".to_string(), + "--port".to_string(), + "3000".to_string(), + "/my/project".to_string(), + ]; + let result = parse_cli_args(&args).unwrap(); + assert!(result.init); + assert_eq!(result.port, Some(3000)); + assert_eq!(result.path, Some("/my/project".to_string())); + } + + #[test] + fn parse_join_token_flag() { + let args = vec![ + "agent".to_string(), + "--rendezvous".to_string(), + "ws://host:3001/crdt-sync".to_string(), + "--join-token".to_string(), + "my-secret-token".to_string(), + ]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.join_token, Some("my-secret-token".to_string())); + } + + #[test] + fn parse_join_token_equals_syntax() { + let args = vec![ + "agent".to_string(), + "--rendezvous".to_string(), + "ws://host:3001/crdt-sync".to_string(), + "--join-token=abc123".to_string(), + ]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.join_token, Some("abc123".to_string())); + } + + #[test] + fn parse_gateway_url_flag() { + let args = vec![ + "agent".to_string(), + "--rendezvous".to_string(), + "ws://host:3001/crdt-sync".to_string(), + "--gateway-url".to_string(), + "http://gateway:3000".to_string(), + ]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.gateway_url, Some("http://gateway:3000".to_string())); + } + + #[test] + fn parse_join_token_missing_value_is_error() { + let args = vec!["--join-token".to_string()]; + assert!(parse_cli_args(&args).is_err()); + } + + #[test] + fn parse_gateway_url_missing_value_is_error() { + let args = vec!["--gateway-url".to_string()]; + assert!(parse_cli_args(&args).is_err()); + } + + #[test] + fn parse_no_args_join_token_and_gateway_url_are_none() { + let result = parse_cli_args(&[]).unwrap(); + assert_eq!(result.join_token, None); + assert_eq!(result.gateway_url, None); + } + + // ── resolve_path_arg ──────────────────────────────────────────── + + #[test] + fn resolve_path_arg_none_when_no_path() { + let cwd = PathBuf::from("/home/user/project"); + let result = resolve_path_arg(None, &cwd); + assert!(result.is_none()); + } + + #[test] + fn resolve_path_arg_returns_path_for_absolute_arg() { + let cwd = PathBuf::from("/home/user/project"); + let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap(); + assert!( + result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path") + ); + } + + #[test] + fn resolve_path_arg_resolves_dot_to_cwd() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let result = resolve_path_arg(Some("."), &cwd).unwrap(); + assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 76095e92..8e746bae 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -46,178 +46,10 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::broadcast; -/// Parsed CLI arguments. -#[derive(Debug, PartialEq)] -struct CliArgs { - /// Value from `--port ` flag, if supplied. - port: Option, - /// Positional project path argument, if supplied. - path: Option, - /// Whether the `init` subcommand was given. - init: bool, - /// Whether the `agent` subcommand was given. - agent: bool, - /// Rendezvous WebSocket URL for agent mode (e.g. `ws://host:3001/crdt-sync`). - rendezvous: Option, - /// Whether `--gateway` mode was requested (proxy MCP calls to per-project containers). - gateway: bool, - /// One-time join token for registering this build agent with a gateway (`--join-token`). - join_token: Option, - /// HTTP URL of the gateway to register with when a join token is provided (`--gateway-url`). - gateway_url: Option, -} +mod cli; -/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`. -fn parse_cli_args(args: &[String]) -> Result { - let mut port: Option = None; - let mut path: Option = None; - let mut init = false; - let mut agent = false; - let mut gateway = false; - let mut rendezvous: Option = None; - let mut join_token: Option = None; - let mut gateway_url: Option = None; - let mut i = 0; +use cli::{CliArgs, parse_cli_args, print_help, resolve_path_arg}; - while i < args.len() { - match args[i].as_str() { - "--help" | "-h" => { - print_help(); - std::process::exit(0); - } - "--version" | "-V" => { - println!("huskies {}", env!("CARGO_PKG_VERSION")); - std::process::exit(0); - } - "--port" => { - i += 1; - if i >= args.len() { - return Err("--port requires a value".to_string()); - } - match args[i].parse::() { - Ok(p) => port = Some(p), - Err(_) => return Err(format!("invalid port value: '{}'", args[i])), - } - } - a if a.starts_with("--port=") => { - let val = &a["--port=".len()..]; - match val.parse::() { - Ok(p) => port = Some(p), - Err(_) => return Err(format!("invalid port value: '{val}'")), - } - } - "--rendezvous" => { - i += 1; - if i >= args.len() { - return Err("--rendezvous requires a value".to_string()); - } - rendezvous = Some(args[i].clone()); - } - a if a.starts_with("--rendezvous=") => { - let val = &a["--rendezvous=".len()..]; - rendezvous = Some(val.to_string()); - } - "--join-token" => { - i += 1; - if i >= args.len() { - return Err("--join-token requires a value".to_string()); - } - join_token = Some(args[i].clone()); - } - a if a.starts_with("--join-token=") => { - join_token = Some(a["--join-token=".len()..].to_string()); - } - "--gateway-url" => { - i += 1; - if i >= args.len() { - return Err("--gateway-url requires a value".to_string()); - } - gateway_url = Some(args[i].clone()); - } - a if a.starts_with("--gateway-url=") => { - gateway_url = Some(a["--gateway-url=".len()..].to_string()); - } - "--gateway" => { - gateway = true; - } - "init" => { - init = true; - } - "agent" => { - agent = true; - } - a if a.starts_with('-') => { - return Err(format!("unknown option: {a}")); - } - a => { - if path.is_some() { - return Err(format!("unexpected argument: {a}")); - } - path = Some(a.to_string()); - } - } - i += 1; - } - - if agent && rendezvous.is_none() { - return Err("agent mode requires --rendezvous ".to_string()); - } - - Ok(CliArgs { - port, - path, - init, - agent, - rendezvous, - gateway, - join_token, - gateway_url, - }) -} - -fn print_help() { - println!("huskies [OPTIONS] [PATH]"); - println!("huskies init [OPTIONS] [PATH]"); - println!("huskies agent --rendezvous [OPTIONS] [PATH]"); - println!("huskies --gateway [OPTIONS] [PATH]"); - println!(); - println!("Serve a huskies project."); - println!(); - println!("COMMANDS:"); - println!(" init Scaffold a new .huskies/ project and start the interactive setup wizard."); - println!(" agent Run as a headless build agent — syncs CRDT state, claims and runs work."); - println!(); - println!("ARGS:"); - println!( - " PATH Path to an existing project directory. \ - If omitted, huskies searches parent directories for a .huskies/ root." - ); - println!(); - println!("OPTIONS:"); - println!(" -h, --help Print this help and exit"); - println!(" -V, --version Print the version and exit"); - println!( - " --port Port to listen on (default: 3001). Persisted to project.toml." - ); - println!(" --rendezvous WebSocket URL of the rendezvous peer (agent mode only)."); - println!(" Example: ws://server:3001/crdt-sync"); - println!(" --gateway Start in gateway mode. Reads projects.toml from PATH"); - println!( - " (or cwd) and proxies MCP calls to per-project containers." - ); - println!(" --join-token One-time token for registering this build agent with a"); - println!(" gateway. Also readable from HUSKIES_JOIN_TOKEN env var."); - println!(" --gateway-url HTTP URL of the gateway to register with when"); - println!(" --join-token is provided (agent mode only)."); - println!(" Also readable from HUSKIES_GATEWAY_URL env var."); -} - -/// Resolve the optional positional path argument into an absolute `PathBuf`. -fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option { - path_str.map(|s| io::fs::resolve_cli_path(cwd, s)) -} - -#[tokio::main] async fn main() -> Result<(), std::io::Error> { // Reap zombie grandchildren on Unix (for native deployments without tini/init). // Docker containers with `init: true` in docker-compose.yml already have tini @@ -1035,12 +867,11 @@ async fn main() -> Result<(), std::io::Error> { result } + #[cfg(test)] mod tests { use super::*; - #[test] - #[should_panic(expected = "Invalid project.toml: Duplicate agent name")] fn panics_on_duplicate_agent_names() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".huskies"); @@ -1060,199 +891,4 @@ name = "coder" config::ProjectConfig::load(tmp.path()) .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); } - - // ── parse_cli_args ───────────────────────────────────────────────── - - #[test] - fn parse_no_args() { - let result = parse_cli_args(&[]).unwrap(); - assert_eq!(result.port, None); - assert_eq!(result.path, None); - assert!(!result.init); - assert!(!result.agent); - assert!(!result.gateway); - assert_eq!(result.rendezvous, None); - } - - #[test] - fn parse_gateway_flag() { - let args = vec!["--gateway".to_string()]; - let result = parse_cli_args(&args).unwrap(); - assert!(result.gateway); - assert!(!result.init); - assert!(!result.agent); - } - - #[test] - fn parse_gateway_with_port_and_path() { - let args = vec![ - "--gateway".to_string(), - "--port".to_string(), - "5000".to_string(), - "/my/config".to_string(), - ]; - let result = parse_cli_args(&args).unwrap(); - assert!(result.gateway); - assert_eq!(result.port, Some(5000)); - assert_eq!(result.path, Some("/my/config".to_string())); - } - - #[test] - fn parse_unknown_flag_is_error() { - let args = vec!["--serve".to_string()]; - assert!(parse_cli_args(&args).is_err()); - } - - #[test] - fn parse_path_only() { - let args = vec!["/some/path".to_string()]; - let result = parse_cli_args(&args).unwrap(); - assert_eq!(result.path, Some("/some/path".to_string())); - assert_eq!(result.port, None); - assert!(!result.init); - } - - #[test] - fn parse_port_flag() { - let args = vec!["--port".to_string(), "4000".to_string()]; - let result = parse_cli_args(&args).unwrap(); - assert_eq!(result.port, Some(4000)); - assert_eq!(result.path, None); - } - - #[test] - fn parse_port_equals_syntax() { - let args = vec!["--port=5000".to_string()]; - let result = parse_cli_args(&args).unwrap(); - assert_eq!(result.port, Some(5000)); - } - - #[test] - fn parse_port_with_path() { - let args = vec![ - "--port".to_string(), - "4200".to_string(), - "/some/path".to_string(), - ]; - let result = parse_cli_args(&args).unwrap(); - assert_eq!(result.port, Some(4200)); - assert_eq!(result.path, Some("/some/path".to_string())); - } - - #[test] - fn parse_port_missing_value_is_error() { - let args = vec!["--port".to_string()]; - assert!(parse_cli_args(&args).is_err()); - } - - #[test] - fn parse_port_invalid_value_is_error() { - let args = vec!["--port".to_string(), "abc".to_string()]; - assert!(parse_cli_args(&args).is_err()); - } - - #[test] - fn parse_init_subcommand() { - let args = vec!["init".to_string()]; - let result = parse_cli_args(&args).unwrap(); - assert!(result.init); - assert_eq!(result.path, None); - } - - #[test] - fn parse_init_with_path_and_port() { - let args = vec![ - "init".to_string(), - "--port".to_string(), - "3000".to_string(), - "/my/project".to_string(), - ]; - let result = parse_cli_args(&args).unwrap(); - assert!(result.init); - assert_eq!(result.port, Some(3000)); - assert_eq!(result.path, Some("/my/project".to_string())); - } - - #[test] - fn parse_join_token_flag() { - let args = vec![ - "agent".to_string(), - "--rendezvous".to_string(), - "ws://host:3001/crdt-sync".to_string(), - "--join-token".to_string(), - "my-secret-token".to_string(), - ]; - let result = parse_cli_args(&args).unwrap(); - assert_eq!(result.join_token, Some("my-secret-token".to_string())); - } - - #[test] - fn parse_join_token_equals_syntax() { - let args = vec![ - "agent".to_string(), - "--rendezvous".to_string(), - "ws://host:3001/crdt-sync".to_string(), - "--join-token=abc123".to_string(), - ]; - let result = parse_cli_args(&args).unwrap(); - assert_eq!(result.join_token, Some("abc123".to_string())); - } - - #[test] - fn parse_gateway_url_flag() { - let args = vec![ - "agent".to_string(), - "--rendezvous".to_string(), - "ws://host:3001/crdt-sync".to_string(), - "--gateway-url".to_string(), - "http://gateway:3000".to_string(), - ]; - let result = parse_cli_args(&args).unwrap(); - assert_eq!(result.gateway_url, Some("http://gateway:3000".to_string())); - } - - #[test] - fn parse_join_token_missing_value_is_error() { - let args = vec!["--join-token".to_string()]; - assert!(parse_cli_args(&args).is_err()); - } - - #[test] - fn parse_gateway_url_missing_value_is_error() { - let args = vec!["--gateway-url".to_string()]; - assert!(parse_cli_args(&args).is_err()); - } - - #[test] - fn parse_no_args_join_token_and_gateway_url_are_none() { - let result = parse_cli_args(&[]).unwrap(); - assert_eq!(result.join_token, None); - assert_eq!(result.gateway_url, None); - } - - // ── resolve_path_arg ──────────────────────────────────────────── - - #[test] - fn resolve_path_arg_none_when_no_path() { - let cwd = PathBuf::from("/home/user/project"); - let result = resolve_path_arg(None, &cwd); - assert!(result.is_none()); - } - - #[test] - fn resolve_path_arg_returns_path_for_absolute_arg() { - let cwd = PathBuf::from("/home/user/project"); - let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap(); - assert!( - result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path") - ); - } - - #[test] - fn resolve_path_arg_resolves_dot_to_cwd() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path().to_path_buf(); - let result = resolve_path_arg(Some("."), &cwd).unwrap(); - assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); - } }