use crate::display::collect_entities;
use crate::models::vcard::{VCardAddress, VCardValue};
use crate::models::*;
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
const KEY_WIDTH: usize = 16;
pub fn display_whois(obj: &RdapObject) {
let output = format_whois(obj);
print!("{}", output);
}
pub fn format_whois(obj: &RdapObject) -> String {
match obj {
RdapObject::Autnum(a) => format_autnum(a),
RdapObject::Domain(d) => format_domain(d),
RdapObject::IpNetwork(ip) => format_ip_network(ip),
RdapObject::Entity(e) => format_top_entity(e),
RdapObject::Nameserver(ns) => format_nameserver_obj(ns),
RdapObject::Error(err) => format_error(err),
RdapObject::DomainSearch(ds) => format_domain_search(ds),
RdapObject::EntitySearch(es) => format_entity_search(es),
RdapObject::NameserverSearch(ns) => format_nameserver_search(ns),
RdapObject::Help(h) => format_help(h),
}
}
fn field(key: &str, value: &str) -> String {
let key_with_colon = format!("{}:", key);
format!("{:<width$}{}\n", key_with_colon, value, width = KEY_WIDTH)
}
fn source_from_port43(port43: Option<&str>) -> Option<String> {
let p43 = port43?;
let parts: Vec<&str> = p43.split('.').collect();
if parts.len() >= 2 {
let candidate = parts[parts.len() - 2].to_uppercase();
match candidate.as_str() {
"RIPE" | "ARIN" | "APNIC" | "AFRINIC" | "LACNIC" => Some(candidate),
_ => {
if parts[0].eq_ignore_ascii_case("whois") && parts.len() >= 2 {
Some(parts[1].to_uppercase())
} else {
Some(p43.to_uppercase())
}
}
}
} else {
Some(p43.to_uppercase())
}
}
fn entity_kind(entity: &Entity) -> Option<String> {
let vcard = entity.vcard.as_ref()?;
for prop in vcard.properties() {
if prop.name == "kind"
&& let VCardValue::Text(s) = &prop.value
{
return Some(s.clone());
}
}
None
}
fn format_events(events: &[Event], out: &mut String) {
for event in events {
match event.action.as_str() {
"registration" => out.push_str(&field("created", &event.date)),
"last changed" => out.push_str(&field("last-modified", &event.date)),
_ => {}
}
}
}
fn format_remarks(remarks: &[Remark], out: &mut String) {
for remark in remarks {
for desc in &remark.description {
out.push_str(&field("remarks", desc));
}
}
}
#[derive(Default)]
struct EntityRoles {
admin_c: Vec<String>,
tech_c: Vec<String>,
abuse_c: Vec<String>,
org: Vec<String>,
mnt_by: Vec<String>,
}
fn classify_entities(entities: &HashMap<String, Entity>) -> EntityRoles {
let mut roles = EntityRoles::default();
for (handle, entity) in entities {
for role in &entity.roles {
match role.as_str() {
"administrative" => roles.admin_c.push(handle.clone()),
"technical" => roles.tech_c.push(handle.clone()),
"abuse" => roles.abuse_c.push(handle.clone()),
"registrant" => {
let kind = entity_kind(entity);
match kind.as_deref() {
Some("org") => roles.org.push(handle.clone()),
_ => roles.mnt_by.push(handle.clone()),
}
}
_ => {}
}
}
}
roles.admin_c.sort();
roles.tech_c.sort();
roles.abuse_c.sort();
roles.org.sort();
roles.mnt_by.sort();
roles
}
fn format_entity_refs(roles: &EntityRoles, out: &mut String) {
for handle in &roles.org {
out.push_str(&field("org", handle));
}
for handle in &roles.admin_c {
out.push_str(&field("admin-c", handle));
}
for handle in &roles.tech_c {
out.push_str(&field("tech-c", handle));
}
for handle in &roles.abuse_c {
out.push_str(&field("abuse-c", handle));
}
for handle in &roles.mnt_by {
out.push_str(&field("mnt-by", handle));
}
}
fn format_address(addr: &VCardAddress, out: &mut String) {
if let Some(label) = &addr.label {
for line in label.as_str().lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
out.push_str(&field("address", trimmed));
}
}
} else {
let parts: [&str; 7] = [
&addr.po_box,
&addr.extended,
&addr.street,
&addr.locality,
&addr.region,
&addr.postal_code,
&addr.country,
];
let non_empty: Vec<&str> = parts.iter().filter(|p| !p.is_empty()).copied().collect();
if !non_empty.is_empty() {
out.push_str(&field("address", &non_empty.join(", ")));
}
}
}
fn format_entity_block(entity: &Entity, source: Option<&str>, top_level: bool) -> String {
let mut out = String::new();
let handle = entity.handle.as_deref().unwrap_or("UNKNOWN");
let kind = entity_kind(entity);
match kind.as_deref() {
Some("org") => {
out.push_str(&field("organisation", handle));
if let Some(vcard) = &entity.vcard {
if let Some(name) = vcard.name() {
out.push_str(&field("org-name", name));
}
if let Some(addr) = vcard.address() {
format_address(&addr, &mut out);
}
if let Some(email) = vcard.email() {
out.push_str(&field("e-mail", email));
}
if let Some(tel) = vcard.tel() {
out.push_str(&field("phone", tel));
}
}
if !entity.entities.is_empty() {
let nested = collect_entities(&entity.entities);
let nested_roles = classify_entities(&nested);
for h in &nested_roles.mnt_by {
out.push_str(&field("mnt-by", h));
}
}
format_events(&entity.events, &mut out);
if let Some(src) = source {
out.push_str(&field("source", src));
}
}
Some("group") => {
out.push_str(&field(
"role",
entity
.vcard
.as_ref()
.and_then(|v| v.name())
.unwrap_or(handle),
));
if let Some(vcard) = &entity.vcard {
if let Some(addr) = vcard.address() {
format_address(&addr, &mut out);
}
if let Some(email) = vcard.email() {
out.push_str(&field("abuse-mailbox", email));
}
if let Some(tel) = vcard.tel() {
out.push_str(&field("phone", tel));
}
}
out.push_str(&field("nic-hdl", handle));
if !entity.entities.is_empty() {
let nested = collect_entities(&entity.entities);
let nested_roles = classify_entities(&nested);
for h in &nested_roles.mnt_by {
out.push_str(&field("mnt-by", h));
}
}
format_events(&entity.events, &mut out);
if let Some(src) = source {
out.push_str(&field("source", src));
}
}
_ => {
if !top_level && (handle.ends_with("-MNT") || handle.ends_with("-mnt")) {
return String::new();
}
if handle.ends_with("-MNT") || handle.ends_with("-mnt") {
out.push_str(&field("mntner", handle));
if let Some(vcard) = &entity.vcard {
if let Some(name) = vcard.name() {
out.push_str(&field("descr", name));
}
if let Some(addr) = vcard.address() {
format_address(&addr, &mut out);
}
if let Some(email) = vcard.email() {
out.push_str(&field("upd-to", email));
}
}
} else {
out.push_str(&field(
"person",
entity
.vcard
.as_ref()
.and_then(|v| v.name())
.unwrap_or(handle),
));
if let Some(vcard) = &entity.vcard {
if let Some(addr) = vcard.address() {
format_address(&addr, &mut out);
}
if let Some(email) = vcard.email() {
out.push_str(&field("e-mail", email));
}
if let Some(tel) = vcard.tel() {
out.push_str(&field("phone", tel));
}
}
out.push_str(&field("nic-hdl", handle));
}
if !entity.entities.is_empty() {
let nested = collect_entities(&entity.entities);
let nested_roles = classify_entities(&nested);
for h in &nested_roles.mnt_by {
out.push_str(&field("mnt-by", h));
}
}
format_events(&entity.events, &mut out);
if let Some(src) = source {
out.push_str(&field("source", src));
}
}
}
out
}
fn format_associated_entities(
entity_map: &HashMap<String, Entity>,
source: Option<&str>,
) -> String {
let mut out = String::new();
let mut handles: Vec<_> = entity_map.keys().collect();
handles.sort();
for handle in handles {
if let Some(entity) = entity_map.get(handle) {
let block = format_entity_block(entity, source, false);
if !block.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&block);
}
}
}
out
}
fn format_autnum(autnum: &Autnum) -> String {
let mut out = String::new();
let source = source_from_port43(autnum.port43.as_deref());
if let Some(start) = autnum.start_autnum {
let _ = writeln!(out, "{:<width$}AS{}", "aut-num:", start, width = KEY_WIDTH);
}
if let Some(name) = &autnum.name {
out.push_str(&field("as-name", name));
}
let entity_map = collect_entities(&autnum.entities);
let roles = classify_entities(&entity_map);
format_entity_refs(&roles, &mut out);
format_remarks(&autnum.remarks, &mut out);
for status in &autnum.status {
out.push_str(&field("status", &status.to_uppercase()));
}
format_events(&autnum.events, &mut out);
if let Some(src) = &source {
out.push_str(&field("source", src));
}
let entity_blocks = format_associated_entities(&entity_map, source.as_deref());
if !entity_blocks.is_empty() {
out.push('\n');
out.push_str(&entity_blocks);
}
out
}
fn format_domain(domain: &Domain) -> String {
let mut out = String::new();
let source = source_from_port43(domain.port43.as_deref());
if let Some(name) = &domain.ldh_name {
out.push_str(&field("domain", name));
}
if let Some(handle) = &domain.handle {
out.push_str(&field("handle", handle));
}
for ns in &domain.nameservers {
if let Some(name) = &ns.ldh_name {
out.push_str(&field("nserver", name));
}
}
if let Some(dnssec) = &domain.secure_dns {
if let Some(ds) = dnssec.delegation_signed {
out.push_str(&field("ds-rdata", if ds { "signed" } else { "unsigned" }));
}
for ds in &dnssec.ds_data {
let mut ds_line = String::new();
if let Some(tag) = ds.key_tag {
let _ = write!(ds_line, "{}", tag);
}
if let Some(alg) = ds.algorithm {
let _ = write!(ds_line, " {}", alg);
}
if let Some(dt) = ds.digest_type {
let _ = write!(ds_line, " {}", dt);
}
if let Some(digest) = &ds.digest {
let _ = write!(ds_line, " {}", digest);
}
if !ds_line.is_empty() {
out.push_str(&field("ds-rdata", ds_line.trim()));
}
}
}
for status in &domain.status {
out.push_str(&field("status", status));
}
let entity_map = collect_entities(&domain.entities);
let roles = classify_entities(&entity_map);
format_entity_refs(&roles, &mut out);
format_remarks(&domain.remarks, &mut out);
format_events(&domain.events, &mut out);
if let Some(src) = &source {
out.push_str(&field("source", src));
}
let entity_blocks = format_associated_entities(&entity_map, source.as_deref());
if !entity_blocks.is_empty() {
out.push('\n');
out.push_str(&entity_blocks);
}
out
}
fn format_ip_network(ip: &IpNetwork) -> String {
let mut out = String::new();
let source = source_from_port43(ip.port43.as_deref());
let obj_type = match ip.ip_version.as_deref() {
Some("v6") => "inet6num",
_ => "inetnum",
};
if let (Some(start), Some(end)) = (&ip.start_address, &ip.end_address) {
let range = format!("{} - {}", start, end);
out.push_str(&field(obj_type, &range));
} else if let Some(handle) = &ip.handle {
out.push_str(&field(obj_type, handle));
}
if let Some(name) = &ip.name {
out.push_str(&field("netname", name));
}
if let Some(net_type) = &ip.network_type {
out.push_str(&field("type", net_type));
}
if let Some(country) = &ip.country {
out.push_str(&field("country", country));
}
let entity_map = collect_entities(&ip.entities);
let roles = classify_entities(&entity_map);
format_entity_refs(&roles, &mut out);
format_remarks(&ip.remarks, &mut out);
for status in &ip.status {
out.push_str(&field("status", status));
}
format_events(&ip.events, &mut out);
if let Some(src) = &source {
out.push_str(&field("source", src));
}
let entity_blocks = format_associated_entities(&entity_map, source.as_deref());
if !entity_blocks.is_empty() {
out.push('\n');
out.push_str(&entity_blocks);
}
out
}
fn format_top_entity(entity: &Entity) -> String {
let mut out = String::new();
let source = source_from_port43(entity.port43.as_deref());
out.push_str(&format_entity_block(entity, source.as_deref(), true));
if !entity.entities.is_empty() {
let entity_map = collect_entities(&entity.entities);
let blocks = format_associated_entities(&entity_map, source.as_deref());
if !blocks.is_empty() {
out.push('\n');
out.push_str(&blocks);
}
}
out
}
fn format_nameserver_obj(ns: &Nameserver) -> String {
let mut out = String::new();
let source = source_from_port43(ns.port43.as_deref());
if let Some(name) = &ns.ldh_name {
out.push_str(&field("host", name));
}
if let Some(handle) = &ns.handle {
out.push_str(&field("handle", handle));
}
if let Some(ips) = &ns.ip_addresses {
for ip in &ips.v4 {
out.push_str(&field("ip-address", ip));
}
for ip in &ips.v6 {
out.push_str(&field("ip-address", ip));
}
}
for status in &ns.status {
out.push_str(&field("status", status));
}
format_remarks(&ns.remarks, &mut out);
format_events(&ns.events, &mut out);
if !ns.entities.is_empty() {
let entity_map = collect_entities(&ns.entities);
let roles = classify_entities(&entity_map);
format_entity_refs(&roles, &mut out);
if let Some(src) = &source {
out.push_str(&field("source", src));
}
let blocks = format_associated_entities(&entity_map, source.as_deref());
if !blocks.is_empty() {
out.push('\n');
out.push_str(&blocks);
}
} else if let Some(src) = &source {
out.push_str(&field("source", src));
}
out
}
fn format_error(err: &ErrorResponse) -> String {
let mut out = String::new();
if let Some(code) = err.error_code {
let _ = writeln!(out, "%ERROR:101: no entries found");
let _ = writeln!(out, "%");
if let Some(title) = &err.title {
let _ = writeln!(out, "% {}: {}", code, title);
} else {
let _ = writeln!(out, "% Error {}", code);
}
for desc in &err.description {
let _ = writeln!(out, "% {}", desc);
}
}
out
}
fn format_domain_search(ds: &DomainSearchResults) -> String {
let mut out = String::new();
for (i, domain) in ds.domains.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format_domain(domain));
}
out
}
fn format_entity_search(es: &EntitySearchResults) -> String {
let mut out = String::new();
for (i, entity) in es.entities.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format_top_entity(entity));
}
out
}
fn format_nameserver_search(ns: &NameserverSearchResults) -> String {
let mut out = String::new();
for (i, nameserver) in ns.nameservers.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format_nameserver_obj(nameserver));
}
out
}
fn format_help(help: &HelpResponse) -> String {
let mut out = String::new();
for notice in &help.notices {
if let Some(title) = ¬ice.title {
let _ = writeln!(out, "% {}", title);
}
for desc in ¬ice.description {
let _ = writeln!(out, "% {}", desc);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_field_formatting() {
let result = field("aut-num", "AS213605");
assert_eq!(result, "aut-num: AS213605\n");
}
#[test]
fn test_field_long_key() {
let result = field("last-modified", "2026-02-05T03:28:52Z");
assert_eq!(result, "last-modified: 2026-02-05T03:28:52Z\n");
}
#[test]
fn test_field_short_key() {
let result = field("org", "ORG-LA1994-RIPE");
assert_eq!(result, "org: ORG-LA1994-RIPE\n");
}
#[test]
fn test_source_from_port43_ripe() {
assert_eq!(
source_from_port43(Some("whois.ripe.net")),
Some("RIPE".to_string())
);
}
#[test]
fn test_source_from_port43_arin() {
assert_eq!(
source_from_port43(Some("whois.arin.net")),
Some("ARIN".to_string())
);
}
#[test]
fn test_source_from_port43_apnic() {
assert_eq!(
source_from_port43(Some("whois.apnic.net")),
Some("APNIC".to_string())
);
}
#[test]
fn test_source_from_port43_none() {
assert_eq!(source_from_port43(None), None);
}
#[test]
fn test_format_error() {
let err = ErrorResponse {
conformance: vec![],
notices: vec![],
error_code: Some(404),
title: Some("Not Found".to_string()),
description: vec!["The requested object was not found.".to_string()],
lang: None,
};
let result = format_error(&err);
assert!(result.contains("no entries found"));
assert!(result.contains("404"));
assert!(result.contains("Not Found"));
}
#[test]
fn test_format_help() {
let help = HelpResponse {
conformance: vec![],
notices: vec![Notice {
title: Some("Terms of Service".to_string()),
notice_type: None,
description: vec!["Use is subject to terms.".to_string()],
links: vec![],
}],
lang: None,
};
let result = format_help(&help);
assert!(result.contains("% Terms of Service"));
assert!(result.contains("% Use is subject to terms."));
}
}