prlens 0.1.1

One queue for all your PRs — aggregates GitHub and Bitbucket review requests into a single interactive view
Documentation
use clap::{Args, Parser, Subcommand, ValueEnum};

#[derive(Parser)]
#[command(
    name = "prlens",
    version,
    about = "Aggregate PR review queues from GitHub and Bitbucket",
    long_about = "prlens shows all pull requests waiting for your review across providers.\nConfigured via ~/.config/prlens/config.toml",
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Commands>,
}

#[derive(Subcommand)]
pub enum Commands {
    /// List pull requests awaiting your review
    List(ListArgs),
    /// Open a pull request in the default browser
    Open(OpenArgs),
}

#[derive(Args)]
pub struct OpenArgs {
    /// Pull request number to open (as shown in the list)
    pub number: u64,
}

/// Filter pull requests by review status.
#[derive(Clone, Debug, ValueEnum)]
pub enum StatusFilter {
    /// Show PRs with NeedsReview or InReview status
    Pending,
    /// Show only approved PRs
    Approved,
    /// Show PRs with ChangesRequested or Mixed status
    #[value(name = "changes-requested")]
    ChangesRequested,
}

/// Sort order for the PR queue.
#[derive(Clone, Debug, ValueEnum)]
pub enum SortKey {
    /// Oldest activity first
    Age,
    /// Alphabetical by author login
    Author,
    /// By urgency: changes-requested first
    Status,
}

#[derive(Args, Clone)]
pub struct ListArgs {
    /// Filter by repository name (owner/repo or just repo)
    #[arg(long)]
    pub repo: Option<String>,

    /// Filter by organization name
    #[arg(long)]
    pub org: Option<String>,

    /// Filter by review status
    #[arg(long, value_enum)]
    pub status: Option<StatusFilter>,

    /// Sort order
    #[arg(long, value_enum, default_value = "age")]
    pub sort: SortKey,

    /// Activate a named profile on startup (matches config [[profiles]] name, case-insensitive)
    #[arg(short = 'p', long)]
    pub profile: Option<String>,
}

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

    #[test]
    fn parse_status_pending() {
        let cli = Cli::try_parse_from(["prlens", "list", "--status", "pending"]).unwrap();
        match cli.command {
            Some(Commands::List(args)) => {
                assert!(matches!(args.status, Some(StatusFilter::Pending)));
            }
            _ => panic!("expected List subcommand"),
        }
    }

    #[test]
    fn parse_status_changes_requested() {
        let cli = Cli::try_parse_from(["prlens", "list", "--status", "changes-requested"]).unwrap();
        match cli.command {
            Some(Commands::List(args)) => {
                assert!(matches!(args.status, Some(StatusFilter::ChangesRequested)));
            }
            _ => panic!("expected List subcommand"),
        }
    }

    #[test]
    fn parse_sort_author() {
        let cli = Cli::try_parse_from(["prlens", "list", "--sort", "author"]).unwrap();
        match cli.command {
            Some(Commands::List(args)) => {
                assert!(matches!(args.sort, SortKey::Author));
            }
            _ => panic!("expected List subcommand"),
        }
    }

    #[test]
    fn parse_default_sort_is_age() {
        let cli = Cli::try_parse_from(["prlens", "list"]).unwrap();
        match cli.command {
            Some(Commands::List(args)) => {
                assert!(matches!(args.sort, SortKey::Age));
            }
            _ => panic!("expected List subcommand"),
        }
    }

    #[test]
    fn parse_open_with_number_succeeds() {
        let cli = Cli::try_parse_from(["prlens", "open", "42"]).unwrap();
        match cli.command {
            Some(Commands::Open(args)) => {
                assert_eq!(args.number, 42);
            }
            _ => panic!("expected Open subcommand with number 42"),
        }
    }

    #[test]
    fn parse_open_without_number_returns_err() {
        let result = Cli::try_parse_from(["prlens", "open"]);
        assert!(result.is_err(), "open without a number should fail to parse");
    }

    #[test]
    fn parse_profile_short_flag() {
        let cli = Cli::try_parse_from(["prlens", "list", "-p", "work"]).unwrap();
        match cli.command {
            Some(Commands::List(args)) => assert_eq!(args.profile.as_deref(), Some("work")),
            _ => panic!("expected List subcommand"),
        }
    }

    #[test]
    fn parse_profile_long_flag() {
        let cli = Cli::try_parse_from(["prlens", "list", "--profile", "oss"]).unwrap();
        match cli.command {
            Some(Commands::List(args)) => assert_eq!(args.profile.as_deref(), Some("oss")),
            _ => panic!("expected List subcommand"),
        }
    }

    #[test]
    fn parse_profile_default_is_none() {
        let cli = Cli::try_parse_from(["prlens", "list"]).unwrap();
        match cli.command {
            Some(Commands::List(args)) => assert_eq!(args.profile, None),
            _ => panic!("expected List subcommand"),
        }
    }
}