nordisk-pairing 0.5.29

The boxes of an estate find each other: a seed each box is installed with, a one-time join against the front, a tunnel (WireGuard by default) they meet inside, roles decided once, a pairing loop each box runs itself, and the signal it reports. Shared by gunnar and holger; the laptop's xtask only lays machines, writes seeds and reads the signal. Design: .nornir/pairing-design.md (2026-09-24).
Documentation
//! **The signal: where a box is, said by the box.** One line for a log and a
//! value for a status door — the same reading, so an operator's `status`, the
//! laptop's `estate pairing` and the boot narration all say the same thing.
//! The owner (2026-09-24): the output while an estate pairs is not debug
//! narration; it is a pairing SIGNAL, and the signal is also a status.

use crate::pair::{Machine, State};
use crate::seed::TunnelKind;
use crate::Role;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signal {
    pub name: String,
    pub tunnel: TunnelKind,
    pub tunnel_up: bool,
    pub joined: bool,
    pub role: Option<Role>,
    /// The step the box is on, in words, and how many times it has tried it.
    pub step: String,
    pub tries: u32,
    pub last_transient: Option<String>,
    pub paired: bool,
    pub refused: Option<String>,
}

impl Signal {
    pub fn of(name: &str, tunnel: TunnelKind, m: &Machine) -> Signal {
        let (tunnel_up, joined, role) = match &m.state {
            State::Booted => (false, false, None),
            State::Joined { role, .. } => (false, true, Some(*role)),
            State::RoleKnown { role, .. } => (true, true, Some(*role)),
            State::TwinConferred { .. } | State::TwinCertified { .. } => (true, true, Some(Role::Twin)),
            State::Paired { role } => (true, true, Some(*role)),
            State::Refused { .. } => (false, false, None),
        };
        Signal {
            name: name.to_string(),
            tunnel,
            tunnel_up: tunnel_up || tunnel == TunnelKind::None,
            joined,
            role,
            step: m.next().name().to_string(),
            tries: m.tries,
            last_transient: m.last_transient.clone(),
            paired: m.paired(),
            refused: match &m.state {
                State::Refused { why } => Some(why.clone()),
                _ => None,
            },
        }
    }

    /// `pair   main: tunnel wireguard up · joined · role twin · step twincertify (try 3: …) · not paired`
    pub fn line(&self) -> String {
        let mut parts = vec![format!(
            "tunnel {} {}",
            self.tunnel.as_str(),
            if self.tunnel_up { "up" } else { "DOWN" }
        )];
        parts.push(if self.joined { "joined".into() } else { "not joined".into() });
        if let Some(r) = self.role {
            parts.push(format!("role {}", r.as_str()));
        }
        if let Some(why) = &self.refused {
            parts.push(format!("REFUSED for good: {why}"));
        } else if !self.paired {
            let mut s = format!("step {}", self.step);
            if self.tries > 0 {
                s.push_str(&format!(" (try {}: {})", self.tries + 1, self.last_transient.as_deref().unwrap_or("")));
            }
            parts.push(s);
        }
        parts.push(if self.paired { "PAIRED".into() } else { "not paired".into() });
        format!("   pair   {}: {}", self.name, parts.join(" · "))
    }
}

impl Signal {
    /// The signal as a document (TOML), for a status file a reader can parse.
    pub fn render(&self) -> Option<String> {
        toml::to_string(self).ok()
    }

    /// The document back (what the installer reads off a VM).
    pub fn parse(text: &str) -> Result<Signal, String> {
        toml::from_str(text).map_err(|e| format!("not a pairing signal: {e}"))
    }
}

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

    #[test]
    fn the_line_follows_the_machine() {
        let mut m = Machine::default();
        let s = Signal::of("twin", TunnelKind::Wireguard, &m);
        assert!(s.line().contains("tunnel wireguard DOWN · not joined · step join · not paired"), "{}", s.line());
        m.apply(Event::Joined { role: Role::Twin, primary: Some(crate::OVERLAY_BOX_A), peers_admitted: 2, peers_expected: 3 });
        let s = Signal::of("twin", TunnelKind::Wireguard, &m);
        assert!(s.line().contains("tunnel wireguard DOWN · joined · role twin · step tunnel-up"), "{}", s.line());
        m.apply(Event::TunnelUp);
        m.apply(Event::Transient("primary not listening".into()));
        let s = Signal::of("twin", TunnelKind::Wireguard, &m);
        assert!(s.line().contains("joined · role twin · step become-twin") && s.line().contains("try 2: primary not listening"), "{}", s.line());
        m.apply(Event::TwinConferred);
        m.apply(Event::TwinCertified);
        m.apply(Event::Following { applied: 5, head: 5 });
        let s = Signal::of("twin", TunnelKind::Wireguard, &m);
        assert!(s.paired && s.line().ends_with("PAIRED"), "{}", s.line());
        let none = Signal::of("main", TunnelKind::None, &Machine::default());
        assert!(none.tunnel_up, "with no tunnel the wire is always up");
    }
}