Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

estate/holger-live/src/waits.rs

//! **A first-boot wait, said by name, once a minute** (test3-holger-rs 2026-10-22: the main
//! stood 40+ minutes at `TICK stage=pairing` and nothing anyone could read said what it waited
//! for). Every wait of a Holger box's first boot — main, twin and front — says, in its boot log
//! (which the installer's log door, DEV-NARRATE and the ONE box's Why line read):
//!
//! ```text
//! WAITING <who> waits for <what> since 2026-10-22T18:12:05Z (12 min so far)
//! ```
//!
//! once when it starts and once a minute after ([`Wait::due`]), and once when it ends
//! ([`Wait::done`]). `<what>` is a description, never a value: a wait line names no secret,
//! and is worded so that no log filter withholds it (no `token`, `password`, `secret`,
//! `pubkey` in it — the redaction of a box built before nordisk-pairing 0.5.26 drops such a
//! line whole).

use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

/// The word every wait line begins with (the ONE box's Why line looks for it).
pub const MARKER: &str = "WAITING";
/// How often a wait that goes on is said again.
pub const SAY_EVERY: Duration = Duration::from_secs(60);

/// Pure: `unix_s` as `YYYY-MM-DDTHH:MM:SSZ` (UTC).
pub fn utc(unix_s: u64) -> String {
    let (days, rem) = (unix_s / 86_400, unix_s % 86_400);
    // Howard Hinnant's civil_from_days.
    let z = days as i64 + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + i64::from(m <= 2);
    format!(
        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
        rem / 3_600,
        rem % 3_600 / 60,
        rem % 60
    )
}

/// Pure: a duration for a person: `40 s`, `12 min`, `2 h 5 min`.
pub fn waited(secs: u64) -> String {
    match secs {
        s if s < 60 => format!("{s} s"),
        s if s < 3_600 => format!("{} min", s / 60),
        s => format!("{} h {} min", s / 3_600, s % 3_600 / 60),
    }
}

/// Pure: **one wait line**.
pub fn line(who: &str, what: &str, since_unix_s: u64, waited_s: u64) -> String {
    format!(
        "{MARKER} {who} waits for {what} since {} ({} so far)",
        utc(since_unix_s),
        waited(waited_s)
    )
}

/// Pure: **the line said when a wait ends**.
pub fn done_line(who: &str, what: &str, waited_s: u64) -> String {
    format!(
        "{MARKER} over: {who} waited {} for {what}",
        waited(waited_s)
    )
}

fn now_unix() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// **One wait**: who waits, for what, since when, and when it was last said.
#[derive(Debug, Clone)]
pub struct Wait {
    who: String,
    what: String,
    since_unix_s: u64,
    t0: Instant,
    said: Option<Instant>,
}

impl Wait {
    /// A wait that starts now.
    pub fn new(who: &str, what: &str) -> Wait {
        Wait {
            who: who.into(),
            what: what.into(),
            since_unix_s: now_unix(),
            t0: Instant::now(),
            said: None,
        }
    }

    /// A wait that started at `since_unix_s` / `t0` (the tests' clock).
    pub fn at(who: &str, what: &str, since_unix_s: u64, t0: Instant) -> Wait {
        Wait {
            who: who.into(),
            what: what.into(),
            since_unix_s,
            t0,
            said: None,
        }
    }

    /// What is waited for, with its start: `<what> since <utc>` (a pair status's words).
    pub fn words(&self) -> String {
        format!("{} since {}", self.what, utc(self.since_unix_s))
    }

    /// [`Self::due_at`] now.
    pub fn due(&mut self) -> Option<String> {
        self.due_at(Instant::now())
    }

    /// **The line to say at `now`**, or `None`: the first ask, then once per [`SAY_EVERY`].
    pub fn due_at(&mut self, now: Instant) -> Option<String> {
        if self
            .said
            .is_some_and(|s| now.saturating_duration_since(s) < SAY_EVERY)
        {
            return None;
        }
        self.said = Some(now);
        Some(line(
            &self.who,
            &self.what,
            self.since_unix_s,
            now.saturating_duration_since(self.t0).as_secs(),
        ))
    }

    /// The line to say when the wait is over.
    pub fn done(&self) -> String {
        done_line(&self.who, &self.what, self.t0.elapsed().as_secs())
    }
}

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

    #[test]
    fn utc_is_the_calendar() {
        assert_eq!(utc(0), "1970-01-01T00:00:00Z");
        assert_eq!(utc(1_792_692_725), "2026-10-22T18:12:05Z");
        assert_eq!(utc(951_782_400), "2000-02-29T00:00:00Z");
    }

    /// **A wait is said by name when it starts and once a minute after, with its start**
    /// (test3-holger-rs 2026-10-22), and never in words a log filter withholds.
    #[test]
    fn a_wait_is_said_once_a_minute_by_name_with_its_start() {
        let t0 = Instant::now();
        let mut w = Wait::at(
            "main",
            "the front's offered console key, to derive the console login from",
            1_792_692_725,
            t0,
        );
        let first = w.due_at(t0).expect("said at once");
        assert_eq!(first, "WAITING main waits for the front's offered console key, to derive the console login from since 2026-10-22T18:12:05Z (0 s so far)");
        assert_eq!(
            w.due_at(t0 + Duration::from_secs(30)),
            None,
            "not twice a minute"
        );
        let again = w
            .due_at(t0 + Duration::from_secs(61))
            .expect("once a minute");
        assert!(
            again.ends_with("since 2026-10-22T18:12:05Z (1 min so far)"),
            "{again}"
        );
        assert_eq!(w.due_at(t0 + Duration::from_secs(100)), None);
        assert!(w
            .due_at(t0 + Duration::from_secs(40 * 60 + 5))
            .unwrap()
            .contains("(40 min so far)"));
        assert_eq!(w.words(), "the front's offered console key, to derive the console login from since 2026-10-22T18:12:05Z");
        for l in [first, again, w.done()] {
            assert_eq!(
                nordisk_pairing::redact::secret_class(&l),
                None,
                "no log filter withholds a wait line: {l}"
            );
        }
    }
}