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)]
45mod tests {
46    use clap::CommandFactory;
47
48    use super::*;
49    use cmd::Command;
50    use cmd::backlight::BacklightAction;
51    use cmd::diag::DiagCmd;
52    use cmd::diag::lighting::Method;
53    use cmd::diag::wheel::ResolutionArg;
54
55    /// Clap's own structural validation (arg ID collisions, invalid
56    /// `conflicts_with` targets, etc.) — cheap and catches a broken derive
57    /// tree before it ever reaches a user.
58    #[test]
59    fn cli_command_tree_is_well_formed() {
60        Cli::command().debug_assert();
61    }
62
63    /// A bare `openlogi` invocation must remain valid — `run()` defaults the
64    /// missing subcommand to `list`.
65    #[test]
66    fn bare_invocation_has_no_subcommand() {
67        let cli = Cli::try_parse_from(["openlogi"]).expect("bare invocation parses");
68        assert!(cli.cmd.is_none());
69    }
70
71    /// A bare `openlogi backlight` must stay valid — `run` treats a missing
72    /// action as `status`, so it can never write to the device by accident.
73    #[test]
74    fn backlight_defaults_to_status_and_accepts_a_device_filter() {
75        let cli = Cli::try_parse_from(["openlogi", "backlight", "--device", "MX KEYS S"])
76            .expect("bare backlight invocation parses");
77
78        match cli.cmd.expect("subcommand present") {
79            Command::Backlight(args) => {
80                assert_eq!(args.device.as_deref(), Some("MX KEYS S"));
81                assert!(args.action.is_none());
82            }
83            other => panic!("expected Backlight, got {other:?}"),
84        }
85    }
86
87    #[test]
88    fn backlight_off_is_parsed_as_its_own_action() {
89        let cli =
90            Cli::try_parse_from(["openlogi", "backlight", "off"]).expect("backlight off parses");
91
92        match cli.cmd.expect("subcommand present") {
93            Command::Backlight(args) => {
94                assert!(matches!(args.action, Some(BacklightAction::Off)));
95            }
96            other => panic!("expected Backlight, got {other:?}"),
97        }
98    }
99
100    #[test]
101    fn backlight_rejects_an_unknown_action() {
102        let result = Cli::try_parse_from(["openlogi", "backlight", "dim"]);
103        result.expect_err("an unknown backlight action must be rejected");
104    }
105
106    #[test]
107    fn smartshift_leave_flipped_conflicts_with_sensitivity() {
108        let result = Cli::try_parse_from([
109            "openlogi",
110            "diag",
111            "smartshift",
112            "--leave-flipped",
113            "--sensitivity",
114            "10",
115        ]);
116        result.expect_err("--leave-flipped and --sensitivity must conflict");
117    }
118
119    #[test]
120    fn smartshift_rejects_zero_sensitivity() {
121        // `--sensitivity` is a `NonZeroU8`; 0 must fail to parse rather than
122        // silently becoming "no change" downstream.
123        let result = Cli::try_parse_from(["openlogi", "diag", "smartshift", "--sensitivity", "0"]);
124        result.expect_err("a zero --sensitivity must fail to parse");
125    }
126
127    #[test]
128    fn dpi_target_and_device_flags_are_mapped() {
129        let cli = Cli::try_parse_from([
130            "openlogi",
131            "diag",
132            "dpi",
133            "--target",
134            "800",
135            "--device",
136            "MX Master",
137        ])
138        .expect("valid dpi invocation parses");
139
140        match cli.cmd.expect("subcommand present") {
141            Command::Diag(DiagCmd::Dpi(args)) => {
142                assert_eq!(args.target, Some(800));
143                assert_eq!(args.device.as_deref(), Some("MX Master"));
144            }
145            other => panic!("expected Diag(Dpi), got {other:?}"),
146        }
147    }
148
149    #[test]
150    fn lighting_color_is_positional_and_method_is_a_flag() {
151        let cli = Cli::try_parse_from([
152            "openlogi", "diag", "lighting", "ff0000", "--method", "effects",
153        ])
154        .expect("valid lighting invocation parses");
155
156        match cli.cmd.expect("subcommand present") {
157            Command::Diag(DiagCmd::Lighting(args)) => {
158                assert_eq!(args.color, "ff0000");
159                assert!(matches!(args.method, Method::Effects));
160            }
161            other => panic!("expected Diag(Lighting), got {other:?}"),
162        }
163    }
164
165    #[test]
166    fn lighting_rejects_unknown_method() {
167        let result = Cli::try_parse_from([
168            "openlogi", "diag", "lighting", "ff0000", "--method", "bogus",
169        ]);
170        result.expect_err("an unknown lighting method must be rejected");
171    }
172
173    #[test]
174    fn wheel_resolution_and_device_flags_are_mapped() {
175        let cli = Cli::try_parse_from([
176            "openlogi",
177            "diag",
178            "wheel",
179            "--device",
180            "MX Anywhere 3S",
181            "--resolution",
182            "low",
183        ])
184        .expect("valid wheel invocation parses");
185
186        match cli.cmd.expect("subcommand present") {
187            Command::Diag(DiagCmd::Wheel(args)) => {
188                assert_eq!(args.device.as_deref(), Some("MX Anywhere 3S"));
189                assert_eq!(args.resolution, Some(ResolutionArg::Low));
190            }
191            other => panic!("expected Diag(Wheel), got {other:?}"),
192        }
193    }
194}