Skip to main content

openlogi_cli/
lib.rs

1//! OpenLogi CLI implementation. The `openlogi` binary is a thin wrapper that
2//! calls [`run`]; the command tree and argument parsing live here.
3
4use anyhow::Result;
5use clap::Parser;
6use tracing_subscriber::{EnvFilter, fmt};
7
8mod cmd;
9
10/// OpenLogi: a local-first companion for Logitech HID++ peripherals.
11#[derive(Debug, Parser)]
12#[command(
13    name = "openlogi",
14    version,
15    about = "OpenLogi: a local-first companion for Logitech HID++ peripherals.",
16    long_about = None,
17)]
18struct Cli {
19    #[command(subcommand)]
20    cmd: Option<cmd::Command>,
21}
22
23/// Initialise logging, parse arguments, and dispatch the chosen subcommand.
24pub async fn run() -> Result<()> {
25    fmt()
26        .with_writer(std::io::stderr)
27        .with_env_filter(
28            EnvFilter::try_from_env("OPENLOGI_LOG").unwrap_or_else(|_| EnvFilter::new("info")),
29        )
30        .init();
31
32    let cli = Cli::parse();
33    let command = cli
34        .cmd
35        .unwrap_or(cmd::Command::List(cmd::list::ListArgs {}));
36    command.run().await
37}
38
39#[cfg(test)]
40#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
41mod tests {
42    use clap::CommandFactory;
43
44    use super::*;
45    use cmd::Command;
46    use cmd::diag::DiagCmd;
47    use cmd::diag::lighting::Method;
48    use cmd::diag::wheel::ResolutionArg;
49
50    /// Clap's own structural validation (arg ID collisions, invalid
51    /// `conflicts_with` targets, etc.) — cheap and catches a broken derive
52    /// tree before it ever reaches a user.
53    #[test]
54    fn cli_command_tree_is_well_formed() {
55        Cli::command().debug_assert();
56    }
57
58    /// A bare `openlogi` invocation must remain valid — `run()` defaults the
59    /// missing subcommand to `list`.
60    #[test]
61    fn bare_invocation_has_no_subcommand() {
62        let cli = Cli::try_parse_from(["openlogi"]).expect("bare invocation parses");
63        assert!(cli.cmd.is_none());
64    }
65
66    #[test]
67    fn smartshift_leave_flipped_conflicts_with_sensitivity() {
68        let result = Cli::try_parse_from([
69            "openlogi",
70            "diag",
71            "smartshift",
72            "--leave-flipped",
73            "--sensitivity",
74            "10",
75        ]);
76        assert!(result.is_err());
77    }
78
79    #[test]
80    fn smartshift_rejects_zero_sensitivity() {
81        // `--sensitivity` is a `NonZeroU8`; 0 must fail to parse rather than
82        // silently becoming "no change" downstream.
83        let result = Cli::try_parse_from(["openlogi", "diag", "smartshift", "--sensitivity", "0"]);
84        assert!(result.is_err());
85    }
86
87    #[test]
88    fn dpi_target_and_device_flags_are_mapped() {
89        let cli = Cli::try_parse_from([
90            "openlogi",
91            "diag",
92            "dpi",
93            "--target",
94            "800",
95            "--device",
96            "MX Master",
97        ])
98        .expect("valid dpi invocation parses");
99
100        match cli.cmd.expect("subcommand present") {
101            Command::Diag(DiagCmd::Dpi(args)) => {
102                assert_eq!(args.target, Some(800));
103                assert_eq!(args.device.as_deref(), Some("MX Master"));
104            }
105            other => panic!("expected Diag(Dpi), got {other:?}"),
106        }
107    }
108
109    #[test]
110    fn lighting_color_is_positional_and_method_is_a_flag() {
111        let cli = Cli::try_parse_from([
112            "openlogi", "diag", "lighting", "ff0000", "--method", "effects",
113        ])
114        .expect("valid lighting invocation parses");
115
116        match cli.cmd.expect("subcommand present") {
117            Command::Diag(DiagCmd::Lighting(args)) => {
118                assert_eq!(args.color, "ff0000");
119                assert!(matches!(args.method, Method::Effects));
120            }
121            other => panic!("expected Diag(Lighting), got {other:?}"),
122        }
123    }
124
125    #[test]
126    fn lighting_rejects_unknown_method() {
127        let result = Cli::try_parse_from([
128            "openlogi", "diag", "lighting", "ff0000", "--method", "bogus",
129        ]);
130        assert!(result.is_err());
131    }
132
133    #[test]
134    fn wheel_resolution_and_device_flags_are_mapped() {
135        let cli = Cli::try_parse_from([
136            "openlogi",
137            "diag",
138            "wheel",
139            "--device",
140            "MX Anywhere 3S",
141            "--resolution",
142            "low",
143        ])
144        .expect("valid wheel invocation parses");
145
146        match cli.cmd.expect("subcommand present") {
147            Command::Diag(DiagCmd::Wheel(args)) => {
148                assert_eq!(args.device.as_deref(), Some("MX Anywhere 3S"));
149                assert_eq!(args.resolution, Some(ResolutionArg::Low));
150            }
151            other => panic!("expected Diag(Wheel), got {other:?}"),
152        }
153    }
154}