reserve 0.1.1

Check domain name availability across grouped extensions, straight from the registry
pub(crate) mod detail;
pub(crate) mod table;

use std::fmt::Write as _;

use anstyle::{AnsiColor, Color, Style};
use reserve_core::{Catalog, Extension, Family, Page, Sort, SortDirection, SortKey};

use crate::output::table::{Column, Table};

/// @docgen Only the sixteen named terminal colors are used, so a rethemed terminal shows the user's palette rather than ours.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Palette {
    enabled: bool,
}

impl Palette {
    #[must_use]
    pub(crate) const fn new(enabled: bool) -> Self {
        Self { enabled }
    }

    fn paint(self, text: &str, style: Style) -> String {
        if self.enabled {
            format!("{style}{text}{style:#}")
        } else {
            text.to_owned()
        }
    }

    #[must_use]
    pub(crate) fn heading(self, text: &str) -> String {
        self.paint(text, Style::new().bold())
    }

    #[must_use]
    pub(crate) fn dim(self, text: &str) -> String {
        self.paint(text, Style::new().dimmed())
    }

    #[must_use]
    pub(crate) fn error(self, text: &str) -> String {
        self.paint(
            text,
            Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))),
        )
    }

    #[must_use]
    pub(crate) fn success(self, text: &str) -> String {
        self.paint(
            text,
            Style::new()
                .fg_color(Some(Color::Ansi(AnsiColor::Green)))
                .bold(),
        )
    }

    #[must_use]
    pub(crate) fn warning(self, text: &str) -> String {
        self.paint(
            text,
            Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow))),
        )
    }

    #[must_use]
    pub(crate) fn accent(self, text: &str) -> String {
        self.paint(
            text,
            Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))),
        )
    }
}

#[must_use]
pub(crate) fn format_thousands(value: u64) -> String {
    let digits = value.to_string();
    let mut out = String::with_capacity(digits.len().saturating_add(digits.len().div_ceil(3)));
    for (index, ch) in digits.chars().enumerate() {
        let remaining = digits.len() - index;
        if index > 0 && remaining.is_multiple_of(3) {
            out.push(',');
        }
        out.push(ch);
    }
    out
}

#[must_use]
pub(crate) fn groups(
    catalog: &Catalog,
    only: Option<Family>,
    palette: Palette,
    width: usize,
) -> String {
    let families = only.map_or_else(|| Family::all().to_vec(), |family| vec![family]);
    let mut out = String::new();

    for (index, family) in families.iter().enumerate() {
        let family_groups = catalog.groups_in(*family);
        if family_groups.is_empty() {
            continue;
        }
        if index > 0 {
            out.push('\n');
        }
        let _ = writeln!(out, "{}", palette.heading(family.title()));

        let mut table = Table::new(vec![
            Column::left("GROUP").shrinks_to(8),
            Column::right("EXTENSIONS").drop_order(2).shrinks_to(4),
            Column::left("WHAT IT COVERS").drop_order(1).shrinks_to(14),
        ]);
        for group in family_groups {
            table.push(vec![
                palette.accent(&group.key),
                catalog.group_size(group).to_string(),
                palette.dim(&group.summary),
            ]);
        }
        out.push_str(&table.render(false, width));
    }

    if out.is_empty() {
        return "No groups matched.\n".to_owned();
    }
    out
}

#[must_use]
pub(crate) fn extensions(
    items: &[&Extension],
    page: Page,
    sort: Sort,
    palette: Palette,
    show_footer: bool,
    width: usize,
) -> String {
    let visible = page.slice(items);
    let mut table = Table::new(vec![
        Column::left("EXTENSION").shrinks_to(10),
        Column::right("RANK").drop_order(3).shrinks_to(4),
        Column::left("KIND").drop_order(2).shrinks_to(7),
        Column::left("USED FOR")
            .drop_order(1)
            .shrinks_to(12)
            .caps_at(44),
    ]);

    for ext in visible {
        table.push(vec![
            palette.accent(ext.suffix.as_str()),
            ext.rank.map_or_else(|| "-".to_owned(), |r| r.to_string()),
            ext.kind.label().to_owned(),
            palette.dim(&describe_use(ext)),
        ]);
    }

    let mut out = table.render(true, width);
    if show_footer {
        out.push('\n');
        out.push_str(&footer(items.len(), page, sort, palette));
    }
    out
}

fn describe_use(ext: &Extension) -> String {
    let mut parts: Vec<String> = Vec::new();
    if let Some(country) = &ext.country {
        parts.push(country.clone());
    }
    if !ext.industries.is_empty() {
        parts.push(ext.industries.join(", "));
    }
    if ext.repurposed {
        parts.push("often used off-label".to_owned());
    }
    if !ext.registrable {
        parts.push("not publicly registrable".to_owned());
    }
    parts.join(" · ")
}

#[must_use]
pub(crate) fn footer(total: usize, page: Page, sort: Sort, palette: Palette) -> String {
    let pages = page.pages_for(total);
    let shown = total.saturating_sub(page.offset()).min(page.size);
    let first = if total == 0 { 0 } else { page.offset() + 1 };
    let last = page.offset() + shown;

    let mut line = format!(
        "{} of {} extensions · page {} of {} · sorted by {} {}",
        palette.heading(&format!("{first}-{last}")),
        format_thousands(total as u64),
        page.number.min(pages),
        pages,
        sort.key,
        describe_direction(sort),
    );

    if pages > 1 {
        let mut page_flags: Vec<String> = Vec::new();
        if page.previous().is_some() {
            page_flags.push(format!("--page {}", page.number.saturating_sub(1)));
        }
        if page.next(total).is_some() {
            page_flags.push(format!("--page {}", page.number.saturating_add(1)));
        }
        page_flags.push("--all-pages".to_owned());
        let _ = write!(
            line,
            "\n{}",
            palette.dim(&format!("next: {}", page_flags.join("  |  ")))
        );
    }

    line.push('\n');
    line
}

const fn describe_direction(sort: Sort) -> &'static str {
    match (sort.key, sort.direction) {
        (SortKey::Popularity, SortDirection::Descending) => "(most used first)",
        (SortKey::Popularity, SortDirection::Ascending) => "(least used first)",
        (SortKey::Length, SortDirection::Ascending) => "(shortest first)",
        (SortKey::Length, SortDirection::Descending) => "(longest first)",
        (SortKey::Name, SortDirection::Ascending) => "(a to z)",
        (SortKey::Name, SortDirection::Descending) => "(z to a)",
    }
}

#[must_use]
pub(crate) fn findings(
    items: &[&reserve_core::Finding],
    tally: reserve_core::Tally,
    palette: Palette,
    width: usize,
) -> String {
    let mut table = Table::new(vec![
        Column::left("").shrinks_to(1),
        Column::left("DOMAIN").shrinks_to(12),
        Column::left("STATUS").shrinks_to(7),
        Column::left("SOURCE").drop_order(2).shrinks_to(6),
        Column::right("TIME").drop_order(3).shrinks_to(4),
        Column::left("NOTE").drop_order(1).shrinks_to(10),
    ]);

    for finding in items {
        let mark = match finding.status {
            reserve_core::Status::Available => palette.success("+"),
            reserve_core::Status::Taken => palette.error("-"),
            reserve_core::Status::Unknown(_) => palette.warning("?"),
        };
        let status = match &finding.status {
            reserve_core::Status::Available => palette.success("AVAILABLE"),
            reserve_core::Status::Taken => palette.error("TAKEN"),
            reserve_core::Status::Unknown(_) => palette.warning("UNKNOWN"),
        };
        let note = match &finding.status {
            reserve_core::Status::Unknown(reason) => reason.to_string(),
            _ => String::new(),
        };
        table.push(vec![
            mark,
            palette.accent(&finding.domain),
            status,
            finding
                .source
                .map_or_else(String::new, |s| s.label().to_owned()),
            format!("{}ms", finding.elapsed.as_millis()),
            palette.dim(&note),
        ]);
    }

    let mut out = table.render(true, width);
    out.push('\n');
    out.push_str(&format!(
        "{} available  {} taken  {} unknown   ({} checked)\n",
        palette.success(&tally.available.to_string()),
        tally.taken,
        tally.unknown,
        tally.checked(),
    ));
    if tally.unknown > 0 {
        out.push_str(
            &palette.dim("unknown means no registry would answer, never that the name is free\n"),
        );
    }
    out
}

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

    fn palette() -> Palette {
        Palette::new(false)
    }

    #[test]
    fn counts_get_thousands_separators() {
        assert_eq!(format_thousands(0), "0");
        assert_eq!(format_thousands(999), "999");
        assert_eq!(format_thousands(1000), "1,000");
        assert_eq!(format_thousands(1234567), "1,234,567");
    }

    #[test]
    fn the_footer_reports_the_visible_range_and_the_total() {
        let sort = Sort::default();
        let text = footer(120, Page::new(2, 25), sort, palette());
        assert!(text.contains("26-50"), "{text}");
        assert!(text.contains("120 extensions"), "{text}");
        assert!(text.contains("page 2 of 5"), "{text}");
    }

    #[test]
    fn the_footer_offers_only_the_moves_that_exist() {
        let sort = Sort::default();
        let first = footer(60, Page::new(1, 25), sort, palette());
        assert!(!first.contains("--page 0"), "{first}");
        assert!(first.contains("--page 2"), "{first}");

        let last = footer(60, Page::new(3, 25), sort, palette());
        assert!(last.contains("--page 2"), "{last}");
        assert!(!last.contains("--page 4"), "{last}");
    }

    #[test]
    fn a_single_page_gets_no_navigation_line() {
        let text = footer(10, Page::new(1, 25), Sort::default(), palette());
        assert!(!text.contains("next:"), "{text}");
    }

    #[test]
    fn an_empty_list_reports_zero_rather_than_a_broken_range() {
        let text = footer(0, Page::default(), Sort::default(), palette());
        assert!(text.contains("0-0"), "{text}");
    }

    #[test]
    fn a_disabled_palette_adds_no_escape_codes() {
        let plain = Palette::new(false);
        assert_eq!(plain.error("taken"), "taken");
        assert_eq!(plain.heading("Groups"), "Groups");
    }

    #[test]
    fn an_enabled_palette_wraps_the_text() {
        let colored = Palette::new(true);
        let painted = colored.error("taken");
        assert!(painted.contains("taken"));
        assert!(painted.len() > "taken".len());
    }

    #[test]
    fn the_group_listing_names_every_family_present() {
        let catalog = Catalog::bundled().expect("bundled catalog");
        let text = groups(&catalog, None, palette(), 0);
        for family in Family::all() {
            assert!(text.contains(family.title()), "{} missing", family.title());
        }
    }

    #[test]
    fn filtering_the_group_listing_shows_one_family_only() {
        let catalog = Catalog::bundled().expect("bundled catalog");
        let text = groups(&catalog, Some(Family::Region), palette(), 0);
        assert!(text.contains(Family::Region.title()));
        assert!(!text.contains(Family::Industry.title()));
    }
}