add modifiers.rs

This commit is contained in:
Brad Warren
2026-06-10 15:52:16 -07:00
committed by Will Greenberg
parent 30c4cb0e0c
commit 3b3532d3fd
3 changed files with 333 additions and 23 deletions
+65 -23
View File
@@ -1,54 +1,96 @@
//! Combines the values we care about from the clap CLI and the modifiers module into something
//! serializable by serde.
use std::collections::HashMap;
use serde::Serialize;
#[derive(Serialize, Debug)]
use crate::modifiers;
#[derive(Debug, Serialize)]
pub struct Command<'a> {
subcommands: Vec<Subcommand<'a>>,
}
impl Command<'_> {
pub fn new(clap_command: &clap::Command) -> Command<'_> {
pub fn new(command: &clap::Command) -> Command<'_> {
let subcommand_map: HashMap<&str, &clap::Command> = command
.get_subcommands()
.map(|s| (s.get_name(), s))
.collect();
Command {
subcommands: clap_command
.get_subcommands()
// at least for now, we filter out subcommands that themselves have subcommands like
// "util" as supporting these would require additional changes to both the frontend
// and this module
.filter(|c| !c.has_subcommands())
.map(|c| Subcommand::new(c))
// this resulting vector contains the subcommands that are found in both
// command.get_subcommands() and modifiers::subcommand_modifiers() in the order defined
// by subcommand_modifiers()
subcommands: modifiers::subcommand_modifiers()
.iter()
.filter_map(|modifier| {
subcommand_map
.get(modifier.command)
.map(|subcommand| Subcommand::new(subcommand, modifier))
})
.collect(),
}
}
}
#[derive(Serialize, Debug)]
#[derive(Debug, Serialize)]
struct Argument<'a> {
name: &'a str,
advanced: bool,
flag: String,
label: &'a str,
takes_values: bool,
}
#[derive(Serialize, Debug)]
#[derive(Debug, Serialize)]
struct Subcommand<'a> {
arguments: Vec<Argument<'a>>,
name: &'a str,
command: &'a str,
label: &'a str,
}
impl Argument<'_> {
fn new(clap_argument: &clap::Arg) -> Argument<'_> {
Argument {
name: clap_argument.get_id().as_str(),
takes_values: clap_argument.get_action().takes_values(),
}
fn new<'a>(
argument: &'a clap::Arg,
modifier: &modifiers::ArgumentModifier<'static>,
) -> Option<Argument<'a>> {
// if an argument doesn't have the data we need, it's silently dropped from the GUI, however,
// tests should prevent this from happening and we could add logging messages about this in
// the future if desired
Some(Argument {
advanced: modifier.advanced,
flag: format!("--{}", argument.get_long()?),
label: modifier.gui_label,
takes_values: argument.get_action().takes_values(),
})
}
}
impl Subcommand<'_> {
fn new(clap_command: &clap::Command) -> Subcommand<'_> {
fn new<'a>(
command: &'a clap::Command,
modifier: &modifiers::SubcommandModifier<'static>,
) -> Subcommand<'a> {
let argument_map: HashMap<&str, &clap::Arg> = command
.get_arguments()
.map(|a| (a.get_id().as_str(), a))
.collect();
Subcommand {
arguments: clap_command
.get_arguments()
.map(|a| Argument::new(a))
// this resulting vector contains the arguments that are found in both
// command.get_arguments() and modifier.arg_modifiers in the order defined by by
// arg_modifiers
arguments: modifier
.arg_modifiers
.iter()
.filter_map(|arg_modifier| {
argument_map
.get(arg_modifier.clap_id)
.and_then(|arg| Argument::new(arg, arg_modifier))
})
.collect(),
name: clap_command.get_name(),
command: modifier.command,
label: modifier.gui_label,
}
}
}
+1
View File
@@ -5,6 +5,7 @@ use clap::CommandFactory;
use tauri::Emitter;
mod introspect;
mod modifiers;
static INSTALLER_COMMAND: LazyLock<clap::Command> = LazyLock::new(installer::Args::command);
+267
View File
@@ -0,0 +1,267 @@
//! Adds or "modifies" installer CLI attributes for use in the GUI.
//!
//! This module contains little logic (outside of tests) and instead just provides additional
//! metadata about CLI commands and options for the GUI installer.
//!
//! If we like this approach, I think we should consider renaming this file something like
//! gui_modifiers.rs and moving it into the crate for the CLI installer. I think this would simplify
//! development as any breaking changes to the CLI installer interface would cause tests to fail in
//! its own crate instead of installer-gui and it'd help to keep the two interfaces to the installer
//! in sync.
#[derive(Debug, Copy, Clone)]
pub struct ArgumentModifier<'a> {
/// The name or "ID" of the argument as defined in clap. This will usually be the name of the
/// field in the struct the argument is derived from.
pub clap_id: &'a str,
/// The text for displaying this argument in the GUI.
pub gui_label: &'a str,
/// Whether this argument should be hidden behind a menu for "advanced" options.
pub advanced: bool,
}
#[derive(Debug)]
pub struct SubcommandModifier<'a> {
/// The name of the subcommand on the CLI.
pub command: &'a str,
/// The text for displaying this subcommand in the GUI.
pub gui_label: &'a str,
/// Modifications to the arguments of this subcommand. The order arguments are defined in this
/// vector will match the order the arguments are displayed in the GUI.
pub arg_modifiers: Vec<ArgumentModifier<'a>>,
}
/// Provides "modifiers" or additional metadata about each subcommand.
///
/// The order of the subcommands in the returned vector is the same order that subcommands will be
/// shown in the GUI.
pub fn subcommand_modifiers() -> Vec<SubcommandModifier<'static>> {
let admin_ip = ArgumentModifier {
clap_id: "admin_ip",
gui_label: "Admin IP",
advanced: true,
};
let admin_username = ArgumentModifier {
clap_id: "admin_username",
gui_label: "Admin Username",
advanced: true,
};
let admin_password = ArgumentModifier {
clap_id: "admin_password",
gui_label: "Admin Password",
advanced: false,
};
let data_dir = ArgumentModifier {
clap_id: "data_dir",
gui_label: "Data Directory",
advanced: true,
};
let reset_config = ArgumentModifier {
clap_id: "reset_config",
gui_label: "Reset config.toml",
advanced: true,
};
let orbic_and_moxee_args = vec![
admin_password,
admin_ip,
admin_username,
reset_config,
data_dir,
];
vec![
SubcommandModifier {
command: "orbic",
gui_label: "Orbic/Kajeet (via network)",
arg_modifiers: orbic_and_moxee_args.clone(),
},
SubcommandModifier {
command: "orbic-usb",
gui_label: "Orbic/Kajeet (via legacy USB+ADB installer)",
arg_modifiers: vec![reset_config],
},
SubcommandModifier {
command: "tplink",
gui_label: "TP-Link",
arg_modifiers: vec![
admin_ip,
reset_config,
data_dir,
ArgumentModifier {
clap_id: "skip_sdcard",
gui_label: "Skip SD Card",
advanced: true,
},
ArgumentModifier {
clap_id: "sdcard_path",
gui_label: "SD Card Path",
advanced: true,
},
],
},
SubcommandModifier {
command: "moxee",
gui_label: "Moxee",
arg_modifiers: orbic_and_moxee_args,
},
SubcommandModifier {
command: "pinephone",
gui_label: "PinePhone",
arg_modifiers: vec![],
},
SubcommandModifier {
command: "tmobile",
gui_label: "TMobile",
arg_modifiers: vec![admin_password, admin_ip],
},
SubcommandModifier {
command: "uz801",
gui_label: "UZ801",
arg_modifiers: vec![admin_ip],
},
SubcommandModifier {
command: "wingtech",
gui_label: "Wingtech",
arg_modifiers: vec![admin_password, admin_ip],
},
]
}
#[cfg(test)]
mod tests {
//! Subcommands and arguments not returned from subcommand_modifiers() will be excluded from the
//! GUI. This is by design as it allows us to exclude things like some or all of the installer
//! utils from the GUI. The tests below help ensure that exclusions were done deliberately
//! rather than on accident.
use super::*;
use std::collections::HashMap;
/// Lists the subcommands that are purposefully excluded from subcommand_modifiers().
fn excluded_subcommands() -> Vec<&'static str> {
vec!["util"]
}
/// Lists the arguments that are purposefully excluded from subcommand_modifiers(). Items in the
/// list take the form of (subcommand, argument_id) tuples.
fn excluded_arguments() -> Vec<(&'static str, &'static str)> {
// if for example we wanted to exclude the "--admin-password" argument for "orbic", we'd
// return vec![("orbic", "admin_password")] here
vec![]
}
#[test]
fn test_subcommands_excluded_or_modified() {
let mut all_subcommands: Vec<&str> = crate::INSTALLER_COMMAND
.get_subcommands()
.map(|c| c.get_name())
.collect();
let mut excluded_or_modified_subcommands: Vec<&str> = subcommand_modifiers()
.into_iter()
.map(|m| m.command)
.chain(excluded_subcommands())
.collect();
all_subcommands.sort_unstable();
excluded_or_modified_subcommands.sort_unstable();
assert_eq!(
all_subcommands, excluded_or_modified_subcommands,
"Every subcommand must be included exactly once in subcommand_modifiers() or excluded_subcommands()."
);
}
#[test]
fn test_arguments_excluded_or_modified() {
// create maps of subcommand name to lists of argument names
let all_args_for_nonexcluded_subcommands: HashMap<&str, Vec<&str>> =
nonexcluded_subcommand_objects()
.into_iter()
.map(|c| {
(
c.get_name(),
c.get_arguments().map(|a| a.get_id().as_str()).collect(),
)
})
.collect();
let modified_args: HashMap<&str, Vec<&str>> = subcommand_modifiers()
.into_iter()
.map(|m| {
(
m.command,
m.arg_modifiers
.into_iter()
.map(|arg_m| arg_m.clap_id)
.collect(),
)
})
.collect();
// add excluded_arguments to modified_args
let mut excluded_or_modified_args = modified_args;
for (subcommand_name, arg_name) in excluded_arguments() {
excluded_or_modified_args
.entry(subcommand_name)
.or_default()
.push(arg_name);
}
// assert that all arguments are excluded or modified
for (subcommand_name, mut expected_args) in all_args_for_nonexcluded_subcommands {
let mut found_args = excluded_or_modified_args
.remove(subcommand_name)
.unwrap_or_default();
expected_args.sort_unstable();
found_args.sort_unstable();
assert_eq!(
expected_args, found_args,
"Excluded and modified arguments differ from expected arguments for {subcommand_name}."
)
}
assert!(
excluded_or_modified_args.is_empty(),
"Excluded or modified arguments found for unexpected subcommands. Map of unexpected arguments is {:?}",
excluded_or_modified_args
);
}
#[test]
fn test_arguments_have_long_flag() {
// any arguments without a long form command line flag will be excluded from the GUI so
// let's test for it here to avoid surprises
let excluded_args = excluded_arguments();
let nonexcluded_args: Vec<(&str, &clap::Arg)> = nonexcluded_subcommand_objects()
.into_iter()
.flat_map(|c| {
c.get_arguments().filter_map(|a| {
let subcommand_name = c.get_name();
if excluded_args.contains(&(subcommand_name, a.get_id().as_str())) {
None
} else {
Some((subcommand_name, a))
}
})
})
.collect();
for (subcommand_name, arg) in nonexcluded_args {
assert!(
arg.get_long().is_some(),
"The {} argument for {subcommand_name} is missing a long form command line flag.",
arg.get_id().as_str()
)
}
}
fn nonexcluded_subcommand_objects() -> Vec<&'static clap::Command> {
let excluded_subcommands = excluded_subcommands();
crate::INSTALLER_COMMAND
.get_subcommands()
.filter(|s| !excluded_subcommands.contains(&s.get_name()))
.collect()
}
}