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;
#[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 {
started_at: now.replace_nanosecond(0).unwrap_or(now),
started: Instant::now(),
}
}
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()
}
}
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")
);
}
#[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"
}
}
}
}
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,
_ => {}
},
}
}
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}"
);
}
}