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;
8use tracing_subscriber::{EnvFilter, fmt};
9
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    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    /// Clap's own structural validation (arg ID collisions, invalid
60    /// `conflicts_with` targets, etc.) — cheap and catches a broken derive
61    /// tree before it ever reaches a user.
62    #[test]
63    fn cli_command_tree_is_well_formed() {
64        Cli::command().debug_assert();
65    }
66
67    /// A bare `openlogi` invocation must remain valid — `run()` defaults the
68    /// missing subcommand to `list`.
69    #[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    /// A bare `openlogi backlight` must stay valid — `run` treats a missing
76    /// action as `status`, so it can never write to the device by accident.
77    #[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        // `--sensitivity` is a `NonZeroU8`; 0 must fail to parse rather than
126        // silently becoming "no change" downstream.
127        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}