prlens 0.1.0

One queue for all your PRs — aggregates GitHub and Bitbucket review requests into a single interactive view
Documentation
use chrono::{DateTime, Utc};
use comfy_table::{Cell, ColumnConstraint, ContentArrangement, Table, Width, presets::UTF8_FULL_CONDENSED};
use crate::models::{CiStatus, PullRequest, ReviewStatus};

/// Print a unified table of pull requests from all providers.
/// All PRs are in a single sorted list — no per-provider section breaks.
/// If the slice is empty, prints "No pull requests found." without a table.
pub fn print_pr_table_unified(prs: &[PullRequest]) {
    if prs.is_empty() {
        println!("No pull requests found.");
        return;
    }

    let mut table = Table::new();
    table.load_preset(UTF8_FULL_CONDENSED);
    table.set_content_arrangement(ContentArrangement::Dynamic);
    table.set_header(vec!["Src", "#", "Title", "Author", "Repo", "Status", "CI", "Rev.", "Age"]);

    // Constrain title column (index 2) — comfy-table handles Unicode width correctly.
    // Must be called after set_header() so the column object exists.
    if let Some(col) = table.column_mut(2) {
        col.set_constraint(ColumnConstraint::UpperBoundary(Width::Fixed(50)));
    }

    for pr in prs {
        let provider_badge = match pr.provider.as_str() {
            "github" => "\u{E709}",    // nf-dev-github
            "bitbucket" => "\u{E703}", // nf-dev-bitbucket
            other => other,
        };

        // Draft indicator: nf-md-pencil_circle prefix when pr.draft is true
        let title = if pr.draft {
            format!("\u{F444} {}", pr.title)
        } else {
            pr.title.clone()
        };

        table.add_row(vec![
            Cell::new(provider_badge),
            Cell::new(pr.number.to_string()),
            Cell::new(&title),
            Cell::new(&pr.author.login),
            Cell::new(&pr.repo_full_name),
            Cell::new(format_review_status(&pr.review_status)),
            Cell::new(format_ci_status(&pr.ci_status)),
            Cell::new(pr.reviewers.len().to_string()),
            Cell::new(format_age(&pr.updated_at)),
        ]);
    }

    println!("{}", table);
}

/// Print a formatted table of pull requests to stdout (legacy per-provider function).
/// Kept for backward compatibility. New code should use print_pr_table_unified.
/// If the slice is empty, prints a "No pull requests found" message.
pub fn print_pr_table(provider_name: &str, prs: &[PullRequest]) {
    if prs.is_empty() {
        println!("[{}] No pull requests found.", provider_name);
        return;
    }

    let mut table = Table::new();
    table.load_preset(UTF8_FULL_CONDENSED);
    table.set_header(vec!["Provider", "#", "Title", "Author", "Repo", "Status", "Updated"]);

    for pr in prs {
        let title = if pr.title.chars().count() > 50 {
            format!("{}...", &pr.title.chars().take(47).collect::<String>())
        } else {
            pr.title.clone()
        };

        table.add_row(vec![
            provider_name.to_string(),
            pr.number.to_string(),
            title,
            pr.author.login.clone(),
            pr.repo_full_name.clone(),
            format_review_status(&pr.review_status).to_string(),
            format!("{}", pr.updated_at.format("%Y-%m-%d %H:%M")),
        ]);
    }

    println!("{}", table);
}

/// Format a DateTime as a compact relative age string.
/// Buckets: "now" (<60s), "{n}m" (<60min), "{n}h" (<24h), "{n}d" (<7d),
///          "{n}w" (<8 weeks), "{n}mo" (<12 months), "{n}y" (else).
/// Clock skew guard: negative elapsed returns "future".
fn format_age(dt: &DateTime<Utc>) -> String {
    let elapsed = Utc::now().signed_duration_since(*dt);
    let secs = elapsed.num_seconds();

    if secs < 0 {
        return "future".to_string();
    }
    if secs < 60 {
        return "now".to_string();
    }
    let mins = elapsed.num_minutes();
    if mins < 60 {
        return format!("{}m", mins);
    }
    let hours = elapsed.num_hours();
    if hours < 24 {
        return format!("{}h", hours);
    }
    let days = elapsed.num_days();
    if days < 7 {
        return format!("{}d", days);
    }
    let weeks = days / 7;
    if weeks < 8 {
        return format!("{}w", weeks);
    }
    let months = days / 30;
    if months < 12 {
        return format!("{}mo", months);
    }
    format!("{}y", days / 365)
}

fn format_review_status(status: &ReviewStatus) -> &'static str {
    match status {
        ReviewStatus::NeedsReview => "needs review",
        ReviewStatus::Approved => "approved",
        ReviewStatus::ChangesRequested => "changes requested",
        ReviewStatus::Mixed => "mixed",
        ReviewStatus::InReview => "in review",
    }
}

/// Format a CI status as a Nerd Font icon.
fn format_ci_status(ci: &Option<CiStatus>) -> &'static str {
    match ci {
        None => "\u{F068}",                                          // nf-fa-minus
        Some(CiStatus::Success) => "\u{F058}",                      // nf-fa-check_circle
        Some(CiStatus::Failed) => "\u{F057}",                       // nf-fa-times_circle
        Some(CiStatus::Pending) | Some(CiStatus::Running) => "\u{F110}", // nf-fa-spinner
        Some(CiStatus::Cancelled) => "\u{F068}",                    // nf-fa-minus
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{CiStatus, PrIdentifier, PrState, PullRequest, ReviewStatus, User};
    use chrono::Duration;

    fn make_pr(draft: bool, updated_at: DateTime<Utc>) -> PullRequest {
        PullRequest {
            id: PrIdentifier {
                provider: "github".to_string(),
                owner: "acme".to_string(),
                repo: "widget".to_string(),
                number: 1,
            },
            number: 1,
            title: "feat: test PR".to_string(),
            url: "https://github.com/acme/widget/pull/1".to_string(),
            author: User {
                login: "alice".to_string(),
                display_name: None,
                avatar_url: None,
            },
            reviewers: vec![],
            repo_full_name: "acme/widget".to_string(),
            provider: "github".to_string(),
            head_branch: "feat/test".to_string(),
            base_branch: "main".to_string(),
            state: PrState::Open,
            review_status: ReviewStatus::NeedsReview,
            ci_status: None,
            draft,
            created_at: Utc::now(),
            updated_at,
            labels: vec![],
            comment_count: 0,
            additions: None,
            deletions: None,
        }
    }

    #[test]
    fn format_age_minutes() {
        let dt = Utc::now() - Duration::minutes(45);
        assert_eq!(format_age(&dt), "45m");
    }

    #[test]
    fn format_age_days() {
        let dt = Utc::now() - Duration::days(3);
        assert_eq!(format_age(&dt), "3d");
    }

    #[test]
    fn format_age_now() {
        let dt = Utc::now() - Duration::seconds(30);
        assert_eq!(format_age(&dt), "now");
    }

    #[test]
    fn format_age_hours() {
        let dt = Utc::now() - Duration::hours(5);
        assert_eq!(format_age(&dt), "5h");
    }

    #[test]
    fn format_age_weeks() {
        let dt = Utc::now() - Duration::days(14);
        assert_eq!(format_age(&dt), "2w");
    }

    #[test]
    fn format_age_months() {
        let dt = Utc::now() - Duration::days(90);
        assert_eq!(format_age(&dt), "3mo");
    }

    #[test]
    fn format_age_future_clock_skew() {
        // DateTime slightly in the future (clock skew) returns "future"
        let dt = Utc::now() + Duration::seconds(60);
        assert_eq!(format_age(&dt), "future");
    }

    #[test]
    fn format_ci_status_none_returns_dash() {
        assert_eq!(format_ci_status(&None), "\u{F068}");
    }

    #[test]
    fn format_ci_status_success() {
        assert_eq!(format_ci_status(&Some(CiStatus::Success)), "\u{F058}");
    }

    #[test]
    fn format_ci_status_failed() {
        assert_eq!(format_ci_status(&Some(CiStatus::Failed)), "\u{F057}");
    }

    #[test]
    fn format_ci_status_pending() {
        assert_eq!(format_ci_status(&Some(CiStatus::Pending)), "\u{F110}");
    }

    #[test]
    fn format_ci_status_running() {
        assert_eq!(format_ci_status(&Some(CiStatus::Running)), "\u{F110}");
    }

    #[test]
    fn format_ci_status_cancelled() {
        assert_eq!(format_ci_status(&Some(CiStatus::Cancelled)), "\u{F068}");
    }

    #[test]
    fn draft_pr_has_d_prefix_in_title() {
        let pr = make_pr(true, Utc::now() - Duration::hours(1));
        // Build what the table cell would contain for a draft PR
        let title = if pr.draft {
            format!("\u{F444} {}", pr.title)
        } else {
            pr.title.clone()
        };
        assert!(title.starts_with("\u{F444} "), "Draft PR title must start with draft icon, got: {}", title);
        assert_eq!(title, "\u{F444} feat: test PR");
    }

    #[test]
    fn non_draft_pr_has_no_d_prefix() {
        let pr = make_pr(false, Utc::now() - Duration::hours(1));
        let title = if pr.draft {
            format!("\u{F444} {}", pr.title)
        } else {
            pr.title.clone()
        };
        assert!(!title.starts_with("\u{F444} "), "Non-draft PR title must not start with draft icon");
        assert_eq!(title, "feat: test PR");
    }

    #[test]
    fn print_pr_table_unified_empty_prints_no_prs_message() {
        // Verify empty slice doesn't panic and (by convention) returns without building a table.
        // We can't easily assert stdout here, but we can verify it doesn't panic.
        print_pr_table_unified(&[]);
    }

    #[test]
    fn print_pr_table_unified_single_pr_does_not_panic() {
        let pr = make_pr(false, Utc::now() - Duration::hours(2));
        // Smoke test — verify no panic when rendering a single PR row.
        print_pr_table_unified(&[pr]);
    }
}