use std::time::Duration;
use clap::{Args, Parser, Subcommand};
use crate::config::Config;
#[derive(Debug, Parser)]
#[command(name = "uc-server", version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Serve(ServeArgs),
Migrate(MigrateArgs),
Healthcheck(HealthcheckArgs),
}
#[derive(Debug, Default, Clone, Args)]
pub struct MigrateArgs {
#[arg(short, long, env = "UC_SERVER_CONFIG", value_name = "PATH")]
pub config: Option<String>,
}
#[derive(Debug, Default, Clone, Args)]
pub struct ServeArgs {
#[arg(short, long, env = "UC_SERVER_CONFIG", value_name = "PATH")]
pub config: Option<String>,
#[arg(long)]
pub host: Option<String>,
#[arg(long, short)]
pub port: Option<u16>,
#[arg(long)]
pub no_ui: bool,
}
impl ServeArgs {
pub fn overlay(&self, cfg: &mut Config) {
if let Some(host) = &self.host {
cfg.host = Some(host.clone());
}
if let Some(port) = self.port {
cfg.port = Some(port);
}
if self.no_ui {
cfg.ui.serve = false;
}
}
}
#[derive(Debug, Default, Clone, Args)]
pub struct HealthcheckArgs {
#[arg(short, long, env = "UC_SERVER_CONFIG", value_name = "PATH")]
pub config: Option<String>,
#[arg(long)]
pub host: Option<String>,
#[arg(long, short)]
pub port: Option<u16>,
#[arg(long, value_name = "URL")]
pub url: Option<String>,
#[arg(long, default_value_t = 3)]
pub timeout_secs: u64,
}
impl HealthcheckArgs {
fn target_url(&self) -> Result<String, String> {
if let Some(url) = &self.url {
return Ok(url.clone());
}
let mut cfg = Config::load(self.config.as_ref())?;
if let Some(host) = &self.host {
cfg.host = Some(host.clone());
}
if let Some(port) = self.port {
cfg.port = Some(port);
}
Ok(cfg.health_url())
}
}
pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> {
let url = args.target_url()?;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(args.timeout_secs))
.build()
.map_err(|e| e.to_string())?;
let resp = client.get(&url).send().map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
return Err(format!("health endpoint returned {status}"));
}
let body = resp.text().map_err(|e| e.to_string())?;
if body.trim() != "OK" {
return Err(format!("unexpected health body {body:?}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_arg_tree_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn serve_parses_with_no_flags() {
let cli = Cli::try_parse_from(["uc-server", "serve"]).unwrap();
let Command::Serve(args) = cli.command else {
panic!("expected serve");
};
assert!(args.config.is_none());
assert!(args.host.is_none());
assert!(args.port.is_none());
assert!(!args.no_ui);
}
#[test]
fn no_ui_flag_overlays_serve() {
let cli = Cli::try_parse_from(["uc-server", "serve"]).unwrap();
let Command::Serve(args) = cli.command else {
panic!("expected serve");
};
let mut cfg = Config::default();
args.overlay(&mut cfg);
assert!(
cfg.ui.serve,
"absent --no-ui leaves serve at its config value"
);
let cli = Cli::try_parse_from(["uc-server", "serve", "--no-ui"]).unwrap();
let Command::Serve(args) = cli.command else {
panic!("expected serve");
};
assert!(args.no_ui);
let mut cfg = Config::default();
args.overlay(&mut cfg);
assert!(!cfg.ui.serve);
}
#[test]
fn serve_flags_overlay_config() {
let cli = Cli::try_parse_from([
"uc-server",
"serve",
"--host",
"127.0.0.1",
"--port",
"9000",
])
.unwrap();
let Command::Serve(args) = cli.command else {
panic!("expected serve");
};
let mut cfg = Config::default();
args.overlay(&mut cfg);
assert_eq!(cfg.host.as_deref(), Some("127.0.0.1"));
assert_eq!(cfg.port, Some(9000));
}
#[test]
fn migrate_parses() {
let cli = Cli::try_parse_from(["uc-server", "migrate"]).unwrap();
let Command::Migrate(args) = cli.command else {
panic!("expected migrate");
};
assert!(args.config.is_none());
}
#[test]
fn healthcheck_parses() {
let cli = Cli::try_parse_from(["uc-server", "healthcheck", "--port", "9000"]).unwrap();
let Command::Healthcheck(args) = cli.command else {
panic!("expected healthcheck");
};
assert_eq!(args.port, Some(9000));
assert_eq!(args.timeout_secs, 3);
}
#[test]
fn healthcheck_url_overrides_config() {
let args = HealthcheckArgs {
url: Some("http://example.test:1234/health".into()),
..HealthcheckArgs::default()
};
assert_eq!(
args.target_url().unwrap(),
"http://example.test:1234/health"
);
}
#[test]
fn no_subcommand_is_an_error() {
assert!(Cli::try_parse_from(["uc-server"]).is_err());
}
}