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