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