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};
#[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(¬e),
]);
}
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()));
}
}