use crate::generator::charset::CharsetOptions; use crate::generator::passphrase::PassphraseOptions; use clap::{Args, Parser, Subcommand, ValueEnum}; #[derive(Parser, Debug)] #[command(name = "pasx", version, about = "Terminal password generator")] pub struct Cli { #[command(subcommand)] pub command: Option, #[command(flatten)] pub opts: GenArgs, } #[derive(Subcommand, Debug)] pub enum Command { /// Generate one password, print it, and exit Gen(GenCmdArgs), /// Print a shell completion script Completions { #[arg(value_enum)] shell: ShellKind, }, } #[derive(Args, Debug)] pub struct GenCmdArgs { #[command(flatten)] pub opts: GenArgs, /// Also copy the password to the clipboard #[arg(long)] pub copy: bool, } #[derive(Args, Debug, Clone)] pub struct GenArgs { /// Generation mode #[arg(short = 'm', long, value_enum, default_value_t = Mode::Charset)] pub mode: Mode, /// Password length in characters #[arg(short = 'l', long, default_value_t = 18)] pub length: usize, /// Exclude uppercase letters #[arg(long)] pub no_upper: bool, /// Exclude lowercase letters #[arg(long)] pub no_lower: bool, /// Exclude digits #[arg(long)] pub no_digits: bool, /// Exclude symbols #[arg(long)] pub no_symbols: bool, /// Exclude look-alike characters (0 O o l 1 I) #[arg(long)] pub exclude_ambiguous: bool, /// Number of words in passphrase mode #[arg(short = 'w', long, default_value_t = 5)] pub words: usize, /// Separator between words in passphrase mode #[arg(long, default_value_t = '-')] pub separator: char, /// Capitalize each word in passphrase mode #[arg(long)] pub capitalize: bool, /// Append a random digit in passphrase mode #[arg(long)] pub add_number: bool, } #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Charset, Passphrase, } #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] pub enum ShellKind { Nu, Bash, Zsh, Fish, } impl GenArgs { pub fn charset_options(&self) -> CharsetOptions { CharsetOptions { length: self.length, upper: !self.no_upper, lower: !self.no_lower, digits: !self.no_digits, symbols: !self.no_symbols, exclude_ambiguous: self.exclude_ambiguous, } } pub fn passphrase_options(&self) -> PassphraseOptions { PassphraseOptions { words: self.words, separator: self.separator, capitalize: self.capitalize, add_number: self.add_number, } } } use clap::CommandFactory; pub fn write_completions(shell: ShellKind, out: &mut impl std::io::Write) -> std::io::Result<()> { let mut cmd = Cli::command(); let name = cmd.get_name().to_string(); // clap_complete panics internally if the writer it is handed fails, so it // renders into memory and the fallible write happens here. let mut script = Vec::new(); match shell { ShellKind::Nu => { clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, name, &mut script) } ShellKind::Bash => { clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, name, &mut script) } ShellKind::Zsh => { clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, name, &mut script) } ShellKind::Fish => { clap_complete::generate(clap_complete::Shell::Fish, &mut cmd, name, &mut script) } } out.write_all(&script) } #[cfg(test)] mod tests { use super::*; use clap::{CommandFactory, Parser}; #[test] fn argument_definition_is_valid() { Cli::command().debug_assert(); } #[test] fn bare_invocation_has_no_subcommand_and_full_defaults() { let cli = Cli::parse_from(["pasx"]); assert!(cli.command.is_none()); assert_eq!(cli.opts.mode, Mode::Charset); assert_eq!(cli.opts.charset_options(), CharsetOptions::default()); assert_eq!(cli.opts.passphrase_options(), PassphraseOptions::default()); } #[test] fn negative_flags_switch_classes_off() { let cli = Cli::parse_from(["pasx", "--length", "24", "--no-symbols", "--no-upper"]); let opts = cli.opts.charset_options(); assert_eq!(opts.length, 24); assert!(!opts.symbols); assert!(!opts.upper); assert!(opts.lower); assert!(opts.digits); } #[test] fn passphrase_flags_reach_the_options() { let cli = Cli::parse_from([ "pasx", "--mode", "passphrase", "--words", "7", "--separator", ".", "--capitalize", ]); assert_eq!(cli.opts.mode, Mode::Passphrase); let opts = cli.opts.passphrase_options(); assert_eq!(opts.words, 7); assert_eq!(opts.separator, '.'); assert!(opts.capitalize); assert!(!opts.add_number); } #[test] fn gen_subcommand_takes_the_same_flags_plus_copy() { let cli = Cli::parse_from(["pasx", "gen", "-l", "32", "--copy"]); match cli.command { Some(Command::Gen(args)) => { assert_eq!(args.opts.charset_options().length, 32); assert!(args.copy); } other => panic!("expected gen subcommand, got {other:?}"), } } #[test] fn completions_subcommand_parses_every_shell() { for (arg, expected) in [ ("nu", ShellKind::Nu), ("bash", ShellKind::Bash), ("zsh", ShellKind::Zsh), ("fish", ShellKind::Fish), ] { let cli = Cli::parse_from(["pasx", "completions", arg]); match cli.command { Some(Command::Completions { shell }) => assert_eq!(shell, expected), other => panic!("expected completions subcommand, got {other:?}"), } } } #[test] fn exclude_ambiguous_reaches_the_options() { let cli = Cli::parse_from(["pasx", "--exclude-ambiguous"]); assert!(cli.opts.charset_options().exclude_ambiguous); } #[test] fn nushell_completions_mention_the_binary_and_its_flags() { let mut buf = Vec::new(); write_completions(ShellKind::Nu, &mut buf).unwrap(); let script = String::from_utf8(buf).unwrap(); assert!(script.contains("pasx"), "{script}"); assert!(script.contains("--exclude-ambiguous"), "{script}"); assert!(script.contains("pasx gen"), "{script}"); } #[test] fn every_shell_produces_a_non_empty_script() { let shells = [ ShellKind::Nu, ShellKind::Bash, ShellKind::Zsh, ShellKind::Fish, ]; for shell in shells { let mut buf = Vec::new(); write_completions(shell, &mut buf).unwrap(); assert!(!buf.is_empty(), "{shell:?} produced nothing"); } } }