use std::fmt::Write as _;
use std::path::PathBuf;
use crate::config::{self, FieldKind, FieldSpec, FieldValue, FIELDS};
pub const CONFIG_FLAG: &str = "--config";
pub const ALLOW_ROOT_FLAG: &str = "--allow-root";
pub const NO_AUTH_FLAG: &str = "--no-auth";
pub const NO_AUTH_HINT: &str = "Use --no-auth if you know what you are doing";
pub const MISSING_CREDENTIALS: &str = concat!(
"no username and PIN, so the service would serve anyone who reaches the port. ",
"Pass --user and --pin, or export MOBUX_AUTH_USER and MOBUX_PIN. ",
"A proxy in front of mobux can authenticate instead. ",
"Use --no-auth if you know what you are doing"
);
pub type CliOverrides = config::PartialConfig;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct RunOptions {
pub overrides: CliOverrides,
pub config_path: Option<PathBuf>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct InstallOptions {
pub run: RunOptions,
pub allow_root: bool,
pub no_auth: bool,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceCommand {
Install(InstallOptions),
Uninstall,
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateCommand {
Check,
Apply,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigureCommand {
Interactive { force: bool },
Schema,
Check(Option<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Parsed {
Run(RunOptions),
Service(ServiceCommand),
Update(UpdateCommand),
Configure(ConfigureCommand),
Help,
Version,
Invalid(String),
}
pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Parsed {
let mut args = args.into_iter().peekable();
if args.peek().is_some_and(|arg| arg == "service") {
args.next();
return parse_service(args);
}
if args.peek().is_some_and(|arg| arg == "update") {
args.next();
return parse_update(args);
}
if args.peek().is_some_and(|arg| arg == "configure") {
args.next();
return parse_configure(args);
}
match parse_flags(args) {
Ok(options) => Parsed::Run(options),
Err(stop) => stop.parsed(),
}
}
fn parse_service<I: Iterator<Item = String>>(mut args: I) -> Parsed {
let Some(subcommand) = args.next() else {
return Parsed::Invalid(
"service needs a subcommand: install, uninstall or status".to_string(),
);
};
match subcommand.as_str() {
"--help" | "-h" => Parsed::Help,
"install" => {
let mut allow_root = false;
let mut no_auth = false;
let flags: Vec<String> = args
.filter(|arg| {
allow_root |= arg == ALLOW_ROOT_FLAG;
no_auth |= arg == NO_AUTH_FLAG;
arg != ALLOW_ROOT_FLAG && arg != NO_AUTH_FLAG
})
.collect();
let run = match parse_flags(flags) {
Ok(run) => run,
Err(stop) => return stop.parsed(),
};
if no_auth && states_credentials(&run.overrides) {
return Parsed::Invalid(format!(
"{NO_AUTH_FLAG} serves the UI to anyone who reaches it, so it cannot be \
combined with --user or --pin. Pass one or the other."
));
}
Parsed::Service(ServiceCommand::Install(InstallOptions {
run,
allow_root,
no_auth,
}))
}
"uninstall" | "status" => {
let command = match subcommand.as_str() {
"uninstall" => ServiceCommand::Uninstall,
_ => ServiceCommand::Status,
};
match args.next() {
None => Parsed::Service(command),
Some(extra) if extra == "--help" || extra == "-h" => Parsed::Help,
Some(extra) => Parsed::Invalid(format!(
"service {subcommand} takes no arguments, got {extra:?}"
)),
}
}
_ => Parsed::Invalid(format!(
"unknown service subcommand {subcommand:?} — expected install, uninstall or status"
)),
}
}
fn states_credentials(overrides: &CliOverrides) -> bool {
overrides
.auth
.as_ref()
.is_some_and(|auth| auth.user.is_some() || auth.pin.is_some() || auth.pass.is_some())
}
fn parse_update<I: Iterator<Item = String>>(mut args: I) -> Parsed {
let mut command = UpdateCommand::Apply;
for arg in args.by_ref() {
match arg.as_str() {
"--help" | "-h" => return Parsed::Help,
"--check" => command = UpdateCommand::Check,
_ => {
return Parsed::Invalid(format!(
"unknown argument {arg:?} — `mobux update` takes only --check"
))
}
}
}
Parsed::Update(command)
}
fn parse_configure<I: Iterator<Item = String>>(args: I) -> Parsed {
let mut mode: Option<ConfigureCommand> = None;
let mut force = false;
let mut args = args.peekable();
while let Some(arg) = args.next() {
let chosen = match arg.as_str() {
"--help" | "-h" => return Parsed::Help,
"--force" | "-f" => {
force = true;
continue;
}
"--interactive" | "-i" => ConfigureCommand::Interactive { force: false },
"--schema" => ConfigureCommand::Schema,
"--check" => {
let path = args.next_if(|next| !next.starts_with('-'));
ConfigureCommand::Check(path)
}
_ => {
return Parsed::Invalid(format!(
"unknown argument {arg:?} — `mobux configure` takes --interactive, --schema, \
--check [PATH] or --force"
))
}
};
if mode.is_some() {
return Parsed::Invalid(
"configure takes one of --interactive, --schema or --check".to_string(),
);
}
mode = Some(chosen);
}
match mode.unwrap_or(ConfigureCommand::Interactive { force: false }) {
ConfigureCommand::Interactive { .. } => {
Parsed::Configure(ConfigureCommand::Interactive { force })
}
other if force => Parsed::Invalid(format!(
"--force applies to the walkthrough, not to {}",
match other {
ConfigureCommand::Schema => "--schema",
_ => "--check",
}
)),
other => Parsed::Configure(other),
}
}
enum Stop {
Help,
Version,
Invalid(String),
}
impl Stop {
fn parsed(self) -> Parsed {
match self {
Stop::Help => Parsed::Help,
Stop::Version => Parsed::Version,
Stop::Invalid(message) => Parsed::Invalid(message),
}
}
}
fn parse_flags<I: IntoIterator<Item = String>>(args: I) -> Result<RunOptions, Stop> {
let mut fields: Vec<(&'static str, FieldValue)> = Vec::new();
let mut config_path: Option<PathBuf> = None;
let mut args = args.into_iter().peekable();
while let Some(arg) = args.next() {
let (name, inline_value) = match arg.split_once('=') {
Some((name, value)) => (name.to_string(), Some(value.to_string())),
None => (arg.clone(), None),
};
if name == "--help" || name == "-h" {
return Err(Stop::Help);
}
if name == "--version" || name == "-V" {
return Err(Stop::Version);
}
if name == CONFIG_FLAG {
let Some(value) = inline_value.or_else(|| args.next()) else {
return Err(Stop::Invalid(format!("{CONFIG_FLAG} needs a value")));
};
let value = value.trim();
if value.is_empty() {
return Err(Stop::Invalid(format!("{CONFIG_FLAG} needs a path")));
}
config_path = Some(PathBuf::from(value));
continue;
}
let Some((spec, negated)) = option_for(&name) else {
return Err(Stop::Invalid(format!("unknown argument {arg:?}")));
};
if negated {
if inline_value.is_some() {
return Err(Stop::Invalid(format!("{name} takes no value")));
}
fields.push((spec.key, FieldValue::Toggle(false)));
continue;
}
if spec.kind == FieldKind::Toggle {
let on = match &inline_value {
None => true,
Some(raw) => match toggle_value(raw) {
Some(on) => on,
None => {
return Err(Stop::Invalid(format!(
"{name} needs true or false, got {raw:?}"
)))
}
},
};
fields.push((spec.key, FieldValue::Toggle(on)));
continue;
}
let Some(value) = inline_value.or_else(|| args.next()) else {
return Err(Stop::Invalid(format!("{name} needs a value")));
};
match spec.kind {
FieldKind::Number => match value.trim().parse::<u16>() {
Ok(number) => fields.push((spec.key, FieldValue::Number(number))),
Err(_) => {
return Err(Stop::Invalid(format!(
"{name} needs a number between 0 and 65535, got {value:?}"
)))
}
},
FieldKind::List => {
fields.push((spec.key, FieldValue::List(config::split_list(&value))))
}
_ => fields.push((spec.key, FieldValue::Text(value.trim().to_string()))),
}
}
Ok(RunOptions {
overrides: config::partial_from_fields(&fields),
config_path,
})
}
fn option_for(name: &str) -> Option<(&'static FieldSpec, bool)> {
if let Some(spec) = FIELDS.iter().find(|spec| spec.flag == Some(name)) {
return Some((spec, false));
}
let positive = format!("--{}", name.strip_prefix("--no-")?);
FIELDS
.iter()
.find(|spec| spec.flag == Some(positive.as_str()) && spec.kind == FieldKind::Toggle)
.map(|spec| (spec, true))
}
pub fn toggle_value(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
pub fn help_text(version: &str) -> String {
let mut out = String::new();
let _ = write!(
out,
"mobux {version} — touch-friendly tmux web UI
Usage: mobux [OPTIONS]
mobux service <install|uninstall|status> [OPTIONS]
mobux update [--check]
mobux configure [--force | --schema | --check [PATH]]
Commands:
service install Install and start a systemd --user service that survives
a reboot. Takes the same options, writes them to the
config file the unit reads, and needs a username and PIN.
Run it as the user the service belongs to; --allow-root
installs root's own service on purpose.
--no-auth writes a config with no credentials, for a
deployment an authenticating proxy already gates.
{NO_AUTH_HINT}.
service uninstall Stop, disable and remove that service
service status Show the service's systemd status
update Install the latest release over this binary, then restart
the systemd --user service when one is installed
update --check Report the current and latest version, install nothing
configure Ask for every setting and write config.json, refusing to
overwrite an existing one without --force
configure --schema Print the JSON schema for config.json
configure --check Validate a config file and report what is wrong with it
Options:
"
);
let mut options: Vec<(String, &str)> = vec![(
format!(" {CONFIG_FLAG} <PATH>"),
"Config file to read (default <config dir>/config.json)",
)];
options.extend(
FIELDS
.iter()
.filter_map(|spec| Some((option_column(spec.flag?, spec.kind), spec.help))),
);
options.push((" -h, --help".to_string(), "Print this help"));
options.push((" -V, --version".to_string(), "Print the version"));
let width = column_width(options.iter().map(|(left, _)| left.as_str()));
for (left, help) in &options {
let _ = writeln!(out, "{left:width$} {help}");
}
let _ = writeln!(out, "\nEnvironment:");
let width = column_width(FIELDS.iter().map(|spec| spec.env)) + 2;
for spec in FIELDS {
let _ = writeln!(out, " {:width$}{}", spec.env, spec.help, width = width);
}
let _ = write!(
out,
"
A flag wins over the environment variable next to it, which wins over the
config file. A list flag may be repeated or take a comma-separated value.
Anything on the command line is visible to other users in the process list, so
prefer MOBUX_PIN on a shared host.
"
);
out
}
fn option_column(flag: &str, kind: FieldKind) -> String {
if kind == FieldKind::Toggle {
return format!(" {flag}, --no-{}", flag.trim_start_matches("--"));
}
let placeholder = flag
.trim_start_matches("--")
.to_uppercase()
.replace('-', "_");
format!(" {flag} <{placeholder}>")
}
fn column_width<'a, I: Iterator<Item = &'a str>>(columns: I) -> usize {
columns
.map(|column| column.chars().count())
.max()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_string()).collect()
}
fn some(value: &str) -> Option<String> {
Some(value.to_string())
}
fn run(list: &[&str]) -> CliOverrides {
options(list).overrides
}
fn options(list: &[&str]) -> RunOptions {
match parse(args(list)) {
Parsed::Run(options) => options,
other => panic!("expected a run, got {other:?}"),
}
}
fn installing(run: RunOptions) -> Parsed {
Parsed::Service(ServiceCommand::Install(InstallOptions {
run,
..Default::default()
}))
}
fn invalid(list: &[&str]) -> String {
match parse(args(list)) {
Parsed::Invalid(message) => message,
other => panic!("expected a rejection, got {other:?}"),
}
}
#[test]
fn no_arguments_leaves_every_override_empty() {
assert_eq!(parse(args(&[])), Parsed::Run(RunOptions::default()));
}
#[test]
fn flags_take_a_separate_or_inline_value() {
let expected = Parsed::Run(RunOptions {
overrides: CliOverrides {
server: Some(config::PartialServerConfig {
port: Some(5151),
..Default::default()
}),
auth: Some(config::PartialAuthConfig {
pin: some("12345"),
user: some("dogwalker"),
..Default::default()
}),
..Default::default()
},
config_path: None,
});
assert_eq!(
parse(args(&[
"--port",
"5151",
"--pin",
"12345",
"--user",
"dogwalker"
])),
expected
);
assert_eq!(
parse(args(&["--port=5151", "--pin=12345", "--user=dogwalker"])),
expected
);
}
#[test]
fn config_names_the_file_without_touching_the_tree() {
let expected = Some(PathBuf::from("/etc/mobux/config.json"));
for spelling in [
vec!["--config", "/etc/mobux/config.json"],
vec!["--config=/etc/mobux/config.json"],
] {
let parsed = options(&spelling);
assert_eq!(parsed.config_path, expected);
assert_eq!(parsed.overrides, CliOverrides::default());
}
}
#[test]
fn config_without_a_path_is_rejected() {
assert!(invalid(&["--config"]).contains("--config"));
assert!(invalid(&["--config", " "]).contains("--config"));
}
#[test]
fn service_install_names_the_file_its_unit_will_read() {
assert_eq!(
parse(args(&["service", "install", "--config", "/etc/mobux.json"])),
installing(RunOptions {
overrides: CliOverrides::default(),
config_path: Some(PathBuf::from("/etc/mobux.json")),
})
);
}
#[test]
fn help_and_version_short_forms_are_recognised() {
assert_eq!(parse(args(&["--help"])), Parsed::Help);
assert_eq!(parse(args(&["-h"])), Parsed::Help);
assert_eq!(parse(args(&["--version"])), Parsed::Version);
assert_eq!(parse(args(&["-V"])), Parsed::Version);
}
#[test]
fn unknown_flag_is_rejected() {
let Parsed::Invalid(message) = parse(args(&["--nope"])) else {
panic!("unknown flag should not parse");
};
assert!(message.contains("--nope"), "{message}");
}
#[test]
fn flag_without_a_value_is_rejected() {
assert!(matches!(parse(args(&["--port"])), Parsed::Invalid(_)));
assert!(matches!(parse(args(&["--pin"])), Parsed::Invalid(_)));
}
#[test]
fn non_numeric_port_is_rejected() {
let Parsed::Invalid(message) = parse(args(&["--port", "https"])) else {
panic!("a non-numeric port should not parse");
};
assert!(message.contains("--port"), "{message}");
}
#[test]
fn service_subcommands_parse() {
assert_eq!(
parse(args(&["service", "install"])),
installing(RunOptions::default())
);
assert_eq!(
parse(args(&["service", "uninstall"])),
Parsed::Service(ServiceCommand::Uninstall)
);
assert_eq!(
parse(args(&["service", "status"])),
Parsed::Service(ServiceCommand::Status)
);
}
#[test]
fn service_install_takes_the_run_flags() {
assert_eq!(
parse(args(&[
"service", "install", "--port", "5151", "--user", "walker", "--pin", "99999"
])),
installing(RunOptions {
overrides: CliOverrides {
server: Some(config::PartialServerConfig {
port: Some(5151),
..Default::default()
}),
auth: Some(config::PartialAuthConfig {
pin: some("99999"),
user: some("walker"),
..Default::default()
}),
..Default::default()
},
config_path: None,
})
);
assert!(matches!(
parse(args(&["service", "install", "--nope"])),
Parsed::Invalid(_)
));
assert!(matches!(
parse(args(&["service", "install", "--port"])),
Parsed::Invalid(_)
));
}
#[test]
fn unknown_or_missing_service_subcommand_is_rejected() {
let Parsed::Invalid(message) = parse(args(&["service", "reinstall"])) else {
panic!("an unknown subcommand should not parse");
};
assert!(message.contains("reinstall"), "{message}");
assert!(matches!(parse(args(&["service"])), Parsed::Invalid(_)));
}
#[test]
fn uninstall_and_status_take_no_flags() {
assert!(matches!(
parse(args(&["service", "status", "--port", "5151"])),
Parsed::Invalid(_)
));
assert_eq!(
parse(args(&["service", "uninstall", "--help"])),
Parsed::Help
);
assert_eq!(parse(args(&["service", "--help"])), Parsed::Help);
}
#[test]
fn update_parses_bare_and_with_check() {
assert_eq!(
parse(args(&["update"])),
Parsed::Update(UpdateCommand::Apply)
);
assert_eq!(
parse(args(&["update", "--check"])),
Parsed::Update(UpdateCommand::Check)
);
assert_eq!(parse(args(&["update", "--help"])), Parsed::Help);
assert_eq!(parse(args(&["update", "-h"])), Parsed::Help);
}
#[test]
fn update_rejects_anything_but_check() {
let Parsed::Invalid(message) = parse(args(&["update", "--force"])) else {
panic!("an unknown update flag should not parse");
};
assert!(message.contains("--force"), "{message}");
assert!(matches!(
parse(args(&["update", "--port", "5151"])),
Parsed::Invalid(_)
));
assert!(matches!(
parse(args(&["update", "now"])),
Parsed::Invalid(_)
));
}
#[test]
fn configure_defaults_to_the_walkthrough() {
assert_eq!(
parse(args(&["configure"])),
Parsed::Configure(ConfigureCommand::Interactive { force: false })
);
assert_eq!(
parse(args(&["configure", "--interactive"])),
Parsed::Configure(ConfigureCommand::Interactive { force: false })
);
assert_eq!(
parse(args(&["configure", "--force"])),
Parsed::Configure(ConfigureCommand::Interactive { force: true })
);
assert_eq!(parse(args(&["configure", "--help"])), Parsed::Help);
}
#[test]
fn configure_schema_and_check_parse() {
assert_eq!(
parse(args(&["configure", "--schema"])),
Parsed::Configure(ConfigureCommand::Schema)
);
assert_eq!(
parse(args(&["configure", "--check"])),
Parsed::Configure(ConfigureCommand::Check(None))
);
assert_eq!(
parse(args(&["configure", "--check", "/etc/mobux.json"])),
Parsed::Configure(ConfigureCommand::Check(some("/etc/mobux.json")))
);
}
#[test]
fn configure_takes_one_mode_and_force_only_with_the_walkthrough() {
assert!(invalid(&["configure", "--schema", "--check"]).contains("one of"));
assert!(invalid(&["configure", "--schema", "--force"]).contains("--schema"));
assert!(invalid(&["configure", "--check", "--force"]).contains("--check"));
assert!(invalid(&["configure", "--pin", "12345"]).contains("--pin"));
}
#[test]
fn help_lists_the_flags_and_the_main_env_vars() {
let help = help_text("1.2.3");
for expected in [
"service install",
"service uninstall",
"service status",
"update",
"update --check",
"configure",
"configure --schema",
"configure --check",
"--config",
"--port",
"--pin",
"--user",
"--help",
"--version",
"MOBUX_PORT",
"MOBUX_AUTH_USER",
"MOBUX_PIN",
"MOBUX_TLS",
"MOBUX_DOMAIN",
"1.2.3",
] {
assert!(help.contains(expected), "help is missing {expected}");
}
}
#[test]
fn every_schema_field_has_a_flag_the_parser_accepts() {
for spec in FIELDS {
let flag = spec
.flag
.unwrap_or_else(|| panic!("{} has no flag", spec.key));
let overrides = match spec.kind {
FieldKind::Toggle => run(&[flag]),
FieldKind::Number => run(&[flag, "8443"]),
_ => run(&[flag, "sample.example"]),
};
assert_ne!(
overrides,
CliOverrides::default(),
"{flag} set nothing on the tree"
);
}
}
#[test]
fn every_toggle_has_a_no_form_that_turns_it_off() {
let toggles = FIELDS.iter().filter(|spec| spec.kind == FieldKind::Toggle);
for spec in toggles {
let flag = spec.flag.expect("a toggle has a flag");
let negated = format!("--no-{}", flag.trim_start_matches("--"));
assert_ne!(run(&[flag]), run(&[&negated]), "{flag} and {negated} agree");
assert_eq!(run(&[flag]), run(&[&format!("{flag}=true")]));
assert_eq!(run(&[&negated]), run(&[&format!("{flag}=0")]));
}
}
#[test]
fn the_tls_flags_land_in_the_tls_section() {
let tls = run(&[
"--no-tls",
"--tls-host",
"a.example",
"--cert-file",
"/etc/cert.pem",
"--key-file",
"/etc/key.pem",
"--acme-domain",
"b.example,c.example",
"--acme-email",
"me@example.com",
"--acme-directory",
"https://acme.example/dir",
"--acme-http-port",
"8081",
])
.tls
.expect("a tls section");
assert_eq!(tls.enabled, Some(false));
assert_eq!(tls.hosts, Some(vec!["a.example".to_string()]));
assert_eq!(tls.cert_file.as_deref(), Some("/etc/cert.pem"));
assert_eq!(tls.key_file.as_deref(), Some("/etc/key.pem"));
assert_eq!(
tls.acme_domains,
Some(vec!["b.example".to_string(), "c.example".to_string()])
);
assert_eq!(tls.acme_email.as_deref(), Some("me@example.com"));
assert_eq!(
tls.acme_directory.as_deref(),
Some("https://acme.example/dir")
);
assert_eq!(tls.acme_http_port, Some(8081));
}
#[test]
fn a_repeated_list_flag_accumulates() {
let hosts = run(&["--tls-host", "a.example", "--tls-host=b.example"])
.tls
.and_then(|tls| tls.hosts);
assert_eq!(
hosts,
Some(vec!["a.example".to_string(), "b.example".to_string()])
);
}
#[test]
fn the_remaining_flags_land_in_their_own_sections() {
let overrides = run(&[
"--pass",
"secret",
"--data-dir",
"/srv/mobux",
"--shell",
"/bin/zsh",
"--domain",
"mobux.example:5151",
"--dev",
"--service-name",
"mobux-dev",
"--vapid-contact",
"mailto:me@example.com",
"--update-check-url",
"https://index.example/mobux",
]);
assert_eq!(
overrides.auth.and_then(|auth| auth.pass).as_deref(),
Some("secret")
);
assert_eq!(
overrides.paths.and_then(|paths| paths.data_dir).as_deref(),
Some("/srv/mobux")
);
assert_eq!(
overrides
.session
.and_then(|session| session.shell)
.as_deref(),
Some("/bin/zsh")
);
assert_eq!(
overrides
.push
.and_then(|push| push.vapid_contact)
.as_deref(),
Some("mailto:me@example.com")
);
assert_eq!(
overrides
.update
.and_then(|update| update.check_url)
.as_deref(),
Some("https://index.example/mobux")
);
let app = overrides.app.expect("an app section");
assert_eq!(app.domain.as_deref(), Some("mobux.example:5151"));
assert_eq!(app.dev, Some(true));
assert_eq!(app.service_name.as_deref(), Some("mobux-dev"));
}
#[test]
fn the_flags_merge_onto_the_config_tree() {
let config = config::Config::default().merged(run(&[
"--port",
"5151",
"--no-tls",
"--data-dir",
"/srv/mobux",
]));
assert_eq!(config.server.port, 5151);
assert!(!config.tls.enabled);
assert_eq!(config.paths.data_dir, "/srv/mobux");
assert_eq!(config.app.service_name, "mobux");
}
#[test]
fn the_tls_flag_turns_https_on_over_the_default() {
assert!(!config::Config::default().tls.enabled);
assert!(
config::Config::default()
.merged(run(&["--tls"]))
.tls
.enabled
);
}
#[test]
fn a_toggle_rejects_a_value_it_cannot_read() {
assert!(invalid(&["--tls=maybe"]).contains("--tls"), "message");
assert!(invalid(&["--no-tls=1"]).contains("takes no value"));
}
#[test]
fn every_new_flag_is_rejected_without_a_value() {
let valued = FIELDS
.iter()
.filter(|spec| !matches!(spec.kind, FieldKind::Toggle));
for spec in valued {
let flag = spec.flag.expect("a flag");
assert!(
invalid(&[flag]).contains(flag),
"{flag} was accepted without a value"
);
}
}
#[test]
fn service_install_takes_the_whole_option_surface() {
assert_eq!(
parse(args(&["service", "install", "--data-dir", "/srv/mobux"])),
installing(options(&["--data-dir", "/srv/mobux"]))
);
}
#[test]
fn allow_root_is_an_install_flag() {
assert_eq!(
parse(args(&[
"service",
"install",
"--allow-root",
"--data-dir",
"/srv/mobux"
])),
Parsed::Service(ServiceCommand::Install(InstallOptions {
run: options(&["--data-dir", "/srv/mobux"]),
allow_root: true,
no_auth: false,
}))
);
assert!(invalid(&["--allow-root"]).contains("--allow-root"));
}
#[test]
fn no_auth_is_an_install_flag_that_excludes_credentials() {
assert_eq!(
parse(args(&["service", "install", "--no-auth", "--port", "5151"])),
Parsed::Service(ServiceCommand::Install(InstallOptions {
run: options(&["--port", "5151"]),
allow_root: false,
no_auth: true,
}))
);
for combination in [
vec!["service", "install", "--no-auth", "--user", "walker"],
vec!["service", "install", "--pin", "99999", "--no-auth"],
vec!["service", "install", "--no-auth", "--user=walker"],
] {
let Parsed::Invalid(message) = parse(args(&combination)) else {
panic!("{combination:?} should not parse");
};
assert!(message.contains("--no-auth"), "{message}");
}
assert!(invalid(&["--no-auth"]).contains("--no-auth"));
}
#[test]
fn missing_credentials_names_the_way_out() {
assert!(
MISSING_CREDENTIALS.ends_with(NO_AUTH_HINT),
"{MISSING_CREDENTIALS}"
);
let refusal = crate::service::resolve_unit_spec(
&config::Config::default(),
std::path::Path::new("/usr/bin/mobux"),
std::path::Path::new("/home/walker/.config/mobux/config.json"),
crate::service::Auth::Required,
)
.expect_err("no credentials, so no unit");
assert!(refusal.contains(NO_AUTH_HINT), "{refusal}");
assert!(
help_text("1.2.3").contains(NO_AUTH_HINT),
"help is missing the --no-auth hint"
);
}
#[test]
fn help_lists_every_flag_and_environment_variable() {
let help = help_text("1.2.3");
for spec in FIELDS {
assert!(
help.contains(spec.flag.expect("a flag")),
"help is missing the flag for {}",
spec.key
);
assert!(help.contains(spec.env), "help is missing {}", spec.env);
}
assert!(help.contains("--no-tls"), "help is missing --no-tls");
}
}