ncheap 0.5.0

Namecheap registrar API CLI built for terminal and AI-agent operability
Documentation
use serde::{Deserialize, Serialize};

use crate::api::xml::{self, de_bool};
use crate::api::{Client, Error, Transport};
use crate::domain::split_sld_tld;

#[derive(Debug, Deserialize)]
pub struct GetListResponse {
    #[serde(rename = "DomainDNSGetListResult")]
    result: DnsListXml,
}

#[derive(Debug, Deserialize)]
struct DnsListXml {
    #[serde(rename = "@Domain")]
    domain: String,
    #[serde(rename = "@IsUsingOurDNS", deserialize_with = "de_bool")]
    is_using_our_dns: bool,
    #[serde(rename = "Nameserver", default)]
    nameservers: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub struct GetHostsResponse {
    #[serde(rename = "DomainDNSGetHostsResult")]
    result: HostsXml,
}

#[derive(Debug, Deserialize)]
struct HostsXml {
    /// The docs show `<Host>`; the live API returns `<host>`.
    #[serde(rename = "Host", alias = "host", default)]
    hosts: Vec<HostRecord>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct HostRecord {
    /// Docs disagree on the attribute casing (HostId vs HostID).
    #[serde(
        rename(deserialize = "@HostId", serialize = "id"),
        alias = "@HostID",
        default
    )]
    pub id: String,
    #[serde(rename(deserialize = "@Name", serialize = "name"))]
    pub name: String,
    #[serde(rename(deserialize = "@Type", serialize = "type"))]
    pub record_type: String,
    #[serde(rename(deserialize = "@Address", serialize = "address"))]
    pub address: String,
    #[serde(rename(deserialize = "@MXPref", serialize = "mx_pref"), default)]
    pub mx_pref: String,
    #[serde(rename(deserialize = "@TTL", serialize = "ttl"), default)]
    pub ttl: String,
}

#[derive(Debug, Serialize)]
pub struct DnsInfo {
    pub domain: String,
    pub is_using_our_dns: bool,
    pub nameservers: Vec<String>,
    /// None when the domain uses external DNS: Namecheap only serves host
    /// records for domains on its own nameservers (getHosts errors 2030288).
    pub host_records: Option<Vec<HostRecord>>,
}

/// Nameserver mode plus host records where Namecheap is authoritative.
/// One API call for external-DNS domains, two when records are fetchable.
pub fn get<T: Transport>(client: &Client<T>, domain: &str) -> Result<DnsInfo, Error> {
    let (sld, tld) = split_sld_tld(domain)?;
    let params = [("SLD", sld.as_str()), ("TLD", tld.as_str())];
    let body = client.call("domains.dns.getList", &params)?;
    let list: GetListResponse = xml::parse(&body)?;
    let host_records = if list.result.is_using_our_dns {
        let body = client.call("domains.dns.getHosts", &params)?;
        let hosts: GetHostsResponse = xml::parse(&body)?;
        Some(hosts.result.hosts)
    } else {
        None
    };
    Ok(DnsInfo {
        domain: list.result.domain,
        is_using_our_dns: list.result.is_using_our_dns,
        nameservers: list.result.nameservers,
        host_records,
    })
}

#[derive(Debug, Deserialize)]
pub struct SetCustomResponse {
    #[serde(rename = "DomainDNSSetCustomResult")]
    result: SetCustomXml,
}

#[derive(Debug, Deserialize)]
struct SetCustomXml {
    #[serde(rename = "@Domain")]
    domain: String,
    #[serde(rename = "@Updated", deserialize_with = "de_bool")]
    updated: bool,
}

#[derive(Debug, Serialize)]
pub struct SetResult {
    pub domain: String,
    pub updated: bool,
    pub nameservers: Vec<String>,
    /// The nameservers that were replaced (pre-image): the manual-undo
    /// input, also journaled before the mutation.
    pub previous_nameservers: Vec<String>,
}

/// Point a domain at custom nameservers (namecheap.domains.dns.setCustom).
/// Mutating: goes through call_mut — production-gated, never auto-retried.
pub fn set<T: Transport>(
    client: &Client<T>,
    domain: &str,
    nameservers: &[String],
) -> Result<SetResult, Error> {
    client.require_mutations_permitted()?;
    let (sld, tld) = split_sld_tld(domain)?;
    // Pre-image: setCustom is full-replace with no upstream undo, so the
    // outgoing nameservers are fetched and journaled before the mutation.
    let pre_params = [("SLD", sld.as_str()), ("TLD", tld.as_str())];
    let pre_body = client.call("domains.dns.getList", &pre_params)?;
    let pre: GetListResponse = xml::parse(&pre_body)?;
    let previous_nameservers = pre.result.nameservers;
    client.journal_note(
        "dns.set",
        serde_json::json!({
            "domain": domain,
            "previous_nameservers": previous_nameservers,
            "is_using_our_dns": pre.result.is_using_our_dns,
        }),
    );
    let list = nameservers.join(",");
    let body = client.call_mut(
        "domains.dns.setCustom",
        &[
            ("SLD", sld.as_str()),
            ("TLD", tld.as_str()),
            ("NameServers", list.as_str()),
        ],
    )?;
    let resp: SetCustomResponse = xml::parse(&body)?;
    Ok(SetResult {
        domain: resp.result.domain,
        updated: resp.result.updated,
        nameservers: nameservers.to_vec(),
        previous_nameservers,
    })
}

pub fn render_set(result: &SetResult) {
    println!(
        "{}: nameservers {} ({})",
        result.domain,
        if result.updated {
            "updated"
        } else {
            "NOT updated"
        },
        result.nameservers.join(", "),
    );
}

pub fn render(info: &DnsInfo) {
    println!("domain: {}", info.domain);
    println!(
        "dns: {}",
        if info.is_using_our_dns {
            "namecheap"
        } else {
            "external"
        }
    );
    for ns in &info.nameservers {
        println!("nameserver: {ns}");
    }
    match &info.host_records {
        Some(hosts) => {
            println!(
                "{:<30} {:<8} {:<8} {:<6} ADDRESS",
                "NAME", "TYPE", "TTL", "MX"
            );
            for h in hosts {
                println!(
                    "{:<30} {:<8} {:<8} {:<6} {}",
                    h.name, h.record_type, h.ttl, h.mx_pref, h.address
                );
            }
        }
        None => println!("(host records not managed by Namecheap)"),
    }
}