use std::fmt::Write as _;
use reserve_core::lookup::DnsRecords;
use reserve_core::{Finding, Registration, Suffix};
use crate::output::Palette;
use crate::output::table::{display_width, truncate};
const INDENT: &str = " ";
const LABEL_COLUMN: usize = 12;
const GAP: usize = 2;
const REGISTRY_RECORD_URL: &str = "https://www.iana.org/domains/root/db/";
const REGISTRAR_SEARCH_URLS: &[&str] = &[
"https://porkbun.com/checkout/search?q=",
"https://www.namecheap.com/domains/registration/results/?domain=",
"https://www.dynadot.com/domain/search?domain=",
"https://www.namesilo.com/domain/search-domains?query=",
];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Sections {
pub registration: bool,
pub responder: bool,
pub dns: bool,
pub where_to_buy: bool,
}
impl Sections {
#[cfg(test)]
pub(crate) const fn full() -> Self {
Self {
registration: true,
responder: true,
dns: true,
where_to_buy: true,
}
}
#[must_use]
pub(crate) const fn any_enabled(&self) -> bool {
self.registration || self.responder || self.dns || self.where_to_buy
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ValueStyle {
Plain,
Link,
}
#[must_use]
pub(crate) fn render(
finding: &Finding,
sections: Sections,
dns: Option<&DnsRecords>,
palette: Palette,
width: usize,
) -> String {
if !sections.any_enabled() {
return String::new();
}
let mut out = String::new();
if sections.registration
&& let Some(record) = finding.registration.as_ref()
{
registration(&mut out, record, palette, width);
}
if sections.responder {
optional_row(
&mut out,
"answered by",
finding.responder.as_deref(),
palette,
width,
);
}
if sections.dns
&& let Some(records) = dns
{
for (kind, values) in &records.by_type {
value_rows(&mut out, kind, values, ValueStyle::Plain, palette, width);
}
}
if sections.where_to_buy && finding.status.is_available() {
where_to_buy(&mut out, finding, palette, width);
}
out
}
fn registration(out: &mut String, record: &Registration, palette: Palette, width: usize) {
optional_row(
out,
"registrar",
record.registrar.as_deref(),
palette,
width,
);
optional_row(
out,
"registrar id",
record.registrar_id.as_deref(),
palette,
width,
);
optional_row(out, "created", record.created_at.as_deref(), palette, width);
optional_row(out, "updated", record.updated_at.as_deref(), palette, width);
optional_row(out, "expires", record.expires_at.as_deref(), palette, width);
if !record.statuses.is_empty() {
row(
out,
"status",
&record.statuses.join(", "),
ValueStyle::Plain,
palette,
width,
);
}
if !record.nameservers.is_empty() {
row(
out,
"nameservers",
&record.nameservers.join(", "),
ValueStyle::Plain,
palette,
width,
);
}
if let Some(signed) = record.has_dnssec {
let state = if signed { "signed" } else { "unsigned" };
row(out, "dnssec", state, ValueStyle::Plain, palette, width);
}
optional_row(out, "abuse", record.abuse_email.as_deref(), palette, width);
}
fn where_to_buy(out: &mut String, finding: &Finding, palette: Palette, width: usize) {
row(
out,
"registry",
®istry_link(&finding.suffix),
ValueStyle::Link,
palette,
width,
);
let searches: Vec<String> = REGISTRAR_SEARCH_URLS
.iter()
.map(|prefix| format!("{prefix}{}", finding.domain))
.collect();
value_rows(out, "buy at", &searches, ValueStyle::Link, palette, width);
}
fn registry_link(suffix: &Suffix) -> String {
format!("{REGISTRY_RECORD_URL}{}.html", suffix.delegated_label())
}
fn optional_row(
out: &mut String,
label: &str,
value: Option<&str>,
palette: Palette,
width: usize,
) {
if let Some(text) = value
&& !text.trim().is_empty()
{
row(out, label, text, ValueStyle::Plain, palette, width);
}
}
fn value_rows(
out: &mut String,
label: &str,
values: &[String],
style: ValueStyle,
palette: Palette,
width: usize,
) {
let mut labelled = false;
for value in values {
if value.trim().is_empty() {
continue;
}
if labelled {
continuation(out, value, style, palette, width);
} else {
row(out, label, value, style, palette, width);
labelled = true;
}
}
}
fn row(
out: &mut String,
label: &str,
value: &str,
style: ValueStyle,
palette: Palette,
width: usize,
) {
let dimmed_label = palette.dim(label);
let padding = LABEL_COLUMN.saturating_sub(display_width(&dimmed_label)) + GAP;
let styled_value = paint(&fit_to_width(value, width), style, palette);
let _ = writeln!(out, "{INDENT}{dimmed_label}{:padding$}{styled_value}", "");
}
fn continuation(out: &mut String, value: &str, style: ValueStyle, palette: Palette, width: usize) {
let padding = LABEL_COLUMN + GAP;
let styled_value = paint(&fit_to_width(value, width), style, palette);
let _ = writeln!(out, "{INDENT}{:padding$}{styled_value}", "");
}
fn paint(value: &str, style: ValueStyle, palette: Palette) -> String {
match style {
ValueStyle::Plain => value.to_owned(),
ValueStyle::Link => palette.accent(value),
}
}
fn fit_to_width(value: &str, width: usize) -> String {
if width == 0 {
return value.to_owned();
}
let room = width.saturating_sub(INDENT.len() + LABEL_COLUMN + GAP);
truncate(value, room)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use reserve_core::{Reason, Result, Source, Status};
use super::*;
fn plain() -> Palette {
Palette::new(false)
}
fn record() -> Registration {
Registration {
registrar: Some("COM LAUDE".to_owned()),
registrar_id: Some("470".to_owned()),
created_at: Some("1987-02-19T05:00:00Z".to_owned()),
updated_at: Some("2026-02-09T15:41:53Z".to_owned()),
expires_at: Some("2027-02-20T05:00:00Z".to_owned()),
statuses: vec!["client transfer prohibited".to_owned(), "ok".to_owned()],
nameservers: vec!["a.ns.example.com".to_owned(), "b.ns.example.com".to_owned()],
has_dnssec: Some(true),
abuse_email: Some("abuse@example.com".to_owned()),
}
}
fn finding(name: &str, status: Status, registration: Option<Registration>) -> Result<Finding> {
let suffix = Suffix::parse("com")?;
Ok(Finding {
domain: format!("{name}.com"),
name: name.to_owned(),
suffix,
status,
source: Some(Source::Registry),
elapsed: Duration::from_millis(40),
responder: Some("rdap.verisign.com".to_owned()),
registration,
})
}
fn dns() -> DnsRecords {
DnsRecords {
by_type: vec![
(
"a".to_owned(),
vec!["93.184.216.34".to_owned(), "93.184.216.35".to_owned()],
),
("mx".to_owned(), vec!["10 mail.example.com".to_owned()]),
],
}
}
fn line_for<'a>(text: &'a str, label: &str) -> Option<&'a str> {
let head = format!("{INDENT}{label} ");
text.lines().find(|line| line.starts_with(&head))
}
#[test]
fn asking_for_nothing_gives_an_empty_block() -> Result<()> {
let taken = finding("apple", Status::Taken, Some(record()))?;
let text = render(&taken, Sections::default(), Some(&dns()), plain(), 0);
assert!(text.is_empty(), "{text}");
assert!(!Sections::default().any_enabled());
Ok(())
}
#[test]
fn a_finding_with_nothing_to_show_gives_an_empty_block() -> Result<()> {
let mut bare = finding("nothing", Status::Unknown(Reason::TimedOut), None)?;
bare.responder = None;
let text = render(&bare, Sections::full(), None, plain(), 0);
assert!(text.is_empty(), "{text}");
Ok(())
}
#[test]
fn an_absent_field_is_left_out_rather_than_printed_blank() -> Result<()> {
let sparse = Registration {
registrar: Some("Example Registrar".to_owned()),
..Registration::default()
};
let taken = finding("apple", Status::Taken, Some(sparse))?;
let sections = Sections {
registration: true,
..Sections::default()
};
let text = render(&taken, sections, None, plain(), 0);
assert_eq!(text.lines().count(), 1, "{text}");
for missing in ["created", "updated", "expires", "status", "dnssec", "abuse"] {
assert!(
line_for(&text, missing).is_none(),
"{missing} should not appear:\n{text}"
);
}
assert!(
!text.contains('-'),
"an absent field became a dash:\n{text}"
);
Ok(())
}
#[test]
fn the_registration_block_joins_lists_with_commas() -> Result<()> {
let taken = finding("apple", Status::Taken, Some(record()))?;
let sections = Sections {
registration: true,
..Sections::default()
};
let text = render(&taken, sections, None, plain(), 0);
let statuses = line_for(&text, "status").unwrap_or_default();
assert!(
statuses.contains("client transfer prohibited, ok"),
"{statuses}"
);
let servers = line_for(&text, "nameservers").unwrap_or_default();
assert!(
servers.contains("a.ns.example.com, b.ns.example.com"),
"{servers}"
);
Ok(())
}
#[test]
fn dnssec_reads_as_signed_or_unsigned() -> Result<()> {
let sections = Sections {
registration: true,
..Sections::default()
};
for (signed, expected) in [(true, "signed"), (false, "unsigned")] {
let held = Registration {
has_dnssec: Some(signed),
..Registration::default()
};
let taken = finding("apple", Status::Taken, Some(held))?;
let text = render(&taken, sections, None, plain(), 0);
let line = line_for(&text, "dnssec").unwrap_or_default();
assert!(line.trim_end().ends_with(expected), "{line}");
assert_eq!(line.contains("unsigned"), !signed, "{line}");
}
let quiet = finding("apple", Status::Taken, Some(Registration::default()))?;
let text = render(&quiet, sections, None, plain(), 0);
assert!(line_for(&text, "dnssec").is_none(), "{text}");
Ok(())
}
#[test]
fn the_responder_section_names_the_server_that_answered() -> Result<()> {
let taken = finding("apple", Status::Taken, None)?;
let sections = Sections {
responder: true,
..Sections::default()
};
let text = render(&taken, sections, None, plain(), 0);
assert!(text.contains("rdap.verisign.com"), "{text}");
assert!(line_for(&text, "answered by").is_some(), "{text}");
Ok(())
}
#[test]
fn dns_records_print_under_their_type_labels() -> Result<()> {
let taken = finding("apple", Status::Taken, None)?;
let sections = Sections {
dns: true,
..Sections::default()
};
let text = render(&taken, sections, Some(&dns()), plain(), 0);
let first = line_for(&text, "a").unwrap_or_default();
assert!(first.contains("93.184.216.34"), "{text}");
assert!(
text.contains("93.184.216.35"),
"the second address was dropped:\n{text}"
);
let mail = line_for(&text, "mx").unwrap_or_default();
assert!(mail.contains("10 mail.example.com"), "{text}");
assert_eq!(text.lines().count(), 3, "{text}");
Ok(())
}
#[test]
fn where_to_buy_appears_only_for_an_available_name() -> Result<()> {
let sections = Sections {
where_to_buy: true,
..Sections::default()
};
let free = finding("unclaimedbrandname", Status::Available, None)?;
let offered = render(&free, sections, None, plain(), 0);
for prefix in REGISTRAR_SEARCH_URLS {
assert!(
offered.contains(prefix),
"{prefix} missing from:\n{offered}"
);
}
assert!(
offered.contains("unclaimedbrandname.com"),
"the search was not prefilled:\n{offered}"
);
assert!(
offered.contains("iana.org/domains/root/db/com.html"),
"{offered}"
);
for status in [Status::Taken, Status::Unknown(Reason::RateLimited)] {
let other = finding("apple", status, None)?;
let text = render(&other, sections, None, plain(), 0);
assert!(
text.is_empty(),
"a name that is not free was offered:\n{text}"
);
}
Ok(())
}
#[test]
fn every_buy_link_gets_its_own_line() -> Result<()> {
let free = finding("openname", Status::Available, None)?;
let sections = Sections {
where_to_buy: true,
..Sections::default()
};
let text = render(&free, sections, None, plain(), 0);
assert_eq!(
text.lines().count(),
REGISTRAR_SEARCH_URLS.len() + 1,
"{text}"
);
Ok(())
}
#[test]
fn full_sections_turns_every_section_on() -> Result<()> {
let sections = Sections::full();
assert!(sections.registration && sections.responder);
assert!(sections.dns && sections.where_to_buy);
assert!(sections.any_enabled());
let free = finding("openname", Status::Available, Some(record()))?;
let text = render(&free, sections, Some(&dns()), plain(), 0);
assert!(line_for(&text, "registrar").is_some(), "{text}");
assert!(line_for(&text, "answered by").is_some(), "{text}");
assert!(line_for(&text, "a").is_some(), "{text}");
assert!(line_for(&text, "buy at").is_some(), "{text}");
Ok(())
}
#[test]
fn a_long_value_is_cut_to_the_given_width() -> Result<()> {
let wordy = Registration {
registrar: Some(
"A Registrar With An Extremely Long Published Trading Name Limited".to_owned(),
),
nameservers: vec![
"ns1.averylongnameserverhostname.example.com".to_owned(),
"ns2.averylongnameserverhostname.example.com".to_owned(),
],
..Registration::default()
};
let taken = finding("apple", Status::Taken, Some(wordy))?;
let sections = Sections {
registration: true,
..Sections::default()
};
for width in [24, 40, 60, 80] {
let text = render(&taken, sections, None, plain(), width);
for line in text.lines() {
assert!(
display_width(line) <= width,
"line of {} exceeded {width}:\n{line}",
display_width(line)
);
}
}
let uncut = render(&taken, sections, None, plain(), 0);
assert!(uncut.contains("Trading Name Limited"), "{uncut}");
Ok(())
}
#[test]
fn a_link_is_cut_without_leaving_its_colour_open() -> Result<()> {
let free = finding("openname", Status::Available, None)?;
let sections = Sections {
where_to_buy: true,
..Sections::default()
};
let text = render(&free, sections, None, Palette::new(true), 40);
for line in text.lines() {
assert!(display_width(line) <= 40, "{line:?}");
}
Ok(())
}
}