cargo-port 0.0.3

A TUI for inspecting and managing Rust projects
use ratatui::text::Line;

use super::GitData;
use super::PackageData;
use super::model;
use super::model::DetailField;
use crate::ci::CiJob;
use crate::ci::CiRun;
use crate::ci::Conclusion;
use crate::ci::FetchStatus::Fetched;
use crate::project::GitPathState;
use crate::tui::constants::LABEL_COLOR;
use crate::tui::panes;
use crate::tui::panes::CI_COMPACT_DURATION_WIDTH;
use crate::tui::render::CiColumn;
use crate::tui::types::PaneFocusState;

fn package_data(is_rust_project: bool) -> PackageData {
    PackageData {
        package_title:    if is_rust_project {
            "Package".to_string()
        } else {
            "Project".to_string()
        },
        title_name:       "demo".to_string(),
        abs_path:         "/tmp/demo".into(),
        path:             "~/demo".to_string(),
        version:          "0.1.0".to_string(),
        description:      None,
        crates_version:   None,
        crates_downloads: None,
        types:            "lib".to_string(),
        disk:             "36.3 GiB".to_string(),
        ci:               None,
        stats_rows:       Vec::new(),
        has_package:      true,
    }
}

fn git_data() -> GitData {
    GitData {
        branch:            None,
        path_state:        GitPathState::OutsideRepo,
        sync:              None,
        vs_origin:         None,
        vs_local:          None,
        local_main_branch: None,
        main_branch_label: "main".to_string(),
        origin:            None,
        owner:             None,
        url:               None,
        stars:             None,
        description:       None,
        inception:         None,
        last_commit:       None,
        worktree_names:    Vec::new(),
    }
}

fn ci_run_with_jobs(jobs: Vec<CiJob>) -> CiRun {
    CiRun {
        run_id: 1,
        created_at: "2026-04-01T21:00:00-04:00".to_string(),
        branch: "feat/box-select".to_string(),
        url: "https://example.com/run/1".to_string(),
        conclusion: Conclusion::Success,
        jobs,
        wall_clock_secs: Some(17),
        commit_title: Some("feat: add box select".to_string()),
        updated_at: None,
        fetched: Fetched,
    }
}

fn line_text(line: &Line<'_>) -> String {
    line.spans
        .iter()
        .map(|span| span.content.as_ref())
        .collect()
}

#[test]
fn stats_width_cases() {
    let cases = [
        (
            "three_digit_counts",
            vec![("example", 999), ("lib", 1)],
            17,
            3,
        ),
        (
            "four_digit_counts",
            vec![("example", 1000), ("lib", 1)],
            18,
            4,
        ),
        ("short_labels", vec![("lib", 5), ("bin", 2)], 17, 3),
        ("empty_rows", vec![], 17, 3),
    ];

    for (name, rows, expected_total, expected_digits) in cases {
        let mut data = package_data(true);
        data.stats_rows = rows;
        let (total, digits) = panes::stats_column_width(&data);
        assert_eq!(total, expected_total, "{name}");
        assert_eq!(digits, expected_digits, "{name}");
    }
}

#[test]
fn package_fields_place_lint_and_ci_before_disk_for_rust_projects() {
    let data = package_data(true);
    assert_eq!(
        model::package_fields_from_data(&data)
            .into_iter()
            .map(DetailField::label)
            .collect::<Vec<_>>(),
        vec!["Path", "Disk", "Type", "Lint", "CI", "Version"]
    );
}

#[test]
fn package_fields_place_lint_and_ci_before_disk_for_non_rust_projects() {
    let data = package_data(false);
    assert_eq!(
        model::package_fields_from_data(&data)
            .into_iter()
            .map(DetailField::label)
            .collect::<Vec<_>>(),
        vec!["Path", "Disk", "Lint", "CI"]
    );
}

#[test]
fn package_label_width_expands_for_crates_io() {
    let data = PackageData {
        crates_version: Some("0.0.3".to_string()),
        crates_downloads: Some(74),
        ..package_data(true)
    };
    let fields = model::package_fields_from_data(&data);
    assert_eq!(panes::package_label_width(&fields), "crates.io".len());
}

#[test]
fn description_lines_use_muted_fallback_when_missing() {
    let data = package_data(true);

    let lines = panes::description_lines(&data, 80, 3);

    assert_eq!(lines.len(), 1);
    assert_eq!(line_text(&lines[0]), "No description available");
    assert_eq!(lines[0].spans[0].style.fg, Some(LABEL_COLOR));
}

#[test]
fn description_lines_render_real_description_with_default_style() {
    let data = PackageData {
        description: Some("Real package description".to_string()),
        ..package_data(true)
    };

    let lines = panes::description_lines(&data, 80, 3);

    assert_eq!(lines.len(), 1);
    assert_eq!(line_text(&lines[0]), "Real package description");
    assert_eq!(lines[0].spans[0].style.fg, None);
}

#[test]
fn description_lines_truncate_overflow_with_ellipsis() {
    let data = PackageData {
        description: Some("one two three four five six seven eight".to_string()),
        ..package_data(true)
    };

    let lines = panes::description_lines(&data, 13, 2);

    assert_eq!(lines.len(), 2);
    assert_eq!(line_text(&lines[0]), "one two three");
    assert!(line_text(&lines[1]).ends_with(''));
}

#[test]
fn detail_column_scroll_waits_until_cursor_reaches_bottom() {
    let focus = PaneFocusState::Active;

    assert_eq!(panes::detail_column_scroll_offset(focus, 0, 4), 0);
    assert_eq!(panes::detail_column_scroll_offset(focus, 3, 4), 0);
    assert_eq!(panes::detail_column_scroll_offset(focus, 4, 4), 1);
    assert_eq!(panes::detail_column_scroll_offset(focus, 7, 4), 4);
}

#[test]
fn detail_column_scroll_stays_at_top_when_not_active() {
    assert_eq!(
        panes::detail_column_scroll_offset(PaneFocusState::Remembered, 7, 4),
        0
    );
    assert_eq!(
        panes::detail_column_scroll_offset(PaneFocusState::Inactive, 7, 4),
        0
    );
}

#[test]
fn git_path_value_appends_status_icon() {
    let data = GitData {
        path_state: GitPathState::Modified,
        ..git_data()
    };

    assert_eq!(DetailField::GitPath.git_value(&data), "🟠 modified");
}

#[test]
fn sync_value_uses_synced_label_when_in_sync() {
    assert_eq!(model::format_remote_status(Some((0, 0))), "☑️");
}

#[test]
fn git_label_width_uses_origin_and_configured_main_labels() {
    let data = GitData {
        vs_origin: Some("origin/main (local cached ref)".to_string()),
        vs_local: Some("↑11 ↓3".to_string()),
        main_branch_label: "primary".to_string(),
        ..git_data()
    };
    let fields = vec![DetailField::VsOrigin, DetailField::VsLocal];

    assert_eq!(
        panes::git_label_width(&data, &fields),
        "vs local primary".len()
    );
}

#[test]
fn git_fields_show_explicit_remote_and_local_rows_for_unpublished_branch() {
    let data = GitData {
        sync: Some(crate::constants::NO_REMOTE_SYNC.to_string()),
        vs_origin: Some(crate::constants::NO_CI_UNPUBLISHED_BRANCH.to_string()),
        vs_local: Some("↑11 ↓3".to_string()),
        ..git_data()
    };

    assert_eq!(
        model::git_fields_from_data(&data),
        vec![
            DetailField::VsOrigin,
            DetailField::Sync,
            DetailField::VsLocal
        ]
    );
}

#[test]
fn git_remote_field_label_uses_remote_without_branch_suffix() {
    assert_eq!(DetailField::VsOrigin.label(), "Remote");
}

#[test]
fn ci_table_hides_durations_when_fixed_columns_overflow() {
    let runs = vec![ci_run_with_jobs(vec![
        CiJob {
            name:          "fmt".to_string(),
            conclusion:    Conclusion::Success,
            duration:      "17s".to_string(),
            duration_secs: Some(17),
        },
        CiJob {
            name:          "clippy".to_string(),
            conclusion:    Conclusion::Success,
            duration:      "21s".to_string(),
            duration_secs: Some(21),
        },
    ])];
    let cols = vec![CiColumn::Fmt, CiColumn::Clippy];

    assert!(!panes::ci_table_shows_durations(&runs, &cols, 20));
    assert_eq!(
        panes::ci_total_width(&runs, false),
        CI_COMPACT_DURATION_WIDTH
    );
}

#[test]
fn ci_table_keeps_durations_when_fixed_columns_fit() {
    let runs = vec![ci_run_with_jobs(vec![CiJob {
        name:          "fmt".to_string(),
        conclusion:    Conclusion::Success,
        duration:      "17s".to_string(),
        duration_secs: Some(17),
    }])];
    let cols = vec![CiColumn::Fmt];

    assert!(panes::ci_table_shows_durations(&runs, &cols, 80));
}