use std::process::ExitCode;
use anyhow::Result;
use clap::Parser;
mod agent;
mod cmd;
#[derive(Debug, Parser)]
#[command(
name = "openlogi",
version,
about = "OpenLogi: a local-first companion for Logitech HID++ peripherals.",
long_about = None,
)]
struct Cli {
#[command(subcommand)]
cmd: Option<cmd::Command>,
}
pub async fn run() -> Result<ExitCode> {
openlogi_core::logging::init_stderr();
let cli = Cli::parse();
let command = cli
.cmd
.unwrap_or(cmd::Command::List(cmd::list::ListArgs {}));
command.run().await
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use super::*;
use cmd::Command;
use cmd::backlight::BacklightAction;
use cmd::diag::DiagCmd;
use cmd::diag::lighting::Method;
use cmd::diag::wheel::ResolutionArg;
use cmd::fixture::record_case::FixtureOperation;
use cmd::fixture::record_profile::RecordProfileArgs;
use cmd::fixture::verify::VerifyArgs;
use cmd::fixture::{FixtureCmd, FixtureRecordCmd};
#[test]
fn cli_command_tree_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn bare_invocation_has_no_subcommand() {
let cli = Cli::try_parse_from(["openlogi"]).expect("bare invocation parses");
assert!(cli.cmd.is_none());
}
#[test]
fn backlight_defaults_to_status_and_accepts_a_device_filter() {
let cli = Cli::try_parse_from(["openlogi", "backlight", "--device", "MX KEYS S"])
.expect("bare backlight invocation parses");
match cli.cmd.expect("subcommand present") {
Command::Backlight(args) => {
assert_eq!(args.device.as_deref(), Some("MX KEYS S"));
assert!(args.action.is_none());
}
other => panic!("expected Backlight, got {other:?}"),
}
}
#[test]
fn backlight_off_is_parsed_as_its_own_action() {
let cli =
Cli::try_parse_from(["openlogi", "backlight", "off"]).expect("backlight off parses");
match cli.cmd.expect("subcommand present") {
Command::Backlight(args) => {
assert!(matches!(args.action, Some(BacklightAction::Off)));
}
other => panic!("expected Backlight, got {other:?}"),
}
}
#[test]
fn backlight_rejects_an_unknown_action() {
let result = Cli::try_parse_from(["openlogi", "backlight", "dim"]);
result.expect_err("an unknown backlight action must be rejected");
}
#[test]
fn smartshift_leave_flipped_conflicts_with_sensitivity() {
let result = Cli::try_parse_from([
"openlogi",
"diag",
"smartshift",
"--leave-flipped",
"--sensitivity",
"10",
]);
result.expect_err("--leave-flipped and --sensitivity must conflict");
}
#[test]
fn smartshift_rejects_zero_sensitivity() {
let result = Cli::try_parse_from(["openlogi", "diag", "smartshift", "--sensitivity", "0"]);
result.expect_err("a zero --sensitivity must fail to parse");
}
#[test]
fn dpi_target_and_device_flags_are_mapped() {
let cli = Cli::try_parse_from([
"openlogi",
"diag",
"dpi",
"--target",
"800",
"--device",
"MX Master",
])
.expect("valid dpi invocation parses");
match cli.cmd.expect("subcommand present") {
Command::Diag(DiagCmd::Dpi(args)) => {
assert_eq!(args.target, Some(800));
assert_eq!(args.device.as_deref(), Some("MX Master"));
}
other => panic!("expected Diag(Dpi), got {other:?}"),
}
}
#[test]
fn lighting_color_is_positional_and_method_is_a_flag() {
let cli = Cli::try_parse_from([
"openlogi", "diag", "lighting", "ff0000", "--method", "effects",
])
.expect("valid lighting invocation parses");
match cli.cmd.expect("subcommand present") {
Command::Diag(DiagCmd::Lighting(args)) => {
assert_eq!(args.color, "ff0000");
assert!(matches!(args.method, Method::Effects));
}
other => panic!("expected Diag(Lighting), got {other:?}"),
}
}
#[test]
fn lighting_rejects_unknown_method() {
let result = Cli::try_parse_from([
"openlogi", "diag", "lighting", "ff0000", "--method", "bogus",
]);
result.expect_err("an unknown lighting method must be rejected");
}
#[test]
fn wheel_resolution_and_device_flags_are_mapped() {
let cli = Cli::try_parse_from([
"openlogi",
"diag",
"wheel",
"--device",
"MX Anywhere 3S",
"--resolution",
"low",
])
.expect("valid wheel invocation parses");
match cli.cmd.expect("subcommand present") {
Command::Diag(DiagCmd::Wheel(args)) => {
assert_eq!(args.device.as_deref(), Some("MX Anywhere 3S"));
assert_eq!(args.resolution, Some(ResolutionArg::Low));
}
other => panic!("expected Diag(Wheel), got {other:?}"),
}
}
#[test]
fn fixture_record_case_parses_every_read_only_operation() {
let operations = [
("feature-table", FixtureOperation::FeatureTable),
("firmware-entities", FixtureOperation::FirmwareEntities),
(
"reprogrammable-controls",
FixtureOperation::ReprogrammableControls,
),
("raw-battery", FixtureOperation::RawBattery),
("dpi-info", FixtureOperation::DpiInfo),
("smartshift-status", FixtureOperation::SmartshiftStatus),
("wheel-mode", FixtureOperation::WheelMode),
("backlight-state", FixtureOperation::BacklightState),
];
for (name, expected) in operations {
let cli = Cli::try_parse_from([
"openlogi",
"fixture",
"record",
"case",
"--operation",
name,
"--name",
"human name",
"--channel",
"logical-channel",
"--output",
"case.json",
"--device",
"MX Master 3S",
"--capacity",
"1024",
"--force",
])
.unwrap_or_else(|error| panic!("{name} should parse: {error}"));
match cli.cmd.expect("subcommand present") {
Command::Fixture(FixtureCmd::Record(FixtureRecordCmd::Case(args))) => {
assert_eq!(args.operation, expected);
assert_eq!(args.name, "human name");
assert_eq!(args.channel, "logical-channel");
assert_eq!(args.output, std::path::PathBuf::from("case.json"));
assert_eq!(args.device.as_deref(), Some("MX Master 3S"));
assert_eq!(args.capacity, 1024);
assert!(args.force);
}
other => panic!("expected Fixture(Record(Case)), got {other:?}"),
}
}
}
#[test]
fn fixture_contribution_wizard_parses_one_resumable_command() {
let cli = Cli::try_parse_from([
"openlogi",
"fixture",
"contribute",
"--id",
"mx-master-3s-001",
"--name",
"MX Master 3S contribution",
"--output",
"fixtures/devices/mx-master-3s-001",
"--device",
"MX Master 3S",
"--profile-only",
])
.expect("fixture contribution parses");
match cli.cmd.expect("subcommand present") {
Command::Fixture(FixtureCmd::Contribute(args)) => {
assert_eq!(args.id, "mx-master-3s-001");
assert_eq!(args.name, "MX Master 3S contribution");
assert_eq!(
args.output,
std::path::PathBuf::from("fixtures/devices/mx-master-3s-001")
);
assert_eq!(args.device.as_deref(), Some("MX Master 3S"));
assert!(args.profile_only);
}
other => panic!("expected Fixture(Contribute), got {other:?}"),
}
}
#[test]
fn fixture_record_case_requires_operation_metadata_and_output() {
let required_flags = ["--operation", "--name", "--channel", "--output"];
let complete = [
"--operation",
"feature-table",
"--name",
"human name",
"--channel",
"logical-channel",
"--output",
"case.json",
];
for missing in required_flags {
let mut args = vec!["openlogi", "fixture", "record", "case"];
let mut index = 0;
while index < complete.len() {
if complete[index] != missing {
args.extend_from_slice(&complete[index..=index + 1]);
}
index += 2;
}
Cli::try_parse_from(args)
.expect_err("every operation and metadata flag must be required");
}
}
#[test]
fn fixture_record_case_capacity_is_nonzero_and_bounded() {
let parse = |capacity| {
Cli::try_parse_from([
"openlogi",
"fixture",
"record",
"case",
"--operation",
"dpi-info",
"--name",
"dpi",
"--channel",
"direct",
"--output",
"case.json",
"--capacity",
capacity,
])
};
parse("1").expect("minimum capacity parses");
parse("65536").expect("maximum capacity parses");
parse("0").expect_err("zero capacity is rejected");
parse("65537").expect_err("capacity above the bound is rejected");
}
#[test]
fn fixture_record_case_exposes_no_write_pairing_or_allow_writes_path() {
for operation in ["set-dpi", "pair", "unpair", "raw-lighting"] {
Cli::try_parse_from([
"openlogi",
"fixture",
"record",
"case",
"--operation",
operation,
"--name",
"unsafe",
"--channel",
"direct",
"--output",
"case.json",
])
.expect_err("write and pairing operation names must be rejected");
}
Cli::try_parse_from([
"openlogi",
"fixture",
"record",
"case",
"--operation",
"dpi-info",
"--name",
"dpi",
"--channel",
"direct",
"--output",
"case.json",
"--allow-writes",
])
.expect_err("--allow-writes must not exist in this command");
}
#[test]
fn direct_fixture_help_discloses_receiver_discovery_side_effects() {
for args in [
vec!["openlogi", "fixture", "record", "case", "--help"],
vec!["openlogi", "fixture", "contribute", "--help"],
] {
let help = Cli::try_parse_from(args)
.expect_err("help exits without accessing hardware")
.to_string();
for disclosure in [
"wireless notifications",
"arrival reports",
"before target selection",
"not restored",
"do not change device settings or pairings",
] {
assert!(help.contains(disclosure), "missing {disclosure}: {help}");
}
}
}
#[test]
fn fixture_record_profile_requires_synthetic_metadata_and_output() {
let cli = Cli::try_parse_from([
"openlogi",
"fixture",
"record",
"profile",
"--id",
"mx-master-3s-001",
"--name",
"Synthetic MX Master 3S",
"--output",
"profile.json",
"--device",
"MX Master 3S",
"--force",
])
.expect("valid profile capture parses");
match cli.cmd.expect("subcommand present") {
Command::Fixture(FixtureCmd::Record(FixtureRecordCmd::Profile(
RecordProfileArgs {
id,
name,
output,
device,
force,
},
))) => {
assert_eq!(id, "mx-master-3s-001");
assert_eq!(name, "Synthetic MX Master 3S");
assert_eq!(output, std::path::PathBuf::from("profile.json"));
assert_eq!(device.as_deref(), Some("MX Master 3S"));
assert!(force);
}
other => panic!("expected Fixture(Record(Profile)), got {other:?}"),
}
let required_flags = ["--id", "--name", "--output"];
let complete = [
"--id",
"synthetic-id",
"--name",
"Synthetic profile",
"--output",
"profile.json",
];
for missing in required_flags {
let mut args = vec!["openlogi", "fixture", "record", "profile"];
let mut index = 0;
while index < complete.len() {
if complete[index] != missing {
args.extend_from_slice(&complete[index..=index + 1]);
}
index += 2;
}
Cli::try_parse_from(args).expect_err("profile metadata and output must be explicit");
}
}
#[test]
fn fixture_record_profile_help_is_agent_only_and_semantic() {
let command = Cli::command();
let mut profile = command
.find_subcommand("fixture")
.and_then(|fixture| fixture.find_subcommand("record"))
.and_then(|record| record.find_subcommand("profile"))
.expect("profile command exists")
.clone();
let help = profile.render_long_help().to_string();
assert!(help.contains("running Agent IPC"), "{help}");
assert!(help.contains("without direct hardware access"), "{help}");
assert!(help.contains("Synthetic fixture profile ID"), "{help}");
for forbidden in ["--direct", "--capacity", "--allow-writes", "--operation"] {
assert!(
!help.contains(forbidden),
"profile help exposed {forbidden}"
);
}
}
#[test]
fn fixture_record_profile_exposes_no_direct_raw_or_write_path() {
let base = [
"openlogi",
"fixture",
"record",
"profile",
"--id",
"synthetic-id",
"--name",
"Synthetic profile",
"--output",
"profile.json",
];
for forbidden in [
"--direct",
"--capacity",
"--allow-writes",
"--operation",
"--channel",
] {
let mut args = base.to_vec();
args.push(forbidden);
if forbidden == "--capacity" || forbidden == "--operation" || forbidden == "--channel" {
args.push("value");
}
Cli::try_parse_from(args).expect_err("profile capture must reject non-Agent paths");
}
}
#[test]
fn fixture_verify_accepts_exactly_one_corpus_directory() {
let cli = Cli::try_parse_from([
"openlogi",
"fixture",
"verify",
"fixtures/devices/mx-master-3s-001",
])
.expect("fixture verify parses");
match cli.cmd.expect("subcommand present") {
Command::Fixture(FixtureCmd::Verify(VerifyArgs { directory })) => {
assert_eq!(
directory,
std::path::PathBuf::from("fixtures/devices/mx-master-3s-001")
);
}
other => panic!("expected Fixture(Verify), got {other:?}"),
}
Cli::try_parse_from(["openlogi", "fixture", "verify"])
.expect_err("fixture directory must be required");
Cli::try_parse_from(["openlogi", "fixture", "verify", "one", "two"])
.expect_err("only one fixture directory may be verified at a time");
}
}