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)]
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 #[test]
59 fn cli_command_tree_is_well_formed() {
60 Cli::command().debug_assert();
61 }
62
63 #[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 #[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 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}