use anyhow::{Result, anyhow, bail};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CliCommand {
Run {
filter: Option<String>,
regex_mode: bool,
user_only: bool,
},
#[cfg(all(feature = "debug_tui", debug_assertions))]
#[doc(hidden)]
DebugTui,
Help,
Version,
}
pub fn parse_args<I>(args: I) -> Result<CliCommand>
where
I: IntoIterator,
I::Item: Into<String>,
{
let mut args: Vec<String> = args.into_iter().map(Into::into).collect();
if !args.is_empty() {
args.remove(0);
}
let mut positionals: Vec<String> = Vec::new();
let mut filter_from_option: Option<String> = None;
let mut regex_from_option: Option<String> = None;
let mut wants_help = false;
let mut wants_version = false;
let mut user_only = false;
let mut saw_valid_option = false;
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--" => {
positionals.extend(iter);
break;
}
#[cfg(all(feature = "debug_tui", debug_assertions))]
"--debug-tui" => return Ok(CliCommand::DebugTui),
"-h" | "--help" => {
wants_help = true;
saw_valid_option = true;
}
"-v" | "--version" => {
wants_version = true;
saw_valid_option = true;
}
"-r" | "--regex" => {
saw_valid_option = true;
if filter_from_option.is_some() || regex_from_option.is_some() {
bail!("cannot combine --regex with -f/--filter");
}
let value = iter
.next()
.ok_or_else(|| anyhow!("{arg} requires a PATTERN value"))?;
if value.is_empty() {
bail!("{arg} requires a non-empty PATTERN value");
}
regex_from_option = Some(value);
}
"-u" | "--user" => {
user_only = true;
saw_valid_option = true;
}
"-f" | "--filter" => {
saw_valid_option = true;
let value = iter
.next()
.ok_or_else(|| anyhow!("{arg} requires a FILTER value"))?;
if filter_from_option.is_some() {
bail!("FILTER specified multiple times");
}
if regex_from_option.is_some() {
bail!("cannot combine --regex with -f/--filter");
}
if value.is_empty() {
bail!("{arg} requires a non-empty FILTER value");
}
filter_from_option = Some(value);
}
_ if arg.starts_with('-') => {
if arg.starts_with("--") {
bail!("unknown option: {arg}");
}
if saw_valid_option {
bail!("unknown option: {arg}");
}
if !positionals.is_empty() || filter_from_option.is_some() {
bail!("unknown option: {arg}");
}
positionals.push(arg);
}
_ => positionals.push(arg),
}
}
if filter_from_option.is_some() && !positionals.is_empty() {
bail!("cannot combine positional FILTER with -f/--filter");
}
if wants_help && wants_version {
return Err(anyhow!("cannot combine --help and --version"));
}
if wants_help {
if !positionals.is_empty()
|| filter_from_option.is_some()
|| regex_from_option.is_some()
|| user_only
{
bail!("--help does not accept FILTER");
}
return Ok(CliCommand::Help);
}
if wants_version {
if !positionals.is_empty()
|| filter_from_option.is_some()
|| regex_from_option.is_some()
|| user_only
{
bail!("--version does not accept FILTER");
}
return Ok(CliCommand::Version);
}
if let Some(filter) = filter_from_option {
return Ok(CliCommand::Run {
filter: Some(filter),
regex_mode: false,
user_only,
});
}
if let Some(pattern) = regex_from_option {
if !positionals.is_empty() {
bail!("too many PATTERN arguments for --regex");
}
return Ok(CliCommand::Run {
filter: Some(pattern),
regex_mode: true,
user_only,
});
}
if saw_valid_option && !positionals.is_empty() {
bail!("when using options, pass FILTER with -f or --filter");
}
match positionals.as_slice() {
[] => Ok(CliCommand::Run {
filter: None,
regex_mode: false,
user_only,
}),
[filter] => Ok(CliCommand::Run {
filter: Some(filter.clone()),
regex_mode: false,
user_only,
}),
_ => bail!("too many positional arguments; expected at most one FILTER"),
}
}
pub fn help_text() -> String {
[
&version_text(),
"",
"usage: psn <FILTER>",
"usage: psn [OPTIONS] -f <FILTER>",
"usage: psn [OPTIONS] -r <PATTERN>",
"",
"Terminal UI for browsing process status and sending Unix signals.",
"",
"Options:",
" -h, --help Show usage instructions",
" -v, --version Show version",
" -f, --filter <value> Filter process names/commands (case insensitive string)",
" -r, --regex <value> Use regex matching (regular expression pattern)",
" -u, --user Show only current user's processes",
]
.join("\n")
}
pub fn version_text() -> String {
format!(
"psn v{}\nprocess status navigator\napache v2 (c) 2026 l5yth",
env!("CARGO_PKG_VERSION")
)
}
#[cfg(test)]
mod tests {
use super::{CliCommand, help_text, parse_args, version_text};
#[test]
fn parse_args_no_args_runs_without_filter() {
let cmd = parse_args(["psn"]).expect("parse should succeed");
assert_eq!(
cmd,
CliCommand::Run {
filter: None,
regex_mode: false,
user_only: false
}
);
}
#[test]
fn parse_args_single_filter_runs_with_filter() {
let cmd = parse_args(["psn", "sshd"]).expect("parse should succeed");
assert_eq!(
cmd,
CliCommand::Run {
filter: Some("sshd".to_string()),
regex_mode: false,
user_only: false
}
);
}
#[test]
fn parse_args_help_and_version_work() {
assert_eq!(
parse_args(["psn", "-h"]).expect("parse should succeed"),
CliCommand::Help
);
assert_eq!(
parse_args(["psn", "--help"]).expect("parse should succeed"),
CliCommand::Help
);
assert_eq!(
parse_args(["psn", "-v"]).expect("parse should succeed"),
CliCommand::Version
);
assert_eq!(
parse_args(["psn", "--version"]).expect("parse should succeed"),
CliCommand::Version
);
}
#[test]
fn parse_args_dash_prefixed_filter_without_options_is_allowed() {
let cmd = parse_args(["psn", "-bash"]).expect("parse should succeed");
assert_eq!(
cmd,
CliCommand::Run {
filter: Some("-bash".to_string()),
regex_mode: false,
user_only: false
}
);
}
#[test]
fn parse_args_filter_flag_works() {
let short = parse_args(["psn", "-f", "sshd"]).expect("parse should succeed");
let long = parse_args(["psn", "--filter", "sshd"]).expect("parse should succeed");
assert_eq!(
short,
CliCommand::Run {
filter: Some("sshd".to_string()),
regex_mode: false,
user_only: false
}
);
assert_eq!(
long,
CliCommand::Run {
filter: Some("sshd".to_string()),
regex_mode: false,
user_only: false
}
);
}
#[test]
fn parse_args_regex_option_works_without_filter_flag() {
let short = parse_args(["psn", "-r", "sshd.*"]).expect("parse should succeed");
let long = parse_args(["psn", "--regex", "sshd.*"]).expect("parse should succeed");
assert_eq!(
short,
CliCommand::Run {
filter: Some("sshd.*".to_string()),
regex_mode: true,
user_only: false
}
);
assert_eq!(
long,
CliCommand::Run {
filter: Some("sshd.*".to_string()),
regex_mode: true,
user_only: false
}
);
}
#[test]
fn parse_args_regex_requires_pattern() {
assert!(parse_args(["psn", "-r"]).is_err());
assert!(parse_args(["psn", "--regex"]).is_err());
assert!(parse_args(["psn", "-r", ""]).is_err());
}
#[test]
fn parse_args_user_flag_works() {
let cmd = parse_args(["psn", "-u"]).expect("parse should succeed");
assert_eq!(
cmd,
CliCommand::Run {
filter: None,
regex_mode: false,
user_only: true
}
);
}
#[test]
fn parse_args_unknown_flag_errors_after_known_option() {
assert!(parse_args(["psn", "-h", "--wat"]).is_err());
assert!(parse_args(["psn", "-f", "sshd", "--wat"]).is_err());
assert!(parse_args(["psn", "-u", "-wat"]).is_err());
}
#[test]
fn parse_args_unknown_long_option_errors() {
assert!(parse_args(["psn", "--wat"]).is_err());
}
#[test]
fn parse_args_unknown_short_before_options_is_filter() {
let cmd = parse_args(["psn", "-wat"]).expect("parse should succeed");
assert_eq!(
cmd,
CliCommand::Run {
filter: Some("-wat".to_string()),
regex_mode: false,
user_only: false
}
);
}
#[test]
fn parse_args_rejects_positional_filter_when_options_present() {
assert!(parse_args(["psn", "-v", "sshd"]).is_err());
assert!(parse_args(["psn", "--help", "sshd"]).is_err());
assert!(parse_args(["psn", "-f", "sshd", "bash"]).is_err());
assert!(parse_args(["psn", "-r", "x", "y"]).is_err());
}
#[test]
fn parse_args_rejects_missing_filter_value_for_flag() {
assert!(parse_args(["psn", "-f"]).is_err());
assert!(parse_args(["psn", "--filter"]).is_err());
}
#[test]
fn parse_args_rejects_empty_filter_value_for_flag() {
assert!(parse_args(["psn", "-f", ""]).is_err());
assert!(parse_args(["psn", "--filter", ""]).is_err());
}
#[test]
fn parse_args_rejects_combined_filter_and_regex() {
assert!(parse_args(["psn", "-f", "ssh", "-r", "ssh.*"]).is_err());
assert!(parse_args(["psn", "-r", "ssh.*", "-f", "ssh"]).is_err());
}
#[test]
fn parse_args_rejects_duplicate_filter_option() {
assert!(parse_args(["psn", "-f", "a", "--filter", "b"]).is_err());
}
#[test]
fn parse_args_user_with_filter_and_regex_works() {
assert_eq!(
parse_args(["psn", "-u", "-f", "ssh"]).expect("parse should succeed"),
CliCommand::Run {
filter: Some("ssh".to_string()),
regex_mode: false,
user_only: true
}
);
assert_eq!(
parse_args(["psn", "-u", "-r", "^ssh(d|agent)$"]).expect("parse should succeed"),
CliCommand::Run {
filter: Some("^ssh(d|agent)$".to_string()),
regex_mode: true,
user_only: true
}
);
}
#[test]
fn parse_args_rejects_help_and_version_together() {
assert!(parse_args(["psn", "--help", "--version"]).is_err());
}
#[test]
fn parse_args_rejects_short_unknown_after_short_unknown_filter() {
assert!(parse_args(["psn", "-x", "-y"]).is_err());
}
#[test]
fn parse_args_rejects_positional_when_user_option_present() {
assert!(parse_args(["psn", "-u", "ssh"]).is_err());
}
#[test]
fn parse_args_option_terminator_allows_dash_prefixed_filter() {
let cmd = parse_args(["psn", "--", "--wat"]).expect("parse should succeed");
assert_eq!(
cmd,
CliCommand::Run {
filter: Some("--wat".to_string()),
regex_mode: false,
user_only: false
}
);
}
#[test]
fn parse_args_rejects_too_many_positionals() {
assert!(parse_args(["psn", "a", "b"]).is_err());
}
#[test]
fn help_text_contains_usage() {
let text = help_text();
assert!(text.contains("usage: psn <FILTER>"));
assert!(text.contains("usage: psn [OPTIONS] -f <FILTER>"));
assert!(text.contains("usage: psn [OPTIONS] -r <PATTERN>"));
assert!(text.contains("--help"));
assert!(text.contains("--version"));
assert!(text.contains("--filter"));
assert!(text.contains("--regex"));
assert!(text.contains("--user"));
assert!(!text.contains("--debug-tui"));
}
#[test]
fn version_text_contains_requested_lines() {
let text = version_text();
assert!(text.contains("psn v"));
assert!(text.contains("process status navigator"));
assert!(text.contains("apache v2 (c) 2026 l5yth"));
}
#[cfg(all(feature = "debug_tui", debug_assertions))]
#[test]
fn parse_args_debug_tui_ignores_all_other_flags() {
assert_eq!(
parse_args(["psn", "--debug-tui", "--help", "-f", "ssh"])
.expect("parse should succeed"),
CliCommand::DebugTui
);
assert_eq!(
parse_args(["psn", "-u", "--debug-tui", "--wat"]).expect("parse should succeed"),
CliCommand::DebugTui
);
}
#[cfg(all(feature = "debug_tui", debug_assertions))]
#[test]
fn parse_args_debug_tui_respects_option_boundaries() {
assert_eq!(
parse_args(["psn", "--", "--debug-tui"]).expect("parse should succeed"),
CliCommand::Run {
filter: Some("--debug-tui".to_string()),
regex_mode: false,
user_only: false
}
);
assert_eq!(
parse_args(["psn", "-f", "--debug-tui"]).expect("parse should succeed"),
CliCommand::Run {
filter: Some("--debug-tui".to_string()),
regex_mode: false,
user_only: false
}
);
}
#[cfg(not(all(feature = "debug_tui", debug_assertions)))]
#[test]
fn parse_args_debug_tui_is_rejected_when_hidden_feature_is_disabled() {
assert!(parse_args(["psn", "--debug-tui"]).is_err());
}
}