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 use cmd::fixture::record_case::FixtureOperation;
55 use cmd::fixture::record_profile::RecordProfileArgs;
56 use cmd::fixture::verify::VerifyArgs;
57 use cmd::fixture::{FixtureCmd, FixtureRecordCmd};
58
59 #[test]
63 fn cli_command_tree_is_well_formed() {
64 Cli::command().debug_assert();
65 }
66
67 #[test]
70 fn bare_invocation_has_no_subcommand() {
71 let cli = Cli::try_parse_from(["openlogi"]).expect("bare invocation parses");
72 assert!(cli.cmd.is_none());
73 }
74
75 #[test]
78 fn backlight_defaults_to_status_and_accepts_a_device_filter() {
79 let cli = Cli::try_parse_from(["openlogi", "backlight", "--device", "MX KEYS S"])
80 .expect("bare backlight invocation parses");
81
82 match cli.cmd.expect("subcommand present") {
83 Command::Backlight(args) => {
84 assert_eq!(args.device.as_deref(), Some("MX KEYS S"));
85 assert!(args.action.is_none());
86 }
87 other => panic!("expected Backlight, got {other:?}"),
88 }
89 }
90
91 #[test]
92 fn backlight_off_is_parsed_as_its_own_action() {
93 let cli =
94 Cli::try_parse_from(["openlogi", "backlight", "off"]).expect("backlight off parses");
95
96 match cli.cmd.expect("subcommand present") {
97 Command::Backlight(args) => {
98 assert!(matches!(args.action, Some(BacklightAction::Off)));
99 }
100 other => panic!("expected Backlight, got {other:?}"),
101 }
102 }
103
104 #[test]
105 fn backlight_rejects_an_unknown_action() {
106 let result = Cli::try_parse_from(["openlogi", "backlight", "dim"]);
107 result.expect_err("an unknown backlight action must be rejected");
108 }
109
110 #[test]
111 fn smartshift_leave_flipped_conflicts_with_sensitivity() {
112 let result = Cli::try_parse_from([
113 "openlogi",
114 "diag",
115 "smartshift",
116 "--leave-flipped",
117 "--sensitivity",
118 "10",
119 ]);
120 result.expect_err("--leave-flipped and --sensitivity must conflict");
121 }
122
123 #[test]
124 fn smartshift_rejects_zero_sensitivity() {
125 let result = Cli::try_parse_from(["openlogi", "diag", "smartshift", "--sensitivity", "0"]);
128 result.expect_err("a zero --sensitivity must fail to parse");
129 }
130
131 #[test]
132 fn dpi_target_and_device_flags_are_mapped() {
133 let cli = Cli::try_parse_from([
134 "openlogi",
135 "diag",
136 "dpi",
137 "--target",
138 "800",
139 "--device",
140 "MX Master",
141 ])
142 .expect("valid dpi invocation parses");
143
144 match cli.cmd.expect("subcommand present") {
145 Command::Diag(DiagCmd::Dpi(args)) => {
146 assert_eq!(args.target, Some(800));
147 assert_eq!(args.device.as_deref(), Some("MX Master"));
148 }
149 other => panic!("expected Diag(Dpi), got {other:?}"),
150 }
151 }
152
153 #[test]
154 fn lighting_color_is_positional_and_method_is_a_flag() {
155 let cli = Cli::try_parse_from([
156 "openlogi", "diag", "lighting", "ff0000", "--method", "effects",
157 ])
158 .expect("valid lighting invocation parses");
159
160 match cli.cmd.expect("subcommand present") {
161 Command::Diag(DiagCmd::Lighting(args)) => {
162 assert_eq!(args.color, "ff0000");
163 assert!(matches!(args.method, Method::Effects));
164 }
165 other => panic!("expected Diag(Lighting), got {other:?}"),
166 }
167 }
168
169 #[test]
170 fn lighting_rejects_unknown_method() {
171 let result = Cli::try_parse_from([
172 "openlogi", "diag", "lighting", "ff0000", "--method", "bogus",
173 ]);
174 result.expect_err("an unknown lighting method must be rejected");
175 }
176
177 #[test]
178 fn wheel_resolution_and_device_flags_are_mapped() {
179 let cli = Cli::try_parse_from([
180 "openlogi",
181 "diag",
182 "wheel",
183 "--device",
184 "MX Anywhere 3S",
185 "--resolution",
186 "low",
187 ])
188 .expect("valid wheel invocation parses");
189
190 match cli.cmd.expect("subcommand present") {
191 Command::Diag(DiagCmd::Wheel(args)) => {
192 assert_eq!(args.device.as_deref(), Some("MX Anywhere 3S"));
193 assert_eq!(args.resolution, Some(ResolutionArg::Low));
194 }
195 other => panic!("expected Diag(Wheel), got {other:?}"),
196 }
197 }
198
199 #[test]
200 fn fixture_record_case_parses_every_read_only_operation() {
201 let operations = [
202 ("feature-table", FixtureOperation::FeatureTable),
203 ("firmware-entities", FixtureOperation::FirmwareEntities),
204 (
205 "reprogrammable-controls",
206 FixtureOperation::ReprogrammableControls,
207 ),
208 ("raw-battery", FixtureOperation::RawBattery),
209 ("dpi-info", FixtureOperation::DpiInfo),
210 ("smartshift-status", FixtureOperation::SmartshiftStatus),
211 ("wheel-mode", FixtureOperation::WheelMode),
212 ("backlight-state", FixtureOperation::BacklightState),
213 ];
214
215 for (name, expected) in operations {
216 let cli = Cli::try_parse_from([
217 "openlogi",
218 "fixture",
219 "record",
220 "case",
221 "--operation",
222 name,
223 "--name",
224 "human name",
225 "--channel",
226 "logical-channel",
227 "--output",
228 "case.json",
229 "--device",
230 "MX Master 3S",
231 "--capacity",
232 "1024",
233 "--force",
234 ])
235 .unwrap_or_else(|error| panic!("{name} should parse: {error}"));
236
237 match cli.cmd.expect("subcommand present") {
238 Command::Fixture(FixtureCmd::Record(FixtureRecordCmd::Case(args))) => {
239 assert_eq!(args.operation, expected);
240 assert_eq!(args.name, "human name");
241 assert_eq!(args.channel, "logical-channel");
242 assert_eq!(args.output, std::path::PathBuf::from("case.json"));
243 assert_eq!(args.device.as_deref(), Some("MX Master 3S"));
244 assert_eq!(args.capacity, 1024);
245 assert!(args.force);
246 }
247 other => panic!("expected Fixture(Record(Case)), got {other:?}"),
248 }
249 }
250 }
251
252 #[test]
253 fn fixture_contribution_wizard_parses_one_resumable_command() {
254 let cli = Cli::try_parse_from([
255 "openlogi",
256 "fixture",
257 "contribute",
258 "--id",
259 "mx-master-3s-001",
260 "--name",
261 "MX Master 3S contribution",
262 "--output",
263 "fixtures/devices/mx-master-3s-001",
264 "--device",
265 "MX Master 3S",
266 "--profile-only",
267 ])
268 .expect("fixture contribution parses");
269
270 match cli.cmd.expect("subcommand present") {
271 Command::Fixture(FixtureCmd::Contribute(args)) => {
272 assert_eq!(args.id, "mx-master-3s-001");
273 assert_eq!(args.name, "MX Master 3S contribution");
274 assert_eq!(
275 args.output,
276 std::path::PathBuf::from("fixtures/devices/mx-master-3s-001")
277 );
278 assert_eq!(args.device.as_deref(), Some("MX Master 3S"));
279 assert!(args.profile_only);
280 }
281 other => panic!("expected Fixture(Contribute), got {other:?}"),
282 }
283 }
284
285 #[test]
286 fn fixture_record_case_requires_operation_metadata_and_output() {
287 let required_flags = ["--operation", "--name", "--channel", "--output"];
288 let complete = [
289 "--operation",
290 "feature-table",
291 "--name",
292 "human name",
293 "--channel",
294 "logical-channel",
295 "--output",
296 "case.json",
297 ];
298
299 for missing in required_flags {
300 let mut args = vec!["openlogi", "fixture", "record", "case"];
301 let mut index = 0;
302 while index < complete.len() {
303 if complete[index] != missing {
304 args.extend_from_slice(&complete[index..=index + 1]);
305 }
306 index += 2;
307 }
308 Cli::try_parse_from(args)
309 .expect_err("every operation and metadata flag must be required");
310 }
311 }
312
313 #[test]
314 fn fixture_record_case_capacity_is_nonzero_and_bounded() {
315 let parse = |capacity| {
316 Cli::try_parse_from([
317 "openlogi",
318 "fixture",
319 "record",
320 "case",
321 "--operation",
322 "dpi-info",
323 "--name",
324 "dpi",
325 "--channel",
326 "direct",
327 "--output",
328 "case.json",
329 "--capacity",
330 capacity,
331 ])
332 };
333
334 parse("1").expect("minimum capacity parses");
335 parse("65536").expect("maximum capacity parses");
336 parse("0").expect_err("zero capacity is rejected");
337 parse("65537").expect_err("capacity above the bound is rejected");
338 }
339
340 #[test]
341 fn fixture_record_case_exposes_no_write_pairing_or_allow_writes_path() {
342 for operation in ["set-dpi", "pair", "unpair", "raw-lighting"] {
343 Cli::try_parse_from([
344 "openlogi",
345 "fixture",
346 "record",
347 "case",
348 "--operation",
349 operation,
350 "--name",
351 "unsafe",
352 "--channel",
353 "direct",
354 "--output",
355 "case.json",
356 ])
357 .expect_err("write and pairing operation names must be rejected");
358 }
359
360 Cli::try_parse_from([
361 "openlogi",
362 "fixture",
363 "record",
364 "case",
365 "--operation",
366 "dpi-info",
367 "--name",
368 "dpi",
369 "--channel",
370 "direct",
371 "--output",
372 "case.json",
373 "--allow-writes",
374 ])
375 .expect_err("--allow-writes must not exist in this command");
376 }
377
378 #[test]
379 fn direct_fixture_help_discloses_receiver_discovery_side_effects() {
380 for args in [
381 vec!["openlogi", "fixture", "record", "case", "--help"],
382 vec!["openlogi", "fixture", "contribute", "--help"],
383 ] {
384 let help = Cli::try_parse_from(args)
385 .expect_err("help exits without accessing hardware")
386 .to_string();
387 for disclosure in [
388 "wireless notifications",
389 "arrival reports",
390 "before target selection",
391 "not restored",
392 "do not change device settings or pairings",
393 ] {
394 assert!(help.contains(disclosure), "missing {disclosure}: {help}");
395 }
396 }
397 }
398
399 #[test]
400 fn fixture_record_profile_requires_synthetic_metadata_and_output() {
401 let cli = Cli::try_parse_from([
402 "openlogi",
403 "fixture",
404 "record",
405 "profile",
406 "--id",
407 "mx-master-3s-001",
408 "--name",
409 "Synthetic MX Master 3S",
410 "--output",
411 "profile.json",
412 "--device",
413 "MX Master 3S",
414 "--force",
415 ])
416 .expect("valid profile capture parses");
417
418 match cli.cmd.expect("subcommand present") {
419 Command::Fixture(FixtureCmd::Record(FixtureRecordCmd::Profile(
420 RecordProfileArgs {
421 id,
422 name,
423 output,
424 device,
425 force,
426 },
427 ))) => {
428 assert_eq!(id, "mx-master-3s-001");
429 assert_eq!(name, "Synthetic MX Master 3S");
430 assert_eq!(output, std::path::PathBuf::from("profile.json"));
431 assert_eq!(device.as_deref(), Some("MX Master 3S"));
432 assert!(force);
433 }
434 other => panic!("expected Fixture(Record(Profile)), got {other:?}"),
435 }
436
437 let required_flags = ["--id", "--name", "--output"];
438 let complete = [
439 "--id",
440 "synthetic-id",
441 "--name",
442 "Synthetic profile",
443 "--output",
444 "profile.json",
445 ];
446 for missing in required_flags {
447 let mut args = vec!["openlogi", "fixture", "record", "profile"];
448 let mut index = 0;
449 while index < complete.len() {
450 if complete[index] != missing {
451 args.extend_from_slice(&complete[index..=index + 1]);
452 }
453 index += 2;
454 }
455 Cli::try_parse_from(args).expect_err("profile metadata and output must be explicit");
456 }
457 }
458
459 #[test]
460 fn fixture_record_profile_help_is_agent_only_and_semantic() {
461 let command = Cli::command();
462 let mut profile = command
463 .find_subcommand("fixture")
464 .and_then(|fixture| fixture.find_subcommand("record"))
465 .and_then(|record| record.find_subcommand("profile"))
466 .expect("profile command exists")
467 .clone();
468 let help = profile.render_long_help().to_string();
469
470 assert!(help.contains("running Agent IPC"), "{help}");
471 assert!(help.contains("without direct hardware access"), "{help}");
472 assert!(help.contains("Synthetic fixture profile ID"), "{help}");
473 for forbidden in ["--direct", "--capacity", "--allow-writes", "--operation"] {
474 assert!(
475 !help.contains(forbidden),
476 "profile help exposed {forbidden}"
477 );
478 }
479 }
480
481 #[test]
482 fn fixture_record_profile_exposes_no_direct_raw_or_write_path() {
483 let base = [
484 "openlogi",
485 "fixture",
486 "record",
487 "profile",
488 "--id",
489 "synthetic-id",
490 "--name",
491 "Synthetic profile",
492 "--output",
493 "profile.json",
494 ];
495 for forbidden in [
496 "--direct",
497 "--capacity",
498 "--allow-writes",
499 "--operation",
500 "--channel",
501 ] {
502 let mut args = base.to_vec();
503 args.push(forbidden);
504 if forbidden == "--capacity" || forbidden == "--operation" || forbidden == "--channel" {
505 args.push("value");
506 }
507 Cli::try_parse_from(args).expect_err("profile capture must reject non-Agent paths");
508 }
509 }
510
511 #[test]
512 fn fixture_verify_accepts_exactly_one_corpus_directory() {
513 let cli = Cli::try_parse_from([
514 "openlogi",
515 "fixture",
516 "verify",
517 "fixtures/devices/mx-master-3s-001",
518 ])
519 .expect("fixture verify parses");
520
521 match cli.cmd.expect("subcommand present") {
522 Command::Fixture(FixtureCmd::Verify(VerifyArgs { directory })) => {
523 assert_eq!(
524 directory,
525 std::path::PathBuf::from("fixtures/devices/mx-master-3s-001")
526 );
527 }
528 other => panic!("expected Fixture(Verify), got {other:?}"),
529 }
530
531 Cli::try_parse_from(["openlogi", "fixture", "verify"])
532 .expect_err("fixture directory must be required");
533 Cli::try_parse_from(["openlogi", "fixture", "verify", "one", "two"])
534 .expect_err("only one fixture directory may be verified at a time");
535 }
536}