reserve-core 0.2.0

Core lookup, catalog, and rate-limiting engine behind the reserve domain finder
Documentation
use std::fmt;
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// @docgen Remote text reaches a terminal and a JSON file, so control bytes could rewrite the screen or hide a line.
pub(crate) fn scrub(raw: &str) -> String {
    raw.chars()
        .filter(|c| !c.is_control())
        .take(200)
        .collect::<String>()
        .trim()
        .to_owned()
}

use crate::tld::Suffix;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Source {
    Registry,
    Text,
    Dns,
}

impl Source {
    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::Registry => "registry",
            Self::Text => "text",
            Self::Dns => "dns",
        }
    }

    /// @docgen Only the registry settles it: DNS proves a name is taken but never free, and the text service is pattern matching.
    #[must_use]
    pub const fn is_authoritative(self) -> bool {
        matches!(self, Self::Registry)
    }
}

impl fmt::Display for Source {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.label())
    }
}

/// @docgen Every one of these is reported as unknown and never folded into available.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "reason", rename_all = "kebab-case")]
pub enum Reason {
    NoService,
    RateLimited,
    /// @docgen A registry fault is not pushback, and calling it rate limiting sends the user away to wait instead of reporting an outage.
    ServerError {
        status: u16,
    },
    /// @docgen The registry answered and refused the question, which is understood perfectly and must not read as a garbled reply.
    Declined {
        detail: String,
    },
    Blocked,
    TimedOut,
    Unreachable,
    Malformed {
        detail: String,
    },
    WrongSubject {
        answered_about: String,
    },
    /// @docgen The extension exists but only a closed group may register in it, so availability is not a meaningful question.
    NotRegistrable,
}

impl Reason {
    #[must_use]
    pub const fn label(&self) -> &'static str {
        match self {
            Self::NoService => "no registry service",
            Self::RateLimited => "rate limited",
            Self::ServerError { .. } => "registry error",
            Self::Declined { .. } => "registry declined",
            Self::Blocked => "access refused",
            Self::TimedOut => "timed out",
            Self::Unreachable => "could not connect",
            Self::Malformed { .. } => "answer not understood",
            Self::WrongSubject { .. } => "answered about another name",
            Self::NotRegistrable => "not publicly registrable",
        }
    }

    #[must_use]
    pub const fn is_retryable(&self) -> bool {
        matches!(
            self,
            Self::RateLimited
                | Self::ServerError { .. }
                | Self::Declined { .. }
                | Self::TimedOut
                | Self::Unreachable
                | Self::Blocked
        )
    }
}

impl fmt::Display for Reason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Malformed { detail } | Self::Declined { detail } => {
                write!(f, "{}: {detail}", self.label())
            }
            Self::WrongSubject { answered_about } => {
                write!(f, "{}: {answered_about}", self.label())
            }
            other => f.write_str(other.label()),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum Status {
    Available,
    Taken,
    Unknown(Reason),
}

impl Status {
    #[must_use]
    pub const fn is_available(&self) -> bool {
        matches!(self, Self::Available)
    }

    #[must_use]
    pub const fn is_unknown(&self) -> bool {
        matches!(self, Self::Unknown(_))
    }

    /// @docgen Meaning never rides on color alone, so the symbol carries it too.
    #[must_use]
    pub const fn mark(&self) -> char {
        match self {
            Self::Available => '+',
            Self::Taken => '-',
            Self::Unknown(_) => '?',
        }
    }

    #[must_use]
    pub const fn label(&self) -> &'static str {
        match self {
            Self::Available => "AVAILABLE",
            Self::Taken => "TAKEN",
            Self::Unknown(_) => "UNKNOWN",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
    pub domain: String,
    pub name: String,
    pub suffix: Suffix,
    pub status: Status,
    pub source: Option<Source>,
    pub elapsed: Duration,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub responder: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub registration: Option<crate::lookup::registration::Registration>,
}

impl Finding {
    #[must_use]
    pub fn unknown(name: &str, suffix: &Suffix, reason: Reason, elapsed: Duration) -> Self {
        Self {
            domain: format!("{name}.{suffix}"),
            name: name.to_owned(),
            suffix: suffix.clone(),
            status: Status::Unknown(reason),
            source: None,
            elapsed,
            responder: None,
            registration: None,
        }
    }

    #[must_use]
    /// @docgen A name whose extension is not in the catalog still gets a row, so it is never dropped from the table.
    pub fn unrecognized(domain: &str) -> Self {
        let suffix = domain.rsplit_once('.').map_or(domain, |(_, tail)| tail);
        Self {
            domain: domain.to_owned(),
            name: domain.to_owned(),
            suffix: Suffix::from_raw(suffix),
            status: Status::Unknown(Reason::NoService),
            source: None,
            elapsed: Duration::ZERO,
            responder: None,
            registration: None,
        }
    }

    #[must_use]
    pub const fn is_available(&self) -> bool {
        self.status.is_available()
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tally {
    pub available: usize,
    pub taken: usize,
    pub unknown: usize,
}

impl Tally {
    #[must_use]
    pub fn of(findings: &[Finding]) -> Self {
        let mut tally = Self::default();
        for finding in findings {
            match finding.status {
                Status::Available => tally.available += 1,
                Status::Taken => tally.taken += 1,
                Status::Unknown(_) => tally.unknown += 1,
            }
        }
        tally
    }

    #[must_use]
    pub const fn checked(&self) -> usize {
        self.available + self.taken + self.unknown
    }

    #[must_use]
    pub const fn has_available(&self) -> bool {
        self.available > 0
    }
}

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

    fn suffix() -> Suffix {
        Suffix::parse("com").expect("com parses")
    }

    #[test]
    fn only_the_registry_settles_the_question_on_its_own() {
        assert!(Source::Registry.is_authoritative());
        assert!(!Source::Dns.is_authoritative());
        assert!(!Source::Text.is_authoritative());
    }

    #[test]
    fn every_unanswered_lookup_is_unknown_and_never_available() {
        let reasons = [
            Reason::NoService,
            Reason::RateLimited,
            Reason::ServerError { status: 500 },
            Reason::Blocked,
            Reason::TimedOut,
            Reason::Unreachable,
            Reason::NotRegistrable,
            Reason::Malformed {
                detail: "bad json".to_owned(),
            },
            Reason::WrongSubject {
                answered_about: "other.com".to_owned(),
            },
        ];
        for reason in reasons {
            let status = Status::Unknown(reason);
            assert!(!status.is_available(), "{status:?} must not read as free");
            assert!(status.is_unknown());
            assert_eq!(status.mark(), '?');
        }
    }

    #[test]
    fn a_throttled_lookup_is_is_retryable_but_a_missing_service_is_not() {
        assert!(Reason::RateLimited.is_retryable());
        assert!(Reason::ServerError { status: 503 }.is_retryable());
        assert!(Reason::TimedOut.is_retryable());
        assert!(Reason::Unreachable.is_retryable());
        assert!(!Reason::NoService.is_retryable());
        assert!(!Reason::NotRegistrable.is_retryable());
        assert!(
            !Reason::Malformed {
                detail: String::new()
            }
            .is_retryable()
        );
    }

    #[test]
    fn the_status_mark_carries_meaning_without_color() {
        assert_eq!(Status::Available.mark(), '+');
        assert_eq!(Status::Taken.mark(), '-');
        assert_eq!(Status::Unknown(Reason::TimedOut).mark(), '?');
        assert_ne!(Status::Available.label(), Status::Taken.label());
    }

    #[test]
    fn an_unknown_finding_reports_the_reason_in_its_text() {
        let finding = Finding::unknown(
            "example",
            &suffix(),
            Reason::RateLimited,
            Duration::from_millis(20),
        );
        assert_eq!(finding.domain, "example.com");
        assert!(!finding.is_available());
        match finding.status {
            Status::Unknown(reason) => assert_eq!(reason.to_string(), "rate limited"),
            other => panic!("expected unknown, got {other:?}"),
        }
    }

    #[test]
    fn an_unreadable_answer_keeps_its_detail_in_the_message() {
        let reason = Reason::Malformed {
            detail: "truncated body".to_owned(),
        };
        assert!(reason.to_string().contains("truncated body"));
    }

    #[test]
    fn the_tally_counts_each_class_and_never_double_counts() {
        let findings = vec![
            Finding {
                domain: "a.com".to_owned(),
                name: "a".to_owned(),
                suffix: suffix(),
                status: Status::Available,
                source: Some(Source::Registry),
                elapsed: Duration::ZERO,
                responder: None,
                registration: None,
            },
            Finding {
                domain: "b.com".to_owned(),
                name: "b".to_owned(),
                suffix: suffix(),
                status: Status::Taken,
                source: Some(Source::Registry),
                elapsed: Duration::ZERO,
                responder: None,
                registration: None,
            },
            Finding::unknown("c", &suffix(), Reason::TimedOut, Duration::ZERO),
        ];

        let tally = Tally::of(&findings);
        assert_eq!(tally.available, 1);
        assert_eq!(tally.taken, 1);
        assert_eq!(tally.unknown, 1);
        assert_eq!(tally.checked(), 3);
        assert!(tally.has_available());
    }

    #[test]
    fn a_run_with_no_free_names_reports_nothing_found() {
        let tally = Tally {
            available: 0,
            taken: 5,
            unknown: 2,
        };
        assert!(!tally.has_available());
        assert_eq!(tally.checked(), 7);
    }

    #[test]
    fn an_empty_run_tallies_to_zero() {
        let tally = Tally::of(&[]);
        assert_eq!(tally, Tally::default());
        assert_eq!(tally.checked(), 0);
        assert!(!tally.has_available());
    }
}