use anyhow::{Context, Result, bail}; #[cfg(not(coverage))] use lesavka_common::lesavka::Empty; use lesavka_common::lesavka::{ CapturePowerCommand, SetCapturePowerRequest, relay_client::RelayClient, }; #[cfg(not(coverage))] use tonic::Request; use tonic::transport::Channel; use lesavka_client::relay_transport; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CommandKind { Status, Auto, On, Off, ResetUsb, } impl CommandKind { fn parse(value: &str) -> Option { match value { "status" | "get" => Some(Self::Status), "auto" => Some(Self::Auto), "on" | "force-on" => Some(Self::On), "off" | "force-off" => Some(Self::Off), "reset-usb" | "recover-usb" => Some(Self::ResetUsb), _ => None, } } } #[derive(Debug, Eq, PartialEq)] struct Config { server: String, command: CommandKind, } #[derive(Debug, Eq, PartialEq)] enum ParseOutcome { Run(Config), Help, } fn usage() -> &'static str { "Usage: lesavka-relayctl [--server http://HOST:50051] " } fn parse_args_outcome_from(args: I) -> Result where I: IntoIterator, S: Into, { let mut args = args.into_iter().map(Into::into); let mut server = "http://127.0.0.1:50051".to_string(); let mut command = None; while let Some(arg) = args.next() { match arg.as_str() { "--server" => { server = args .next() .context("missing value after --server")? .trim() .to_string(); } "--help" | "-h" => { return Ok(ParseOutcome::Help); } _ if command.is_none() => { command = CommandKind::parse(arg.as_str()); if command.is_none() { bail!("unknown command `{arg}`\n{}", usage()); } } _ => bail!("unexpected argument `{arg}`\n{}", usage()), } } Ok(ParseOutcome::Run(Config { server, command: command.unwrap_or(CommandKind::Status), })) } #[cfg(test)] fn parse_args_from(args: I) -> Result where I: IntoIterator, S: Into, { match parse_args_outcome_from(args)? { ParseOutcome::Run(config) => Ok(config), ParseOutcome::Help => bail!("{}", usage()), } } fn parse_args() -> Result { parse_args_outcome_from(std::env::args().skip(1)) } fn capture_power_request(command: CommandKind) -> Option { let (enabled, command) = match command { CommandKind::Status | CommandKind::ResetUsb => return None, CommandKind::Auto => (false, CapturePowerCommand::Auto), CommandKind::On => (true, CapturePowerCommand::ForceOn), CommandKind::Off => (false, CapturePowerCommand::ForceOff), }; Some(SetCapturePowerRequest { enabled, command: command as i32, }) } #[cfg(not(coverage))] async fn connect(server_addr: &str) -> Result> { let channel = relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect() .await .with_context(|| format!("connecting to relay at {server_addr}"))?; Ok(RelayClient::new(channel)) } #[cfg(coverage)] async fn connect(server_addr: &str) -> Result> { let channel = relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect_lazy(); Ok(RelayClient::new(channel)) } fn print_state(state: lesavka_common::lesavka::CapturePowerState) { println!("available={}", state.available); println!("enabled={}", state.enabled); println!("mode={}", state.mode); println!("detected_devices={}", state.detected_devices); println!("active_leases={}", state.active_leases); println!("unit={}", state.unit); println!("detail={}", state.detail); } #[cfg(not(coverage))] #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { let config = match parse_args()? { ParseOutcome::Run(config) => config, ParseOutcome::Help => { println!("{}", usage()); return Ok(()); } }; let mut client = connect(config.server.as_str()).await?; if let Some(request) = capture_power_request(config.command) { let context = match config.command { CommandKind::Auto => "setting capture power to auto", CommandKind::On => "forcing capture power on", CommandKind::Off => "forcing capture power off", CommandKind::Status | CommandKind::ResetUsb => unreachable!(), }; let reply = client .set_capture_power(Request::new(request)) .await .context(context)? .into_inner(); print_state(reply); return Ok(()); } let reply = match config.command { CommandKind::Status => client .get_capture_power(Request::new(Empty {})) .await .context("querying capture power state")? .into_inner(), CommandKind::ResetUsb => { let reply = client .reset_usb(Request::new(Empty {})) .await .context("forcing USB gadget recovery")? .into_inner(); println!("ok={}", reply.ok); return Ok(()); } CommandKind::Auto | CommandKind::On | CommandKind::Off => unreachable!(), }; print_state(reply); Ok(()) } #[cfg(coverage)] fn main() { let _ = parse_args as fn() -> Result; } #[cfg(test)] mod tests { use super::{ CapturePowerCommand, CommandKind, ParseOutcome, capture_power_request, parse_args_from, parse_args_outcome_from, }; use lesavka_common::lesavka::CapturePowerState; #[test] fn command_aliases_parse_to_stable_actions() { assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status)); assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status)); assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On)); assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off)); assert_eq!( CommandKind::parse("recover-usb"), Some(CommandKind::ResetUsb) ); assert_eq!(CommandKind::parse("wat"), None); } #[test] fn parse_args_defaults_to_local_status() { let config = parse_args_from(std::iter::empty::<&str>()).expect("default config"); assert_eq!(config.server, "http://127.0.0.1:50051"); assert_eq!(config.command, CommandKind::Status); } #[test] fn parse_args_accepts_server_and_command() { let config = parse_args_from(["--server", " http://lab:50051 ", "reset-usb"]).expect("config"); assert_eq!(config.server, "http://lab:50051"); assert_eq!(config.command, CommandKind::ResetUsb); } #[test] fn parse_args_rejects_bad_inputs() { assert!(parse_args_from(["--server"]).is_err()); assert!(parse_args_from(["nope"]).is_err()); assert!(parse_args_from(["status", "extra"]).is_err()); } #[test] fn parse_args_reports_help_without_exiting_test_process() { assert_eq!( parse_args_outcome_from(["--help"]).unwrap(), ParseOutcome::Help ); assert_eq!(parse_args_outcome_from(["-h"]).unwrap(), ParseOutcome::Help); assert!(parse_args_from(["--help"]).is_err()); } #[test] fn parse_args_runtime_wrapper_is_non_panicking_under_tests() { let _ = super::parse_args(); } #[cfg(coverage)] #[test] fn coverage_main_references_runtime_parser() { super::main(); } #[cfg(coverage)] #[tokio::test(flavor = "current_thread")] async fn coverage_connect_uses_lazy_channel_after_endpoint_validation() { let client = super::connect("http://127.0.0.1:1") .await .expect("coverage lazy channel"); drop(client); } #[test] fn mutating_commands_map_to_capture_power_requests() { let auto = capture_power_request(CommandKind::Auto).expect("auto request"); assert!(!auto.enabled); assert_eq!(auto.command, CapturePowerCommand::Auto as i32); let on = capture_power_request(CommandKind::On).expect("on request"); assert!(on.enabled); assert_eq!(on.command, CapturePowerCommand::ForceOn as i32); let off = capture_power_request(CommandKind::Off).expect("off request"); assert!(!off.enabled); assert_eq!(off.command, CapturePowerCommand::ForceOff as i32); assert!(capture_power_request(CommandKind::Status).is_none()); assert!(capture_power_request(CommandKind::ResetUsb).is_none()); } #[test] fn print_state_accepts_full_capture_power_payload() { super::print_state(CapturePowerState { available: true, enabled: false, mode: "auto".to_string(), detected_devices: 2, active_leases: 1, unit: "lesavka-capture-power.service".to_string(), detail: "ready".to_string(), }); } }