1use std::process::ExitCode;
5
6use anyhow::Result;
7use clap::Parser;
8use tracing_subscriber::{EnvFilter, fmt};
9
10mod cmd;
11
12#[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
25pub 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 #[test]
60 fn cli_command_tree_is_well_formed() {
61 Cli::command().debug_assert();
62 }
63
64 #[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 #[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 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}