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 std::process::ExitCode;
5
6use anyhow::Result;
7use clap::Parser;
8
9mod agent;
10mod cmd;
11
12/// OpenLogi: a local-first companion for Logitech HID++ peripherals.
13#[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
25/// Initialise logging, parse arguments, and dispatch the chosen subcommand.
26///
27/// Returns the exit status the process should terminate with — `list` uses a
28/// distinct one to report that no hardware is connected.
29pub 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    /// Clap's own structural validation (arg ID collisions, invalid
55    /// `conflicts_with` targets, etc.) — cheap and catches a broken derive
56    /// tree before it ever reaches a user.
57    #[test]
58    fn cli_command_tree_is_well_formed() {
59        Cli::command().debug_assert();
60    }
61
62    /// A bare `openlogi` invocation must remain valid — `run()` defaults the
63    /// missing subcommand to `list`.
64    #[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    /// A bare `openlogi backlight` must stay valid — `run` treats a missing
71    /// action as `status`, so it can never write to the device by accident.
72    #[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        // `--sensitivity` is a `NonZeroU8`; 0 must fail to parse rather than
121        // silently becoming "no change" downstream.
122        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}