rusty-pwgen 0.1.0

Generate pronounceable or random passwords from the OS CSPRNG — a Rust port of Theodore Ts'o's `pwgen` with strict-compat mode, deterministic `-H` reproducible mode (SHA256 + ChaCha20), and a typed library API.
Documentation
//! Command-line interface — clap derive `Cli` struct (FR-011 through FR-016).

use clap::Parser;

#[derive(Parser, Debug)]
#[command(
    name = "rusty-pwgen",
    version,
    about = "Generate pronounceable or random passwords from the OS CSPRNG.",
    long_about = "A Rust port of Theodore Ts'o's pwgen. Default mode generates \
                  pronounceable passwords via a phoneme algorithm; `-s` mode \
                  generates uniform-random passwords via the OS CSPRNG (`OsRng`)."
)]
pub struct Cli {
    /// Capitalize sprinkle layer on (default for pronounceable mode).
    #[arg(
        short = 'c',
        long = "capitalize",
        conflicts_with = "no_capitalize",
        overrides_with = "capitalize"
    )]
    pub capitalize: bool,

    /// Capitalize sprinkle layer off.
    #[arg(short = 'A', long = "no-capitalize", overrides_with = "no_capitalize")]
    pub no_capitalize: bool,

    /// Numeral sprinkle layer on (default for pronounceable mode).
    #[arg(
        short = 'n',
        long = "numerals",
        conflicts_with = "no_numerals",
        overrides_with = "numerals"
    )]
    pub numerals: bool,

    /// Numeral sprinkle layer off.
    #[arg(short = '0', long = "no-numerals", overrides_with = "no_numerals")]
    pub no_numerals: bool,

    /// Include symbols in the active character set.
    #[arg(short = 'y', long = "symbols")]
    pub symbols: bool,

    /// Secure (random) mode — no phoneme constraints.
    #[arg(short = 's', long = "secure")]
    pub secure: bool,

    /// Drop ambiguous characters (l 1 0 O I).
    #[arg(short = 'B', long = "ambiguous")]
    pub ambiguous_filter: bool,

    /// No vowels (implies `-s`).
    #[arg(short = 'v', long = "no-vowels")]
    pub no_vowels: bool,

    /// Force one password per line (no columnar output).
    #[arg(short = '1', conflicts_with = "columnar")]
    pub one_column: bool,

    /// Force columnar output regardless of TTY status.
    #[arg(short = 'C')]
    pub columnar: bool,

    /// Override count (wins over positional count).
    #[arg(short = 'N', long = "num-passwords")]
    pub num_passwords: Option<usize>,

    /// Drop these characters from the active set (implies `-s`).
    #[arg(short = 'r', long = "remove-chars")]
    pub remove_chars: Option<String>,

    /// Reproducible mode: SHA256(file ++ "#" ++ suffix) → ChaCha20Rng seed.
    /// WARNING: deterministic; not for high-value secrets.
    #[arg(short = 'H', long = "sha1")]
    pub sha1: Option<String>,

    /// Enable strict upstream-pwgen compat mode.
    #[arg(long, conflicts_with = "no_strict")]
    pub strict: bool,

    /// Explicitly disable strict mode (overrides env + argv[0]).
    #[arg(long = "no-strict")]
    pub no_strict: bool,

    /// Password length.
    #[arg(default_value_t = 8)]
    pub length: usize,

    /// Count override via positional arg. Either `-N` or this positional may be omitted;
    /// `-N` wins when both are supplied (FR-011).
    pub count: Option<usize>,

    /// Subcommand (currently only `completions`).
    #[command(subcommand)]
    pub command: Option<Subcommand>,
}

#[derive(clap::Subcommand, Debug)]
pub enum Subcommand {
    /// Emit shell completion scripts.
    Completions { shell: clap_complete::Shell },
}

impl Cli {
    /// Resolve effective capitalize flag — default-on unless `-A` was set.
    /// `-c` is the no-op affirmative form (matches upstream pwgen default).
    pub fn capitalize_effective(&self) -> bool {
        !self.no_capitalize
    }

    /// Resolve effective numerals flag — default-on unless `-0` was set.
    /// `-n` is the no-op affirmative form (matches upstream pwgen default).
    pub fn numerals_effective(&self) -> bool {
        !self.no_numerals
    }

    /// FR-011 + FR-019: count resolution.
    /// `-N` > positional > (TTY ? `cols × 20` : 1).
    pub fn resolved_count(&self, is_tty: bool) -> usize {
        if let Some(n) = self.num_passwords {
            return n;
        }
        if let Some(n) = self.count {
            return n;
        }
        if is_tty {
            // Use ~80 columns / (length + 1) padding = ~8 per row at default length 8.
            let cols_per_row = crate::output::columns() / (self.length + 1).max(1);
            cols_per_row.max(1) * crate::DEFAULT_TTY_ROWS
        } else {
            crate::DEFAULT_COUNT_PIPED
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::CommandFactory;

    #[test]
    fn cli_compiles() {
        let cmd = Cli::command();
        assert_eq!(cmd.get_name(), "rusty-pwgen");
    }

    #[test]
    fn parse_no_args_defaults() {
        let cli = Cli::try_parse_from(["rusty-pwgen"]).unwrap();
        assert_eq!(cli.length, 8);
        assert!(cli.count.is_none());
        assert!(!cli.secure);
    }

    #[test]
    fn parse_positional_length_and_count() {
        let cli = Cli::try_parse_from(["rusty-pwgen", "12", "5"]).unwrap();
        assert_eq!(cli.length, 12);
        assert_eq!(cli.count, Some(5));
    }

    #[test]
    fn parse_n_flag_wins_over_positional() {
        // FR-011: -N wins over positional count.
        let cli = Cli::try_parse_from(["rusty-pwgen", "-N", "3", "12", "10"]).unwrap();
        assert_eq!(cli.length, 12);
        assert_eq!(cli.count, Some(10));
        assert_eq!(cli.num_passwords, Some(3));
        assert_eq!(cli.resolved_count(false), 3);
    }

    #[test]
    fn parse_secure_flag() {
        let cli = Cli::try_parse_from(["rusty-pwgen", "-s", "16"]).unwrap();
        assert!(cli.secure);
        assert_eq!(cli.length, 16);
    }

    #[test]
    fn parse_conflicting_caps_flags_rejected() {
        // FR-014: Default mode rejects -c -A.
        let result = Cli::try_parse_from(["rusty-pwgen", "-c", "-A"]);
        assert!(result.is_err());
    }

    #[test]
    fn parse_conflicting_one_vs_columnar_rejected() {
        let result = Cli::try_parse_from(["rusty-pwgen", "-1", "-C"]);
        assert!(result.is_err());
    }

    #[test]
    fn parse_strict_conflicts_with_no_strict() {
        let result = Cli::try_parse_from(["rusty-pwgen", "--strict", "--no-strict"]);
        assert!(result.is_err());
    }
}