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 std::process::ExitCode;
5
6use anyhow::Result;
7use clap::Parser;
8use tracing_subscriber::{EnvFilter, fmt};
9
10mod cmd;
11
12/// OpenLogi: a local-first companion for Logitech HID++ peripherals.
13#[derive(Debug, Parser)]
14#[command(
15    name = "openlogi",
16    version,
17    about = "OpenLogi: a local-first companion for Logitech HID++ peripherals.",
18    long_about = None,
19)]
20struct Cli {
21    #[command(subcommand)]
22    cmd: Option<cmd::Command>,
23}
24
25/// Initialise logging, parse arguments, and dispatch the chosen subcommand.
26///
27/// Returns the exit status the process should terminate with — `list` uses a
28/// distinct one to report that no hardware is connected.
29pub async fn run() -> Result<ExitCode> {
30    fmt()
31        .with_writer(std::io::stderr)
32        .with_env_filter(
33            EnvFilter::try_from_env("OPENLOGI_LOG").unwrap_or_else(|_| EnvFilter::new("info")),
34        )
35        .init();
36
37    let cli = Cli::parse();
38    let command = cli
39        .cmd
40        .unwrap_or(cmd::Command::List(cmd::list::ListArgs {}));
41    command.run().await
42}
43
44#[cfg(test)]
45#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
46mod tests {
47    use clap::CommandFactory;
48
49    use super::*;
50    use cmd::Command;
51    use cmd::backlight::BacklightAction;
52    use cmd::diag::DiagCmd;
53    use cmd::diag::lighting::Method;
54    use cmd::diag::wheel::ResolutionArg;
55
56    /// Clap's own structural validation (arg ID collisions, invalid
57    /// `conflicts_with` targets, etc.) — cheap and catches a broken derive
58    /// tree before it ever reaches a user.
59    #[test]
60    fn cli_command_tree_is_well_formed() {
61        Cli::command().debug_assert();
62    }
63
64    /// A bare `openlogi` invocation must remain valid — `run()` defaults the
65    /// missing subcommand to `list`.
66    #[test]
67    fn bare_invocation_has_no_subcommand() {
68        let cli = Cli::try_parse_from(["openlogi"]).expect("bare invocation parses");
69        assert!(cli.cmd.is_none());
70    }
71
72    /// A bare `openlogi backlight` must stay valid — `run` treats a missing
73    /// action as `status`, so it can never write to the device by accident.
74    #[test]
75    fn backlight_defaults_to_status_and_accepts_a_device_filter() {
76        let cli = Cli::try_parse_from(["openlogi", "backlight", "--device", "MX KEYS S"])
77            .expect("bare backlight invocation parses");
78
79        match cli.cmd.expect("subcommand present") {
80            Command::Backlight(args) => {
81                assert_eq!(args.device.as_deref(), Some("MX KEYS S"));
82                assert!(args.action.is_none());
83            }
84            other => panic!("expected Backlight, got {other:?}"),
85        }
86    }
87
88    #[test]
89    fn backlight_off_is_parsed_as_its_own_action() {
90        let cli =
91            Cli::try_parse_from(["openlogi", "backlight", "off"]).expect("backlight off parses");
92
93        match cli.cmd.expect("subcommand present") {
94            Command::Backlight(args) => {
95                assert!(matches!(args.action, Some(BacklightAction::Off)));
96            }
97            other => panic!("expected Backlight, got {other:?}"),
98        }
99    }
100
101    #[test]
102    fn backlight_rejects_an_unknown_action() {
103        let result = Cli::try_parse_from(["openlogi", "backlight", "dim"]);
104        result.expect_err("an unknown backlight action must be rejected");
105    }
106
107    #[test]
108    fn smartshift_leave_flipped_conflicts_with_sensitivity() {
109        let result = Cli::try_parse_from([
110            "openlogi",
111            "diag",
112            "smartshift",
113            "--leave-flipped",
114            "--sensitivity",
115            "10",
116        ]);
117        result.expect_err("--leave-flipped and --sensitivity must conflict");
118    }
119
120    #[test]
121    fn smartshift_rejects_zero_sensitivity() {
122        // `--sensitivity` is a `NonZeroU8`; 0 must fail to parse rather than
123        // silently becoming "no change" downstream.
124        let result = Cli::try_parse_from(["openlogi", "diag", "smartshift", "--sensitivity", "0"]);
125        result.expect_err("a zero --sensitivity must fail to parse");
126    }
127
128    #[test]
129    fn dpi_target_and_device_flags_are_mapped() {
130        let cli = Cli::try_parse_from([
131            "openlogi",
132            "diag",
133            "dpi",
134            "--target",
135            "800",
136            "--device",
137            "MX Master",
138        ])
139        .expect("valid dpi invocation parses");
140
141        match cli.cmd.expect("subcommand present") {
142            Command::Diag(DiagCmd::Dpi(args)) => {
143                assert_eq!(args.target, Some(800));
144                assert_eq!(args.device.as_deref(), Some("MX Master"));
145            }
146            other => panic!("expected Diag(Dpi), got {other:?}"),
147        }
148    }
149
150    #[test]
151    fn lighting_color_is_positional_and_method_is_a_flag() {
152        let cli = Cli::try_parse_from([
153            "openlogi", "diag", "lighting", "ff0000", "--method", "effects",
154        ])
155        .expect("valid lighting invocation parses");
156
157        match cli.cmd.expect("subcommand present") {
158            Command::Diag(DiagCmd::Lighting(args)) => {
159                assert_eq!(args.color, "ff0000");
160                assert!(matches!(args.method, Method::Effects));
161            }
162            other => panic!("expected Diag(Lighting), got {other:?}"),
163        }
164    }
165
166    #[test]
167    fn lighting_rejects_unknown_method() {
168        let result = Cli::try_parse_from([
169            "openlogi", "diag", "lighting", "ff0000", "--method", "bogus",
170        ]);
171        result.expect_err("an unknown lighting method must be rejected");
172    }
173
174    #[test]
175    fn wheel_resolution_and_device_flags_are_mapped() {
176        let cli = Cli::try_parse_from([
177            "openlogi",
178            "diag",
179            "wheel",
180            "--device",
181            "MX Anywhere 3S",
182            "--resolution",
183            "low",
184        ])
185        .expect("valid wheel invocation parses");
186
187        match cli.cmd.expect("subcommand present") {
188            Command::Diag(DiagCmd::Wheel(args)) => {
189                assert_eq!(args.device.as_deref(), Some("MX Anywhere 3S"));
190                assert_eq!(args.resolution, Some(ResolutionArg::Low));
191            }
192            other => panic!("expected Diag(Wheel), got {other:?}"),
193        }
194    }
195}