use std::{iter::Peekable, net::SocketAddr};
use anyhow::{Context, Result, bail};
use crate::{
settings::{DEFAULT_BIND, eol_bytes},
update::Request,
};
pub const USAGE: &str = r#"smon - minimalistic TUI serial monitor
Usage: smon [options]
smon daemon [--config <path>] [--mcp <host:port>]
smon list [--host <ssh target>]
smon call <tool> [json-args] [--host <ssh target>]
smon update [version] [--from-source] [--yes]
Subcommands:
daemon own every console in the config file and serve them
list list the consoles a running smon owns
call <tool> [json] call one tool, for example
smon call serial_status
smon call serial_send '{"console":"left","text":"reboot"}'
update [version] install the newest release, or the version named.
This restarts the daemon and kills every other smon
running here, so open sessions end.
Options:
--from-source build the update with cargo instead of downloading a
prebuilt binary
--yes do not ask before killing running smon processes
--eol <cr|lf|crlf|none> line ending appended to sent lines, default crlf
--config <path> daemon config file, default the first of $SMON_CONFIG,
<config dir>/smon/daemon.toml, /etc/smon/daemon.toml
--mcp <host:port> endpoint to serve on, or to talk to, default 127.0.0.1:4123.
A standalone TUI moves up to the next free port when
this one is taken, the daemon does not.
--host <ssh target> attach to the smon on another machine, over an ssh
tunnel this opens itself, for example --host pi
-h, --help show this help
-V, --version show the version"#;
#[derive(Debug)]
pub enum Cli {
Run {
eol: Vec<u8>,
mcp: SocketAddr,
host: Option<String>,
},
Daemon {
config: Option<String>,
mcp: Option<SocketAddr>,
},
List {
mcp: SocketAddr,
host: Option<String>,
},
Call {
mcp: SocketAddr,
host: Option<String>,
tool: String,
args: String,
},
Update {
mcp: SocketAddr,
request: Request,
},
Help,
Version,
}
#[derive(Default)]
struct Flags {
eol: Option<String>,
mcp: Option<String>,
config: Option<String>,
host: Option<String>,
from_source: bool,
yes: bool,
}
pub fn parse(args: impl Iterator<Item = String>) -> Result<Cli> {
let mut flags = Flags::default();
let mut words: Vec<String> = Vec::new();
let mut args = args.peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => return Ok(Cli::Help),
"-V" | "--version" => return Ok(Cli::Version),
"--eol" => flags.eol = Some(value(&mut args, "--eol")?),
"--mcp" => flags.mcp = Some(value(&mut args, "--mcp")?),
"--config" => flags.config = Some(value(&mut args, "--config")?),
"--host" => flags.host = Some(value(&mut args, "--host")?),
"--from-source" => flags.from_source = true,
"--yes" => flags.yes = true,
other => {
if let Some(v) = other.strip_prefix("--eol=") {
flags.eol = Some(v.to_string());
} else if let Some(v) = other.strip_prefix("--mcp=") {
flags.mcp = Some(v.to_string());
} else if let Some(v) = other.strip_prefix("--config=") {
flags.config = Some(v.to_string());
} else if let Some(v) = other.strip_prefix("--host=") {
flags.host = Some(v.to_string());
} else if other.starts_with('-') {
bail!("unknown argument '{other}', try --help");
} else {
words.push(other.to_string());
}
}
}
}
let (command, rest) = words.split_first().map_or(("", &[][..]), |(c, r)| (c.as_str(), r));
if command != "update" {
let named = if command.is_empty() {
"the monitor"
} else {
command
};
deny_flag(flags.from_source, "--from-source", named)?;
deny_flag(flags.yes, "--yes", named)?;
}
match command {
"update" => {
deny(flags.eol.as_ref(), "--eol", "update")?;
deny(flags.config.as_ref(), "--config", "update")?;
deny(flags.host.as_ref(), "--host", "update")?;
if rest.len() > 1 {
bail!("update takes at most one version");
}
Ok(Cli::Update {
mcp: endpoint(flags.mcp.as_deref())?,
request: Request {
version: rest.first().cloned(),
from_source: flags.from_source,
yes: flags.yes,
},
})
}
"daemon" => {
reject(rest, "daemon takes no arguments")?;
deny(flags.eol.as_ref(), "--eol", "daemon")?;
deny(flags.host.as_ref(), "--host", "daemon")?;
Ok(Cli::Daemon {
config: flags.config,
mcp: flags.mcp.as_deref().map(address).transpose()?,
})
}
"list" => {
reject(rest, "list takes no arguments")?;
deny(flags.eol.as_ref(), "--eol", "list")?;
deny(flags.config.as_ref(), "--config", "list")?;
Ok(Cli::List {
mcp: endpoint(flags.mcp.as_deref())?,
host: flags.host,
})
}
"call" => {
deny(flags.eol.as_ref(), "--eol", "call")?;
deny(flags.config.as_ref(), "--config", "call")?;
let (tool, rest) = rest.split_first().context("usage: smon call <tool> [json-args]")?;
if rest.len() > 1 {
bail!("too many arguments for call");
}
Ok(Cli::Call {
mcp: endpoint(flags.mcp.as_deref())?,
host: flags.host,
tool: tool.clone(),
args: rest.first().cloned().unwrap_or_else(|| "{}".to_string()),
})
}
"" => {
deny(flags.config.as_ref(), "--config", "the monitor")?;
Ok(Cli::Run {
eol: eol_bytes(flags.eol.as_deref().unwrap_or("crlf"))?,
mcp: endpoint(flags.mcp.as_deref())?,
host: flags.host,
})
}
other => bail!("unknown subcommand '{other}', try --help"),
}
}
fn value(args: &mut Peekable<impl Iterator<Item = String>>, flag: &str) -> Result<String> {
args.next().with_context(|| format!("{flag} needs a value"))
}
fn reject(rest: &[String], message: &str) -> Result<()> {
if rest.is_empty() {
return Ok(());
}
bail!("{message}")
}
fn deny(flag: Option<&String>, name: &str, command: &str) -> Result<()> {
if flag.is_none() {
return Ok(());
}
bail!("{name} means nothing to {command}")
}
fn deny_flag(set: bool, name: &str, command: &str) -> Result<()> {
if !set {
return Ok(());
}
bail!("{name} means nothing to {command}")
}
fn endpoint(text: Option<&str>) -> Result<SocketAddr> {
address(text.unwrap_or(DEFAULT_BIND))
}
fn address(text: &str) -> Result<SocketAddr> {
text.parse()
.with_context(|| format!("invalid address '{text}', expected host:port like {DEFAULT_BIND}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_args(args: &[&str]) -> Result<Cli> {
parse(args.iter().map(|s| s.to_string()))
}
#[test]
fn unknown_or_incomplete_arguments_are_rejected() {
assert!(parse_args(&["--elo=cr"]).is_err());
assert!(parse_args(&["extra"]).is_err());
assert!(parse_args(&["--eol"]).is_err());
assert!(parse_args(&["--eol", "tab"]).is_err());
assert!(parse_args(&["--mcp", "not-an-addr"]).is_err());
}
#[test]
fn eol_and_mcp_parse_in_both_forms() {
let Ok(Cli::Run { eol, mcp, .. }) = parse_args(&["--eol=cr", "--mcp", "127.0.0.1:9000"]) else {
panic!("expected Cli::Run");
};
assert_eq!(eol, b"\r");
assert_eq!(mcp.port(), 9000);
}
#[test]
fn defaults_apply_with_no_arguments() {
let Ok(Cli::Run { eol, mcp, .. }) = parse_args(&[]) else {
panic!("expected Cli::Run");
};
assert_eq!(eol, b"\r\n");
assert_eq!(mcp.to_string(), DEFAULT_BIND);
}
#[test]
fn help_and_version_short_circuit() {
assert!(matches!(parse_args(&["--help"]), Ok(Cli::Help)));
assert!(matches!(parse_args(&["-V", "--bogus"]), Ok(Cli::Version)));
}
#[test]
fn call_and_list_subcommands_parse() {
let Ok(Cli::Call { mcp, tool, args, .. }) = parse_args(&["call", "serial_status"]) else {
panic!("expected Cli::Call");
};
assert_eq!(mcp.to_string(), DEFAULT_BIND);
assert_eq!(tool, "serial_status");
assert_eq!(args, "{}");
assert!(matches!(parse_args(&["list"]), Ok(Cli::List { .. })));
assert!(parse_args(&["call"]).is_err());
assert!(parse_args(&["list", "extra"]).is_err());
assert!(parse_args(&["call", "a", "{}", "extra"]).is_err());
}
#[test]
fn call_takes_json_and_an_endpoint() {
let Ok(Cli::Call { mcp, tool, args, .. }) =
parse_args(&["call", "serial_send", r#"{"text":"x"}"#, "--mcp=127.0.0.1:5000"])
else {
panic!("expected Cli::Call");
};
assert_eq!(mcp.port(), 5000);
assert_eq!(tool, "serial_send");
assert_eq!(args, r#"{"text":"x"}"#);
}
#[test]
fn daemon_takes_a_config_and_an_optional_bind() {
let Ok(Cli::Daemon { config, mcp }) = parse_args(&["daemon", "--config", "/etc/x.toml"]) else {
panic!("expected Cli::Daemon");
};
assert_eq!(config.as_deref(), Some("/etc/x.toml"));
assert_eq!(mcp, None);
let Ok(Cli::Daemon { mcp, .. }) = parse_args(&["daemon", "--mcp", "0.0.0.0:1"]) else {
panic!("expected Cli::Daemon");
};
assert_eq!(mcp.map(|a| a.port()), Some(1));
}
#[test]
fn flags_that_mean_nothing_to_a_subcommand_are_refused() {
assert!(parse_args(&["daemon", "--eol", "cr"]).is_err());
assert!(parse_args(&["daemon", "--host", "pi"]).is_err());
assert!(parse_args(&["list", "--config", "x"]).is_err());
assert!(parse_args(&["call", "t", "--eol", "cr"]).is_err());
assert!(parse_args(&["--config", "x"]).is_err());
}
#[test]
fn host_reaches_another_machine() {
let Ok(Cli::Run { host, .. }) = parse_args(&["--host", "pi"]) else {
panic!("expected Cli::Run");
};
assert_eq!(host.as_deref(), Some("pi"));
let Ok(Cli::List { host, .. }) = parse_args(&["list", "--host=pi"]) else {
panic!("expected Cli::List");
};
assert_eq!(host.as_deref(), Some("pi"));
}
#[test]
fn update_takes_an_optional_version_and_its_own_flags() {
let Ok(Cli::Update { mcp, request }) = parse_args(&["update"]) else {
panic!("expected Cli::Update");
};
assert_eq!(mcp.to_string(), DEFAULT_BIND);
assert_eq!(request.version, None);
assert!(!request.from_source);
assert!(!request.yes);
let Ok(Cli::Update { request, .. }) = parse_args(&["update", "v0.1.2", "--from-source", "--yes"])
else {
panic!("expected Cli::Update");
};
assert_eq!(request.version.as_deref(), Some("v0.1.2"));
assert!(request.from_source);
assert!(request.yes);
}
#[test]
fn update_refuses_a_second_version_and_flags_it_cannot_use() {
assert!(parse_args(&["update", "v0.1.2", "v0.1.3"]).is_err());
assert!(parse_args(&["update", "--eol", "cr"]).is_err());
assert!(parse_args(&["update", "--config", "x"]).is_err());
}
#[test]
fn update_refuses_a_remote_host() {
let error = parse_args(&["update", "--host", "pi"]).unwrap_err().to_string();
assert!(error.contains("--host"), "{error}");
}
#[test]
fn the_update_flags_mean_nothing_to_other_subcommands() {
assert!(parse_args(&["--yes"]).is_err());
assert!(parse_args(&["--from-source"]).is_err());
assert!(parse_args(&["daemon", "--yes"]).is_err());
assert!(parse_args(&["list", "--from-source"]).is_err());
assert!(parse_args(&["call", "serial_status", "--yes"]).is_err());
}
#[test]
fn an_unknown_subcommand_is_named_in_the_error() {
let error = parse_args(&["deamon"]).unwrap_err().to_string();
assert!(error.contains("deamon"), "{error}");
}
}