reserve 0.2.0

Check domain name availability across grouped extensions, straight from the registry
//! When the run happened, how long it took, and what the network did — read from the run itself.

use std::io::{self, Write};
use std::time::{Duration, Instant};

use reserve_core::{Finding, Reason, Status};
use time::{OffsetDateTime, UtcOffset, format_description::well_known::Rfc3339};

use crate::context::Context;
use crate::output::Palette;

/// @docgen The offset is read before the runtime starts, because the library refuses to determine it once the process is multi-threaded.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Clock {
    started_at: OffsetDateTime,
    started: Instant,
}

impl Clock {
    pub(crate) fn start() -> Self {
        let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
        let now = OffsetDateTime::now_utc().to_offset(offset);
        Self {
            // @docgen Whole seconds only; a fraction of a second is noise in a line a person reads.
            started_at: now.replace_nanosecond(0).unwrap_or(now),
            started: Instant::now(),
        }
    }

    /// @docgen RFC 3339 keeps the offset, so a pasted line says which clock it came from rather than leaving the reader to guess.
    pub(crate) fn started_at(&self) -> String {
        self.started_at
            .format(&Rfc3339)
            .unwrap_or_else(|_| "unknown".to_owned())
    }

    pub(crate) fn elapsed(&self) -> Duration {
        self.started.elapsed()
    }
}

/// @docgen Progress belongs to a person watching a terminal, and so does this; a pipe and a build log get the data alone.
pub(crate) fn shows(context: &Context) -> bool {
    context.terminal.stderr_is_tty
        && !context.terminal.is_ci
        && !context.quiet_progress
        && context.decorate
}

pub(crate) fn opening(context: &Context, clock: &Clock, palette: Palette) {
    if !shows(context) {
        return;
    }
    let _ = writeln!(
        io::stderr(),
        "{} {}",
        palette.dim("started"),
        palette.dim(&clock.started_at())
    );
}

pub(crate) fn closing(context: &Context, clock: &Clock, checked: usize, palette: Palette) {
    if !shows(context) {
        return;
    }
    let seconds = clock.elapsed().as_secs_f64();
    let _ = writeln!(
        io::stderr(),
        "{} {checked} checked in {seconds:.2}s",
        palette.dim("finished")
    );
}

/// @docgen The layers are sequential, so the first one that failed is the only one worth naming.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Stalled {
    NoLookupLeft,
    EveryRegistryRefused,
    EveryRegistrySilent,
}

impl Stalled {
    pub(crate) const fn key(self) -> &'static str {
        match self {
            Self::NoLookupLeft => "network.unreachable",
            Self::EveryRegistryRefused => "registry.refused",
            Self::EveryRegistrySilent => "registry.silent",
        }
    }

    pub(crate) const fn sentence(self) -> &'static str {
        match self {
            Self::NoLookupLeft => {
                "no lookup reached a registry, so this machine may have no working connection"
            }
            Self::EveryRegistryRefused => {
                "every registry refused the question, which usually means this address is being throttled"
            }
            Self::EveryRegistrySilent => {
                "every registry stopped answering part way, so the connection may be dropping"
            }
        }
    }
}

/// @docgen Read from the lookups already paid for rather than from a probe, which would race the real request and lie behind a captive portal.
pub(crate) fn diagnose(findings: &[Finding]) -> Option<Stalled> {
    if findings.is_empty() {
        return None;
    }

    let mut answered = 0_usize;
    let mut unreachable = 0_usize;
    let mut timed_out = 0_usize;
    let mut refused = 0_usize;

    for finding in findings {
        match &finding.status {
            Status::Available | Status::Taken => answered += 1,
            Status::Unknown(reason) => match reason {
                Reason::Unreachable => unreachable += 1,
                Reason::TimedOut => timed_out += 1,
                Reason::RateLimited | Reason::Blocked | Reason::Declined { .. } => refused += 1,
                _ => {}
            },
        }
    }

    // @docgen One answer proves the path works, so nothing below can be blamed on the network.
    if answered > 0 {
        return None;
    }

    let total = findings.len();
    if unreachable == total {
        return Some(Stalled::NoLookupLeft);
    }
    if timed_out == total {
        return Some(Stalled::EveryRegistrySilent);
    }
    if refused == total {
        return Some(Stalled::EveryRegistryRefused);
    }
    if unreachable + timed_out == total {
        return Some(Stalled::NoLookupLeft);
    }
    None
}

pub(crate) fn report_stall(stalled: Stalled, palette: Palette) {
    let mut stderr = io::stderr();
    let _ = writeln!(
        stderr,
        "{} {}",
        palette.warning("network:"),
        stalled.sentence()
    );
    let _ = writeln!(stderr, "  {} {}", palette.dim("code:"), stalled.key());
}

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

    fn finding(status: Status) -> Finding {
        let suffix = Suffix::parse("com").expect("a suffix parses");
        let mut built = Finding::unknown("example", &suffix, Reason::NoService, Duration::ZERO);
        built.status = status;
        built
    }

    fn context(stderr_is_tty: bool, is_ci: bool, quiet_progress: bool, decorate: bool) -> Context {
        Context {
            color: crate::context::ColorPolicy::resolve(crate::cli::ColorArg::Never),
            terminal: crate::context::TerminalInfo {
                stdin_is_tty: true,
                stdout_is_tty: true,
                stderr_is_tty,
                width: Some(80),
                height: Some(24),
                is_ci,
            },
            quiet_progress,
            decorate,
            wide_glyphs: true,
        }
    }

    #[test]
    fn the_run_lines_belong_to_a_person_at_a_terminal_and_nobody_else() {
        assert!(
            shows(&context(true, false, false, true)),
            "a person sees them"
        );
        assert!(
            !shows(&context(false, false, false, true)),
            "a pipe does not"
        );
        assert!(
            !shows(&context(true, true, false, true)),
            "a build log does not"
        );
        assert!(
            !shows(&context(true, false, true, true)),
            "a machine-readable or unattended run does not"
        );
        assert!(
            !shows(&context(true, false, false, false)),
            "a plain terminal does not"
        );
    }

    #[test]
    fn a_single_answer_means_the_network_is_never_blamed() {
        let mixed = vec![
            finding(Status::Available),
            finding(Status::Unknown(Reason::Unreachable)),
            finding(Status::Unknown(Reason::Unreachable)),
        ];
        assert_eq!(
            diagnose(&mixed),
            None,
            "one answer proves the path works, whatever else failed"
        );
    }

    #[test]
    fn every_lookup_failing_to_connect_reads_as_no_connection() {
        let all = vec![
            finding(Status::Unknown(Reason::Unreachable)),
            finding(Status::Unknown(Reason::Unreachable)),
        ];
        assert_eq!(diagnose(&all), Some(Stalled::NoLookupLeft));
    }

    #[test]
    fn every_lookup_timing_out_is_told_apart_from_never_connecting() {
        let all = vec![
            finding(Status::Unknown(Reason::TimedOut)),
            finding(Status::Unknown(Reason::TimedOut)),
        ];
        assert_eq!(diagnose(&all), Some(Stalled::EveryRegistrySilent));
    }

    #[test]
    fn every_registry_refusing_is_reported_as_refusal_rather_than_as_a_dead_network() {
        let all = vec![
            finding(Status::Unknown(Reason::RateLimited)),
            finding(Status::Unknown(Reason::Blocked)),
        ];
        assert_eq!(diagnose(&all), Some(Stalled::EveryRegistryRefused));
    }

    #[test]
    fn a_zone_with_no_service_is_not_a_network_fault() {
        let all = vec![
            finding(Status::Unknown(Reason::NoService)),
            finding(Status::Unknown(Reason::NotRegistrable)),
        ];
        assert_eq!(
            diagnose(&all),
            None,
            "the tool reached the world and learned something; that is not a stall"
        );
    }

    #[test]
    fn nothing_checked_is_never_diagnosed() {
        assert_eq!(diagnose(&[]), None);
    }

    #[test]
    fn the_start_time_carries_its_offset_so_a_pasted_line_is_unambiguous() {
        let stamp = Clock::start().started_at();
        assert!(
            stamp.contains('T')
                && (stamp.contains('+') || stamp.ends_with('Z') || stamp.contains('-')),
            "RFC 3339 keeps the offset: {stamp}"
        );
    }
}