aboutsummaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
authorjamie <git@skilet.ro>2026-08-29 22:51:38 +0100
committerjamie <git@skilet.ro>2026-08-29 22:55:36 +0100
commit6ade07db214e8571a439b474e6c4e6a4c861d098 (patch)
tree0c4864b5d4a2abc0b05cfdb83596d87edb018dac /src/main.rs
parent4c42e8c9d7ec1b2e94267038d5ee38a714ca2955 (diff)
feat(cli): adopt clap and strict clippy configHEADmain
Diffstat (limited to 'src/main.rs')
-rw-r--r--src/main.rs237
1 files changed, 117 insertions, 120 deletions
diff --git a/src/main.rs b/src/main.rs
index 9392692..a21959b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,43 +1,73 @@
//! mmx150d — connection monitoring for the beyerdynamic MMX 150 wireless
//! dongle.
-use std::process::{Child, Command, ExitCode, Stdio};
+use std::process::{Child, ExitCode, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+use clap::{Parser, Subcommand};
+
use mmx150::hid::{find_mmx150, Hidraw};
use mmx150::protocol::{self, LinkState, StateTracker, Transition};
-fn main() -> ExitCode {
- let args: Vec<String> = std::env::args().skip(1).collect();
- match args.first().map(String::as_str) {
- Some("status") => cmd_status(),
- Some("daemon") => cmd_daemon(&args[1..]),
- _ => {
- print_usage();
- ExitCode::FAILURE
- }
- }
+const AFTER_HELP: &str = "\
+status exit codes: 0 connected, 1 disconnected, 2 dongle missing/error
+
+example:
+ mmx150d daemon \\
+ --on-connect 'wpctl set-default <mmx150-sink-id>' \\
+ --on-disconnect 'wpctl set-default <speakers-sink-id>'
+
+find sink ids with: wpctl status";
+
+#[derive(Parser, Debug)]
+#[command(
+ name = "mmx150d",
+ version,
+ about = "Connection monitoring for the beyerdynamic MMX 150 wireless dongle",
+ after_help = AFTER_HELP
+)]
+struct Cli {
+ #[command(subcommand)]
+ command: Command,
}
-fn print_usage() {
- eprintln!(
- "usage: mmx150d <status|daemon>\n\
- \n\
- daemon options:\n\
- \x20 --on-connect CMD run CMD (via sh -c) when the headset connects\n\
- \x20 --on-disconnect CMD run CMD (via sh -c) when the headset disconnects\n\
- \x20 --interval SECS poll interval (default: 2)\n\
- \x20 --no-initial do not fire a hook for the state found at startup\n\
- \n\
- status exit codes: 0 connected, 1 disconnected, 2 dongle missing/error\n\
- \n\
- example:\n\
- \x20 mmx150d daemon \\\n\
- \x20 --on-connect 'wpctl set-default <mmx150-sink-id>' \\\n\
- \x20 --on-disconnect 'wpctl set-default <speakers-sink-id>'\n\
- \n\
- find sink ids with: wpctl status"
- );
+#[derive(Subcommand, Debug)]
+enum Command {
+ /// Print the current headset link state
+ Status,
+ /// Run hooks when the headset connects or disconnects
+ Daemon {
+ /// Run CMD (via sh -c) when the headset connects
+ #[arg(long)]
+ on_connect: Option<String>,
+ /// Run CMD (via sh -c) when the headset disconnects
+ #[arg(long)]
+ on_disconnect: Option<String>,
+ /// Poll interval in seconds
+ #[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u64).range(1..))]
+ interval: u64,
+ /// Do not fire a hook for the state found at startup
+ #[arg(long)]
+ no_initial: bool,
+ },
+}
+
+fn main() -> ExitCode {
+ let cli = Cli::parse();
+ match cli.command {
+ Command::Status => cmd_status(),
+ Command::Daemon {
+ on_connect,
+ on_disconnect,
+ interval,
+ no_initial,
+ } => cmd_daemon(
+ on_connect.as_deref(),
+ on_disconnect.as_deref(),
+ interval,
+ no_initial,
+ ),
+ }
}
fn cmd_status() -> ExitCode {
@@ -67,17 +97,17 @@ fn cmd_status() -> ExitCode {
}
}
}
-fn cmd_daemon(args: &[String]) -> ExitCode {
- let opts = match DaemonArgs::parse(args) {
- Ok(o) => o,
- Err(e) => {
- eprintln!("{e}");
- return ExitCode::FAILURE;
- }
- };
+
+fn cmd_daemon(
+ on_connect: Option<&str>,
+ on_disconnect: Option<&str>,
+ interval_secs: u64,
+ no_initial: bool,
+) -> ExitCode {
+ let interval = Duration::from_secs(interval_secs);
let mut hid: Option<Hidraw> = None;
- let mut tracker = StateTracker::new(opts.fire_initial);
+ let mut tracker = StateTracker::new(!no_initial);
let mut children: Vec<Child> = Vec::new();
loop {
@@ -88,13 +118,13 @@ fn cmd_daemon(args: &[String]) -> ExitCode {
match tracker.update(link_up) {
Some(Transition::ToConnected { initial }) => {
log_state("connected", initial);
- if let Some(cmd) = &opts.on_connect {
+ if let Some(cmd) = on_connect {
spawn_hook(cmd, &mut children);
}
}
Some(Transition::ToDisconnected { initial }) => {
log_state("disconnected", initial);
- if let Some(cmd) = &opts.on_disconnect {
+ if let Some(cmd) = on_disconnect {
spawn_hook(cmd, &mut children);
}
}
@@ -102,8 +132,8 @@ fn cmd_daemon(args: &[String]) -> ExitCode {
}
let elapsed = cycle.elapsed();
- if elapsed < opts.interval {
- std::thread::sleep(opts.interval - elapsed);
+ if let Some(remaining) = interval.checked_sub(elapsed) {
+ std::thread::sleep(remaining);
}
}
}
@@ -134,7 +164,7 @@ fn poll(hid: &mut Option<Hidraw>) -> bool {
}
fn spawn_hook(cmd: &str, children: &mut Vec<Child>) {
- match Command::new("sh")
+ match std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.stdin(Stdio::null())
@@ -164,80 +194,36 @@ fn log_state(state: &str, initial: bool) {
fn timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
- .map(|d| d.as_secs())
- .unwrap_or(0)
-}
-
-/// Parsed `mmx150 daemon` command line.
-#[derive(Debug, PartialEq)]
-struct DaemonArgs {
- on_connect: Option<String>,
- on_disconnect: Option<String>,
- interval: Duration,
- fire_initial: bool,
-}
-
-impl DaemonArgs {
- fn parse(args: &[String]) -> Result<DaemonArgs, String> {
- let mut on_connect = None;
- let mut on_disconnect = None;
- let mut interval = Duration::from_secs(2);
- let mut fire_initial = true;
-
- let mut it = args.iter();
- while let Some(arg) = it.next() {
- match arg.as_str() {
- "--on-connect" => {
- on_connect = Some(it.next().ok_or("--on-connect needs an argument")?.clone())
- }
- "--on-disconnect" => {
- on_disconnect = Some(
- it.next()
- .ok_or("--on-disconnect needs an argument")?
- .clone(),
- )
- }
- "--interval" => {
- let secs = it
- .next()
- .and_then(|s| s.parse::<u64>().ok())
- .filter(|&s| s > 0)
- .ok_or("--interval needs a number of seconds > 0")?;
- interval = Duration::from_secs(secs);
- }
- "--no-initial" => fire_initial = false,
- other => return Err(format!("unknown option: {other}")),
- }
- }
- Ok(DaemonArgs {
- on_connect,
- on_disconnect,
- interval,
- fire_initial,
- })
- }
+ .map_or(0, |d| d.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
- fn args(list: &[&str]) -> Vec<String> {
- list.iter().map(|s| s.to_string()).collect()
- }
-
#[test]
fn daemon_args_defaults() {
- let a = DaemonArgs::parse(&args(&[])).unwrap();
- assert_eq!(a.interval, Duration::from_secs(2));
- assert!(a.fire_initial);
- assert_eq!(a.on_connect, None);
- assert_eq!(a.on_disconnect, None);
+ let cli = Cli::try_parse_from(["mmx150d", "daemon"]).unwrap();
+ let Command::Daemon {
+ on_connect,
+ on_disconnect,
+ interval,
+ no_initial,
+ } = cli.command
+ else {
+ panic!("expected daemon subcommand");
+ };
+ assert_eq!(interval, 2);
+ assert!(!no_initial);
+ assert_eq!(on_connect, None);
+ assert_eq!(on_disconnect, None);
}
#[test]
fn daemon_args_full_parse() {
- let a = DaemonArgs::parse(&args(&[
+ let cli = Cli::try_parse_from([
+ "mmx150d",
+ "daemon",
"--on-connect",
"echo up",
"--on-disconnect",
@@ -245,29 +231,40 @@ mod tests {
"--interval",
"5",
"--no-initial",
- ]))
+ ])
.unwrap();
- assert_eq!(a.on_connect.as_deref(), Some("echo up"));
- assert_eq!(a.on_disconnect.as_deref(), Some("echo down"));
- assert_eq!(a.interval, Duration::from_secs(5));
- assert!(!a.fire_initial);
+ let Command::Daemon {
+ on_connect,
+ on_disconnect,
+ interval,
+ no_initial,
+ } = cli.command
+ else {
+ panic!("expected daemon subcommand");
+ };
+ assert_eq!(on_connect.as_deref(), Some("echo up"));
+ assert_eq!(on_disconnect.as_deref(), Some("echo down"));
+ assert_eq!(interval, 5);
+ assert!(no_initial);
}
#[test]
- fn daemon_args_rejects_missing_option_argument() {
- assert!(DaemonArgs::parse(&args(&["--on-connect"])).is_err());
- assert!(DaemonArgs::parse(&args(&["--on-disconnect"])).is_err());
- assert!(DaemonArgs::parse(&args(&["--interval"])).is_err());
+ fn daemon_args_rejects_bad_interval() {
+ assert!(Cli::try_parse_from(["mmx150d", "daemon", "--interval", "0"]).is_err());
+ assert!(Cli::try_parse_from(["mmx150d", "daemon", "--interval", "x"]).is_err());
+ assert!(Cli::try_parse_from(["mmx150d", "daemon", "--interval", "1"]).is_ok());
}
#[test]
- fn daemon_args_rejects_bad_interval() {
- assert!(DaemonArgs::parse(&args(&["--interval", "0"])).is_err());
- assert!(DaemonArgs::parse(&args(&["--interval", "x"])).is_err());
+ fn daemon_args_rejects_unknown_option() {
+ assert!(Cli::try_parse_from(["mmx150d", "daemon", "--wat"]).is_err());
}
#[test]
- fn daemon_args_rejects_unknown_option() {
- assert!(DaemonArgs::parse(&args(&["--wat"])).is_err());
+ fn status_subcommand_parses() {
+ assert!(matches!(
+ Cli::try_parse_from(["mmx150d", "status"]).unwrap().command,
+ Command::Status
+ ));
}
}