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

nordisk/gunnar

xtask/src/setup_svg.rs

//! `cargo xtask svg` — **the setup, drawn like a stereo**: the main device in
//! the middle, the speakers beside it, the remote in your hand.
//!
//! # What this diagram is for
//!
//! `.nornir/gunnar-quickstart.md` is four numbered lists of commands and nothing
//! else. That is deliberate — a person setting a box up wants the line to type,
//! not prose — but a list of verbs does not say **which box you run it on**, and
//! that is the one thing an operator gets wrong. `promote` on the wrong box is
//! two writable primaries; `demote` on the wrong box is a standby that keeps
//! clobbering the new main.
//!
//! So the picture carries exactly that datum and nothing else: every arrow is
//! labelled with the verb, and the arrow **starts at the box you type it on**.
//! There is no measurement here to go stale and no `file:line` to rot, which is
//! why this module has no [`crate::cycle_svg::Evidence`] and no citation guard —
//! it has the guard that fits it instead: every verb the guide's numbered lists
//! use has to appear on an arrow, so a verb that is renamed in the CLI and in
//! the guide cannot quietly keep its old spelling in the picture.
//!
//! # Generated, not hand-drawn
//!
//! The model below is the source: [`UNITS`] are the boxes, [`WIRES`] are the
//! arrows, and every coordinate in the emitted SVG is computed from a unit's
//! rectangle rather than typed. `cargo xtask svg` regenerates it and the tests
//! assert the committed bytes are what the model renders — the same rule
//! `cycle_svg` and `datapath_svg` live under.
//!
//! # Style
//!
//! skade's `.nornir/assets/` palette, matched deliberately: the constellation's
//! diagrams should read as one set. Warm near-black ground, monospace, 1.5 px
//! strokes, 6 px corners, and skade's four box colours — blue for the thing
//! that holds the data, green for the copy, amber for the read-only one, dim
//! for a client.

use std::fmt::Write as _;

use crate::cycle_svg::{esc, n, publish_asset};

// ════════════════════════════════════════════════════════════════════════════
// The palette — skade's, read off `skade/.nornir/assets/*.svg`
// ════════════════════════════════════════════════════════════════════════════

/// The ground every skade diagram is drawn on.
pub const GROUND: &str = "#14110d";
/// Body text.
pub const INK: &str = "#e8e4da";
/// Secondary text: the second line of a box, a footnote.
pub const DIM: &str = "#9a948a";
/// The arrow colour, and the marker's fill.
pub const WIRE_INK: &str = "#6fd3f5";
/// The colour a flip (`promote` / `abdicate`) is drawn in — skade's green, the
/// same one it uses for `publish_branch` and `rollback`.
pub const FLIP_INK: &str = "#4fae76";

/// The canvas.
pub const W: f64 = 1020.0;
/// The canvas.
pub const H: f64 = 540.0;

/// Advance width of one character, per point of font size, for the monospace
/// families this diagram asks for. Used by the layout test to prove no label
/// runs into a box or into another label — the failure this picture's first
/// draft had in three places at once, and the one an eye is worst at catching in
/// markup.
pub const MONO_ADV: f64 = 0.603;

// ════════════════════════════════════════════════════════════════════════════
// The model
// ════════════════════════════════════════════════════════════════════════════

/// What a box **is**, which is the only thing that picks its colour. Nothing
/// prints the role as a word: a reader who has to look up a legend to find out
/// which box takes writes has been given a legend, not a diagram.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
    /// The writable main — the one appliance clients push to.
    Main,
    /// The twin: a full gunnar that serves reads and takes the main's pushes.
    Twin,
    /// A browse replica: public repositories only, and it cannot be promoted.
    Browse,
    /// The console, `gunnar-ui` — the remote control, not a store.
    Console,
    /// A person with stock `git`.
    Client,
    /// The estate's front: the installer's door, the relay, the clock.
    Front,
    /// monetize — the thing that meters, beside the door or already running.
    Money,
}

impl Role {
    /// `fill`, `stroke`.
    pub const fn colours(self) -> (&'static str, &'static str) {
        match self {
            // skade's `snap` — the blue thing that holds the data.
            Role::Main => ("#123448", "#4f9ec0"),
            // skade's `branch` — a second copy of the same history.
            Role::Twin => ("#233a2b", "#4fae76"),
            // skade's `tag` — a fixed, read-only view.
            Role::Browse => ("#3a3323", "#c0a24f"),
            // The remote: not part of the palette's three, so it is given the
            // one hue skade does not use for a store.
            Role::Console => ("#2a2438", "#9a7fc0"),
            // A client is not equipment; it is drawn in the ground's own family.
            Role::Client => ("#22201c", "#9a948a"),
            // The front is the console's family: the door the boxes are told
            // their role at is the same machine the remote lives on.
            Role::Front => ("#2a2438", "#9a7fc0"),
            // Money in skade's warning hue — never mistaken for a store.
            Role::Money => ("#3a2620", "#c0784f"),
        }
    }

    /// The stereo the guide's title promises, one word per box. Printed as the
    /// box's kicker so the metaphor is in the picture rather than only in the
    /// caption.
    pub const fn kicker(self) -> &'static str {
        match self {
            Role::Main => "the main device",
            Role::Twin => "the second speaker",
            Role::Browse => "the rear speaker",
            Role::Console => "the remote",
            Role::Client => "you",
            Role::Front => "the front",
            Role::Money => "monetize",
        }
    }
}

/// One box.
pub struct Unit {
    /// What [`Wire`] names it by. Never drawn.
    pub id: &'static str,
    pub role: Role,
    /// The word above the box, when the role's own kicker is not it — a
    /// variant picture names its machines (`vm1`, `vm2`, `vm3`).
    pub kicker: Option<&'static str>,
    /// The heading, spelled the way the product spells it.
    pub title: &'static str,
    /// At most three lines under the heading: what it is, then the addresses it
    /// answers on. Kept short on purpose — the commands are in the guide.
    pub lines: &'static [&'static str],
    pub x: f64,
    pub y: f64,
    pub w: f64,
    pub h: f64,
}

impl Unit {
    pub fn right(&self) -> f64 {
        self.x + self.w
    }
    pub fn bottom(&self) -> f64 {
        self.y + self.h
    }
    pub fn cx(&self) -> f64 {
        self.x + self.w / 2.0
    }
}

/// How a wire is drawn, and what that says.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    /// Ordinary traffic, or a verb that adds something. Cyan, solid.
    Flow,
    /// A **flip**: the pair changes which box is writable. Green, dashed —
    /// skade draws its fast-forward publish and its rollback the same way, and
    /// this is the same kind of event.
    Flip,
}

impl Kind {
    pub const fn ink(self) -> &'static str {
        match self {
            Kind::Flow => WIRE_INK,
            Kind::Flip => FLIP_INK,
        }
    }
    pub const fn marker(self) -> &'static str {
        match self {
            Kind::Flow => "flow",
            Kind::Flip => "flip",
        }
    }
}

/// Where a wire runs, as a relation between two units — never as coordinates.
/// A path typed by hand is a path that stops agreeing with the boxes the first
/// time one of them moves.
#[derive(Debug, Clone, Copy)]
pub enum Route {
    /// Left-hand unit's right edge → right-hand unit's left edge, at `dy` below
    /// the taller box's top.
    Across { dy: f64 },
    /// `from`'s bottom edge → `to`'s top edge, at `dx` right of `from`'s left.
    Down { dx: f64 },
    /// Down from `from`'s bottom at `dx`, then right into `to`'s left edge at
    /// `to`'s mid-height. The feed to a box that is neither beside nor under the
    /// one that feeds it.
    Elbow { dx: f64 },
    /// A loop that leaves `from`'s bottom and comes back to it, hanging `drop`
    /// below — a verb you run on a box about itself.
    Onto { drop: f64 },
    /// Up from `from`'s top at `dx`, then right into `to`'s left edge at `to`'s
    /// mid-height. The mirror of [`Route::Elbow`]: the feed from a box that sits
    /// below and to the left of the one it feeds — the build box under the
    /// person, whose medium boots the appliance.
    Corner { dx: f64 },
    /// `from`'s top edge → `to`'s bottom edge, at `dx` right of `from`'s left.
    /// The mirror of [`Route::Down`]: a box that dials the one above it — a
    /// gunnar box joining the front's door.
    Rise { dx: f64 },
}

/// One arrow, and the verb written on it.
pub struct Wire {
    pub from: &'static str,
    pub to: &'static str,
    pub route: Route,
    pub kind: Kind,
    /// The verb, as it is typed. This is the load-bearing string: the tests
    /// require every verb the guide uses to appear here.
    pub label: &'static str,
    /// One clause of why, or blank.
    pub note: &'static str,
}

// ════════════════════════════════════════════════════════════════════════════
// The picture, as data
// ════════════════════════════════════════════════════════════════════════════

/// The boxes. Laid out as the stereo the guide's figure caption promises: the
/// main device centre, the speakers stacked on the right, the remote above, the
/// person on the left.
pub const UNITS: &[Unit] = &[
    Unit {
        id: "console",
        role: Role::Console,
        kicker: None,
        title: "gunnar-ui",
        lines: &[
            "gunnar-ui-server + trunk dist",
            "GUNNAR_UI_CONTROL points here \u{2193}",
        ],
        x: 300.0,
        y: 76.0,
        w: 280.0,
        h: 76.0,
    },
    Unit {
        id: "main",
        role: Role::Main,
        kicker: None,
        title: "primary appliance",
        lines: &[
            "writable \u{2014} clients push here",
            "git-ssh :2222 \u{b7} control :50051",
            "/data survives a reinstall",
        ],
        x: 300.0,
        y: 214.0,
        w: 280.0,
        h: 120.0,
    },
    Unit {
        id: "twin",
        role: Role::Twin,
        kicker: None,
        title: "twin (standby)",
        lines: &[
            "a full gunnar, read-only",
            "takes the main's pushes",
            "can be promoted",
        ],
        x: 740.0,
        y: 214.0,
        w: 256.0,
        h: 120.0,
    },
    Unit {
        id: "browse",
        role: Role::Browse,
        kicker: None,
        title: "browse replica",
        lines: &["public repositories only", "never promoted"],
        x: 740.0,
        y: 406.0,
        w: 256.0,
        h: 86.0,
    },
    Unit {
        id: "client",
        role: Role::Client,
        kicker: None,
        title: "stock git",
        lines: &["clone \u{b7} push"],
        x: 24.0,
        y: 236.0,
        w: 160.0,
        h: 76.0,
    },
    // Drawn only in the building scene (§4): the medium is made on a laptop
    // and boots into the main device. In the stereo picture the person's box
    // stands for the laptop, so this one would be the same machine drawn twice.
    Unit {
        id: "builder",
        role: Role::Client,
        kicker: None,
        title: "your laptop",
        lines: &[
            "cargo xtask iso-installer",
            ".iso-out/gunnar-installer-*.iso",
            "install-test pair (KVM) \u{b7} or dd",
        ],
        x: 24.0,
        y: 380.0,
        w: 296.0,
        h: 96.0,
    },
];

/// The arrows. Every verb in the guide's numbered lists that names a box is
/// here, and it starts on the box it is typed against.
///
/// The three lanes between the main and the twin are the pairing, top to bottom
/// in the order an operator runs them: configure, backfill, and then — one day —
/// flip. The third points the other way, which is the whole event drawn as a
/// direction rather than written as a word.
pub const WIRES: &[Wire] = &[
    Wire {
        from: "client",
        to: "main",
        route: Route::Across { dy: 46.0 },
        kind: Kind::Flow,
        label: "SSH :2222",
        note: "clone / push",
    },
    Wire {
        from: "client",
        to: "main",
        route: Route::Across { dy: 86.0 },
        kind: Kind::Flow,
        label: "gRPC :50051",
        note: "control verbs",
    },
    Wire {
        from: "console",
        to: "main",
        route: Route::Down { dx: 140.0 },
        kind: Kind::Flow,
        label: "gRPC :50051",
        note: "sshsig, no TLS",
    },
    Wire {
        from: "main",
        to: "twin",
        route: Route::Across { dy: 30.0 },
        kind: Kind::Flow,
        label: "replication add",
        note: "<name> ssh://\u{2026}:2222",
    },
    Wire {
        from: "main",
        to: "twin",
        route: Route::Across { dy: 78.0 },
        kind: Kind::Flow,
        label: "resync",
        note: "backfill",
    },
    Wire {
        from: "twin",
        to: "main",
        route: Route::Across { dy: 116.0 },
        kind: Kind::Flip,
        label: "promote",
        note: "on the twin",
    },
    Wire {
        from: "main",
        to: "browse",
        route: Route::Elbow { dx: 170.0 },
        kind: Kind::Flow,
        label: "replication add --scope public-only",
        note: "--control <replica>:50051",
    },
    Wire {
        from: "main",
        to: "main",
        route: Route::Onto { drop: 56.0 },
        kind: Kind::Flip,
        label: "abdicate / demote",
        note: "on the old main",
    },
    Wire {
        from: "builder",
        to: "main",
        route: Route::Corner { dx: 140.0 },
        kind: Kind::Flow,
        label: "boot the medium",
        note: "the machine IS the appliance",
    },
];

// ════════════════════════════════════════════════════════════════════════════
// The scenes — one exact picture per section of the guide
// ════════════════════════════════════════════════════════════════════════════

/// **One model: the boxes and the arrows, on one plane.** The quick start's
/// stereo is one ([`SETUP`]); the installer's variants are another
/// (`crate::variant_svg::VARIANTS`). A scene is a crop of exactly one model, so
/// the renderer and every layout guard are shared and never drawn twice.
pub struct Model {
    pub units: &'static [Unit],
    pub wires: &'static [Wire],
}

impl Model {
    pub fn unit(&self, id: &str) -> &'static Unit {
        self.units
            .iter()
            .find(|u| u.id == id)
            .unwrap_or_else(|| panic!("setup_svg: no unit named {id:?}"))
    }
}

/// The quick start's stereo.
pub const SETUP: Model = Model {
    units: UNITS,
    wires: WIRES,
};

/// **One picture of the guide.** The whole stereo is a scene, and each numbered
/// section of `.nornir/gunnar-quickstart.md` is a scene that draws ONLY the
/// boxes and arrows its commands touch. Same model, same coordinates, same
/// guards: a scene is a crop of the one picture, never a second drawing.
pub struct Scene {
    /// The one picture this scene is a crop of.
    pub model: &'static Model,
    /// Published path; `.nornir/assets/…` publishes to both asset dirs.
    pub asset: &'static str,
    pub title: &'static str,
    pub subtitle: &'static str,
    /// Unit ids drawn. A wire is drawn iff both its ends are drawn.
    pub units: &'static [&'static str],
    /// Verbs (wire labels) LEFT OUT although both ends are drawn — §1 draws the
    /// main without its pairing arrows.
    pub omit: &'static [&'static str],
    pub footer: &'static [&'static str],
}

pub const SCENES: &[Scene] = &[
    Scene {
        model: &SETUP,
        asset: ".nornir/assets/gunnar-setup.svg",
        title: "gunnar \u{2014} the setup, like installing a stereo",
        subtitle: "main device in the middle, speakers beside it, remote in your hand \u{b7} an arrow starts on the box you type the verb on",
        units: &["console", "main", "twin", "browse", "client"],
        omit: &[],
        footer: &[
            "the twin is installed from the same medium as the main \u{2014} `replication standby` on it, `replication add` on the main,",
            "and `replication list` says: last successful replication: <time>",
        ],
    },
    Scene {
        model: &SETUP,
        asset: ".nornir/assets/gunnar-setup-1.svg",
        title: "\u{a7}1 \u{b7} one gunnar server",
        subtitle: "one appliance, one person: control it over :50051, push to it over :2222",
        units: &["main", "client"],
        omit: &["abdicate / demote"],
        footer: &["`bootstrap` works once, ever; `status` is the one question an unclaimed server answers"],
    },
    Scene {
        model: &SETUP,
        asset: ".nornir/assets/gunnar-setup-2.svg",
        title: "\u{a7}2 \u{b7} the console beside it",
        subtitle: "gunnar-ui is one process pointed at one control plane; a browser reaches it on :3210",
        units: &["console", "main"],
        omit: &["abdicate / demote"],
        footer: &["GUNNAR_UI_CONTROL is the only thing a failover changes about the console"],
    },
    Scene {
        model: &SETUP,
        asset: ".nornir/assets/gunnar-setup-3.svg",
        title: "\u{a7}3 \u{b7} a twin, and the two flips",
        subtitle: "configure, backfill, and \u{2014} one day \u{2014} flip: the green arrows change which box is writable",
        units: &["main", "twin", "browse"],
        omit: &[],
        footer: &["`promote` on the twin, `abdicate`/`demote` on the old main \u{2014} never both on one box"],
    },
    Scene {
        model: &SETUP,
        asset: ".nornir/assets/gunnar-setup-4.svg",
        title: "\u{a7}4 \u{b7} building gunnar",
        subtitle: "the medium is built on a laptop and boots straight into the main device",
        units: &["builder", "main"],
        omit: &["abdicate / demote"],
        footer: &["`--build kernel` when the pinned kernel toolchain image is on the build box; else `--opt wireguard=off`"],
    },
];

impl Scene {
    /// The wires this scene draws: both ends present, verb not omitted.
    pub fn wires(&self) -> Vec<&'static Wire> {
        self.model
            .wires
            .iter()
            .filter(|w| self.has(w.from) && self.has(w.to) && !self.omit.contains(&w.label))
            .collect()
    }
    pub fn has(&self, id: &str) -> bool {
        self.units.contains(&id)
    }
    pub fn units(&self) -> Vec<&'static Unit> {
        self.model.units.iter().filter(|u| self.has(u.id)).collect()
    }
    /// The crop: `(origin x, origin y, width, height)` in model coordinates.
    /// Everything drawn — boxes, kickers, wire labels — plus a 16 px side margin,
    /// a header band above and a footer band below. Computed, so a scene can
    /// never draw outside its own canvas.
    pub fn frame(&self) -> (f64, f64, f64, f64) {
        let mut x0 = f64::MAX;
        let mut y0 = f64::MAX;
        let mut x1 = f64::MIN;
        let mut y1 = f64::MIN;
        for u in self.units() {
            x0 = x0.min(u.x);
            y0 = y0.min(u.y - 14.0); // the kicker above the box
            x1 = x1.max(u.right());
            y1 = y1.max(u.bottom());
        }
        for l in self.wire_labels() {
            let r = l.rect();
            x0 = x0.min(r.0);
            y0 = y0.min(r.1);
            x1 = x1.max(r.2);
            y1 = y1.max(r.3);
        }
        for w in self.wires() {
            for ((ax, ay), (bx, by)) in wire_segments(w, self.model) {
                x0 = x0.min(ax.min(bx));
                y0 = y0.min(ay.min(by));
                x1 = x1.max(ax.max(bx));
                y1 = y1.max(ay.max(by));
            }
            if let Route::Onto { drop } = w.route {
                y1 = y1.max(self.model.unit(w.from).bottom() + drop);
            }
        }
        let header = 70.0;
        let footer = 22.0 + 16.0 * self.footer.len() as f64;
        // The canvas is never narrower than its own words: the title (15 px
        // bold), the subtitle (13 px) and every footer line (13 px) plus the
        // 16 px margin each side. A short scene with a long footer grows, it
        // does not clip.
        let text = |t: &str, size: f64| t.chars().count() as f64 * size * MONO_ADV + 32.0;
        let mut w = (x1 - x0) + 32.0;
        w = w.max(text(self.title, 15.0)).max(text(self.subtitle, 13.0));
        for f in self.footer {
            w = w.max(text(f, 13.0));
        }
        (
            x0 - 16.0,
            y0 - header,
            w.ceil(),
            (y1 - y0) + header + footer,
        )
    }
    pub fn width(&self) -> f64 {
        self.frame().2
    }
    pub fn height(&self) -> f64 {
        self.frame().3
    }
    /// Every wire's verb and note, placed, in model coordinates.
    pub fn wire_labels(&self) -> Vec<Label> {
        let mut out = Vec::new();
        for w in self.wires() {
            let (_, x, y, ny, anchor) = geometry(w, self.model);
            out.push(Label {
                text: w.label.to_string(),
                size: 12.0,
                x,
                y,
                anchor,
            });
            if !w.note.is_empty() {
                out.push(Label {
                    text: w.note.to_string(),
                    size: 11.0,
                    x,
                    y: ny,
                    anchor,
                });
            }
        }
        out
    }
    /// The subtitle, as a measured line in CANVAS coordinates.
    pub fn subtitle_label(&self) -> Label {
        Label {
            text: self.subtitle.to_string(),
            size: 13.0,
            x: 16.0,
            y: 50.0,
            anchor: "start",
        }
    }
    /// The footer lines, measured, in CANVAS coordinates.
    pub fn footer_labels(&self) -> Vec<Label> {
        let h = self.height();
        self.footer
            .iter()
            .enumerate()
            .map(|(i, t)| Label {
                text: t.to_string(),
                size: 13.0,
                x: 16.0,
                y: h - 14.0 - 16.0 * (self.footer.len() - 1 - i) as f64,
                anchor: "start",
            })
            .collect()
    }
}

/// The whole picture — the first scene.
pub fn whole() -> &'static Scene {
    &SCENES[0]
}

// ════════════════════════════════════════════════════════════════════════════
// Rendering
// ════════════════════════════════════════════════════════════════════════════

/// The drawn path, and the points the verb and its note hang off, computed from
/// the two rectangles. Returns `(path d, label x, verb baseline, note baseline,
/// anchor)`.
///
/// The verb and the note are placed on OPPOSITE sides of a horizontal wire, never
/// at "the line plus five": a note whose baseline sits five pixels under the wire
/// has the wire drawn through its x-height, which is what the committed picture
/// showed on 2026-09-10 for `clone / push`, `<name> ssh://…:2222`, `backfill` and
/// `on the twin` — four of the seven things an operator is meant to type, struck
/// through. [`wire_segments`] and the guard below make that impossible to commit.
fn geometry(w: &Wire, m: &Model) -> (String, f64, f64, f64, &'static str) {
    let (a, b) = (m.unit(w.from), m.unit(w.to));
    match w.route {
        Route::Across { dy } => {
            let (l, r) = if a.x <= b.x { (a, b) } else { (b, a) };
            let y = l.y.min(r.y) + dy;
            let (x0, x1) = if a.x <= b.x {
                (l.right() + 6.0, r.x - 6.0)
            } else {
                (r.x - 6.0, l.right() + 6.0)
            };
            (
                format!("M{},{} L{},{}", n(x0), n(y), n(x1), n(y)),
                (x0 + x1) / 2.0,
                y - 7.0,
                y + 15.0,
                "middle",
            )
        }
        Route::Down { dx } => {
            let x = a.x + dx;
            let (y0, y1) = (a.bottom() + 6.0, b.y - 6.0);
            (
                format!("M{},{} L{},{}", n(x), n(y0), n(x), n(y1)),
                x + 10.0,
                (y0 + y1) / 2.0 + 4.0,
                (y0 + y1) / 2.0 + 19.0,
                "start",
            )
        }
        Route::Elbow { dx } => {
            let x = a.x + dx;
            let y = b.y + b.h / 2.0;
            (
                format!(
                    "M{},{} L{},{} L{},{}",
                    n(x),
                    n(a.bottom() + 6.0),
                    n(x),
                    n(y),
                    n(b.x - 6.0),
                    n(y)
                ),
                // Well clear of the horizontal leg: at the usual `y - 10` the
                // note's own baseline sits ON the wire, and a line struck
                // through the address you are meant to type is worse than no
                // label at all.
                b.x - 10.0,
                y - 24.0,
                y - 9.0,
                "end",
            )
        }
        Route::Onto { drop } => {
            let y = a.bottom() + drop;
            let (x0, x1) = (a.x + 30.0, a.x + 90.0);
            (
                format!(
                    "M{},{} C{},{} {},{} {},{}",
                    n(x0),
                    n(a.bottom() + 6.0),
                    n(x0),
                    n(y),
                    n(x1),
                    n(y),
                    n(x1),
                    n(a.bottom() + 6.0)
                ),
                // To the LEFT of the loop: the right-hand side is where the feed
                // to the browse replica drops past.
                x0 - 14.0,
                y - 8.0,
                y + 7.0,
                "end",
            )
        }
        Route::Corner { dx } => {
            let x = a.x + dx;
            let y = b.y + b.h / 2.0;
            (
                format!(
                    "M{},{} L{},{} L{},{}",
                    n(x),
                    n(a.y - 6.0),
                    n(x),
                    n(y),
                    n(b.x - 6.0),
                    n(y)
                ),
                b.x - 10.0,
                y - 24.0,
                y - 9.0,
                "end",
            )
        }
        Route::Rise { dx } => {
            let x = a.x + dx;
            let (y0, y1) = (a.y - 6.0, b.bottom() + 6.0);
            (
                format!("M{},{} L{},{}", n(x), n(y0), n(x), n(y1)),
                x + 10.0,
                (y0 + y1) / 2.0 - 4.0,
                (y0 + y1) / 2.0 + 11.0,
                "start",
            )
        }
    }
}

/// The straight legs of a wire, as `((x0, y0), (x1, y1))`, axis-aligned. The
/// curve of [`Route::Onto`] has no straight leg and contributes nothing; its label
/// is placed beside the loop, not on it.
pub fn wire_segments(w: &Wire, m: &Model) -> Vec<((f64, f64), (f64, f64))> {
    let (a, b) = (m.unit(w.from), m.unit(w.to));
    match w.route {
        Route::Across { dy } => {
            let (l, r) = if a.x <= b.x { (a, b) } else { (b, a) };
            let y = l.y.min(r.y) + dy;
            vec![((l.right() + 6.0, y), (r.x - 6.0, y))]
        }
        Route::Down { dx } => {
            let x = a.x + dx;
            vec![((x, a.bottom() + 6.0), (x, b.y - 6.0))]
        }
        Route::Elbow { dx } => {
            let x = a.x + dx;
            let y = b.y + b.h / 2.0;
            vec![((x, a.bottom() + 6.0), (x, y)), ((x, y), (b.x - 6.0, y))]
        }
        Route::Onto { .. } => Vec::new(),
        Route::Corner { dx } => {
            let x = a.x + dx;
            let y = b.y + b.h / 2.0;
            vec![((x, a.y - 6.0), (x, y)), ((x, y), (b.x - 6.0, y))]
        }
        Route::Rise { dx } => {
            let x = a.x + dx;
            vec![((x, a.y - 6.0), (x, b.bottom() + 6.0))]
        }
    }
}

/// **One piece of text on the picture, with the box it will occupy.**
///
/// The layout guard needs the same numbers the renderer uses, so both read this
/// rather than each computing its own — a collision test that measures a second
/// opinion of where the text went is a test of the second opinion.
pub struct Label {
    pub text: String,
    /// Font size in points. 12 for a verb, 11 for its note.
    pub size: f64,
    pub x: f64,
    pub y: f64,
    pub anchor: &'static str,
}

impl Label {
    /// `(x0, y0, x1, y1)`, estimated: the advance width times the size for the
    /// run, and the size itself for the height above the baseline.
    pub fn rect(&self) -> (f64, f64, f64, f64) {
        let w = self.text.chars().count() as f64 * self.size * MONO_ADV;
        let x0 = match self.anchor {
            "middle" => self.x - w / 2.0,
            "end" => self.x - w,
            _ => self.x,
        };
        (x0, self.y - self.size, x0 + w, self.y + 2.0)
    }
}

/// Every wire's verb and note of the whole picture, placed. Kept for the guard
/// that reads the stereo alone; scenes have [`Scene::wire_labels`].
pub fn wire_labels() -> Vec<Label> {
    whole().wire_labels()
}

/// Render the whole figure. Pure: same model, same bytes.
pub fn render() -> String {
    render_scene(whole())
}

/// Render one scene. Pure: same model, same bytes, which is what lets the test
/// hold every committed asset to it. Model coordinates are shifted by the
/// scene's frame origin so the crop starts at (0, 0); nothing else moves.
pub fn render_scene(sc: &Scene) -> String {
    let (ox, oy, w_px, h_px) = sc.frame();
    let mut s = String::with_capacity(8192);
    let _ = write!(
        s,
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" font-family="monospace" font-size="13">
  <!-- GENERATED by `cargo xtask svg` from xtask/src/setup_svg.rs. Do not hand-edit:
       every arrow starts on the box the verb is typed against, and moving a line
       instead of the model is how a diagram starts telling an operator to run
       `promote` on the wrong machine. -->
  <style>
    .box{{rx:6;ry:6;stroke-width:1.5}}
    .dim{{fill:{dim}}}
    .hdr{{fill:{ink};font-size:15px;font-weight:bold}}
    .kick{{fill:{dim};font-size:11px}}
    .name{{fill:{ink};font-size:14px;font-weight:bold}}
    .verb{{font-size:12px;font-weight:bold}}
    .note{{fill:{dim};font-size:11px}}
    .flow{{stroke:{flow};stroke-width:1.6;fill:none;marker-end:url(#flow)}}
    .flip{{stroke:{flip};stroke-width:1.6;fill:none;marker-end:url(#flip);stroke-dasharray:4 3}}
  </style>
  <defs>
    <marker id="flow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="{flow}"/></marker>
    <marker id="flip" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="{flip}"/></marker>
  </defs>
  <rect width="{w}" height="{h}" fill="{ground}"/>
  <text x="16" y="28" class="hdr">{title}</text>
  <text x="16" y="50" class="dim">{sub}</text>
  <g transform="translate({tx} {ty})">"##,
        title = esc(sc.title),
        sub = esc(sc.subtitle),
        w = n(w_px),
        h = n(h_px),
        tx = n(-ox),
        ty = n(-oy),
        ground = GROUND,
        ink = INK,
        dim = DIM,
        flow = WIRE_INK,
        flip = FLIP_INK,
    );
    // ── the boxes ───────────────────────────────────────────────────────────
    for u in sc.units() {
        let (fill, stroke) = u.role.colours();
        let _ = write!(
            s,
            "\n  <text x=\"{kx}\" y=\"{ky}\" class=\"kick\">{kick}</text>\
             \n  <rect class=\"box\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"{fill}\" stroke=\"{stroke}\"/>\
             \n  <text x=\"{tx}\" y=\"{ty}\" class=\"name\">{title}</text>",
            kx = n(u.x),
            ky = n(u.y - 8.0),
            kick = esc(u.kicker.unwrap_or(u.role.kicker())),
            x = n(u.x),
            y = n(u.y),
            w = n(u.w),
            h = n(u.h),
            fill = fill,
            stroke = stroke,
            tx = n(u.x + 12.0),
            ty = n(u.y + 24.0),
            title = esc(u.title),
        );
        for (i, line) in u.lines.iter().enumerate() {
            let _ = write!(
                s,
                "\n  <text x=\"{x}\" y=\"{y}\" class=\"dim\">{line}</text>",
                x = n(u.x + 12.0),
                y = n(u.y + 44.0 + i as f64 * 17.0),
                line = esc(line),
            );
        }
    }

    // ── the arrows ──────────────────────────────────────────────────────────
    //
    // The paths here and the label placements in `Scene::wire_labels` come out
    // of the same `geometry` call, so the guard measures the text this loop
    // wrote rather than a second opinion about where it went.
    let mut labels = sc.wire_labels().into_iter();
    for w in sc.wires() {
        let (d, _, _, _, _) = geometry(w, sc.model);
        let verb = labels.next().expect("a label per wire");
        let _ = write!(
            s,
            "\n  <path class=\"{cls}\" d=\"{d}\"/>\
             \n  <text x=\"{lx}\" y=\"{ly}\" text-anchor=\"{anchor}\" class=\"verb\" fill=\"{ink}\">{label}</text>",
            cls = w.kind.marker(),
            d = d,
            lx = n(verb.x),
            ly = n(verb.y),
            anchor = verb.anchor,
            ink = w.kind.ink(),
            label = esc(&verb.text),
        );
        if !w.note.is_empty() {
            let note = labels.next().expect("a note where the wire has one");
            let _ = write!(
                s,
                "\n  <text x=\"{lx}\" y=\"{ly}\" text-anchor=\"{anchor}\" class=\"note\">{note}</text>",
                lx = n(note.x),
                ly = n(note.y),
                anchor = note.anchor,
                note = esc(&note.text),
            );
        }
    }
    s.push_str("\n  </g>");

    for f in sc.footer_labels() {
        let _ = write!(
            s,
            "\n  <text x=\"{x}\" y=\"{y}\" class=\"dim\">{t}</text>",
            x = n(f.x),
            y = n(f.y),
            t = esc(&f.text),
        );
    }
    s.push_str("\n</svg>\n");
    s
}

/// Where the whole figure is published. The guide embeds this path.
pub const ASSET: &str = ".nornir/assets/gunnar-setup.svg";

/// Render and write every scene, to every asset directory. Returns
/// `(asset, bytes)` per scene so the caller prints applied outputs rather than
/// "done".
pub fn write_asset(repo_root: &std::path::Path) -> std::io::Result<Vec<(&'static str, usize)>> {
    let mut out = Vec::with_capacity(SCENES.len() + crate::variant_svg::SCENES.len());
    for sc in SCENES.iter().chain(crate::variant_svg::SCENES) {
        let svg = render_scene(sc);
        let name = std::path::Path::new(sc.asset)
            .file_name()
            .expect("a scene asset names a file");
        publish_asset(repo_root, &name.to_string_lossy(), svg.as_bytes())?;
        out.push((sc.asset, svg.len()));
    }
    Ok(out)
}

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

    fn repo_root() -> std::path::PathBuf {
        crate::guard::workspace_root()
    }

    /// **Every verb the guide tells an operator to type is on an arrow.**
    ///
    /// This is the guard that fits this diagram. There is no measurement here to
    /// go stale, but there is a spelling: the day `abdicate` is renamed, the CLI
    /// and `.nornir/gunnar-quickstart.md` change together and the picture does
    /// not — it keeps drawing the old word, in the right place, looking correct.
    #[test]
    fn every_verb_is_drawn() {
        let svg = render();
        for verb in [
            "replication add",
            "resync",
            "promote",
            "abdicate",
            "demote",
            "--scope public-only",
            ":2222",
            ":50051",
        ] {
            assert!(
                svg.contains(verb),
                "setup_svg: the figure does not say {verb:?}, but the quick start tells an \
                 operator to type it. Add it to WIRES rather than to the picture."
            );
        }
    }

    /// A verb arrow that starts on the wrong box is the failure this diagram
    /// exists to prevent, so the two flips are pinned by name rather than left
    /// to a reader's eye: `promote` leaves the TWIN, `abdicate` leaves the MAIN.
    #[test]
    fn the_flips_start_on_the_right_box() {
        let promote = WIRES
            .iter()
            .find(|w| w.label == "promote")
            .expect("a promote wire");
        assert_eq!(
            promote.from, "twin",
            "`promote` is typed on the standby that is taking over; an arrow that leaves the \
             main draws two writable primaries"
        );
        let step_down = WIRES
            .iter()
            .find(|w| w.label.starts_with("abdicate"))
            .expect("an abdicate wire");
        assert_eq!(
            step_down.from, "main",
            "`abdicate`/`demote` is typed on the box that is standing down"
        );
    }

    /// Every wire names units that exist, and no unit is drawn twice.
    #[test]
    fn the_model_closes() {
        let mut seen = std::collections::BTreeSet::new();
        for u in UNITS {
            assert!(seen.insert(u.id), "setup_svg: two units called {:?}", u.id);
            assert!(
                u.x >= 0.0 && u.right() <= W && u.y >= 0.0 && u.bottom() <= H,
                "setup_svg: {} is off the model's plane",
                u.id
            );
        }
        for w in WIRES {
            assert!(seen.contains(w.from), "setup_svg: no unit {:?}", w.from);
            assert!(seen.contains(w.to), "setup_svg: no unit {:?}", w.to);
        }
        // Every line of a box fits inside it: 12 px padding each side at 13 px,
        // the title at 14 px bold, and the last line's baseline above the
        // bottom edge. The build box's `.iso-out/gunnar-installer-*.iso` ran
        // 1 px past its right edge on 2026-09-10 and no guard said so.
        assert_units_fit(UNITS);
    }

    /// Every line of every box fits inside it — shared with `variant_svg`.
    pub(crate) fn assert_units_fit(units: &[Unit]) {
        for u in units {
            let title_w = u.title.chars().count() as f64 * 14.0 * MONO_ADV;
            assert!(
                title_w + 24.0 <= u.w,
                "setup_svg: the title of `{}` does not fit its box ({title_w:.0} px + 24 > {})",
                u.id,
                u.w
            );
            for line in u.lines {
                let lw = line.chars().count() as f64 * 13.0 * MONO_ADV;
                assert!(
                    lw + 24.0 <= u.w,
                    "setup_svg: the line {line:?} of `{}` does not fit its box ({lw:.0} px + 24 > {})",
                    u.id,
                    u.w
                );
            }
            let last = 44.0 + (u.lines.len().max(1) - 1) as f64 * 17.0 + 4.0;
            assert!(
                last <= u.h,
                "setup_svg: `{}` has {} lines and is only {} px tall (needs {last})",
                u.id,
                u.lines.len(),
                u.h
            );
        }
    }

    /// The rendered bytes are well-formed enough to be an SVG, and every box's
    /// heading survived into them.
    #[test]
    fn every_box_is_drawn() {
        let svg = render();
        assert!(svg.starts_with("<svg "), "setup_svg: not an svg");
        assert!(
            svg.trim_end().ends_with("</svg>"),
            "setup_svg: unclosed svg"
        );
        assert!(
            !svg.contains("\\u{"),
            "setup_svg: a Rust escape leaked into the markup as literal text"
        );
        for sc in SCENES {
            let svg = render_scene(sc);
            for u in sc.units() {
                assert!(
                    svg.contains(&esc(u.title)),
                    "setup_svg[{}]: {} is in the scene and not in the picture",
                    sc.asset,
                    u.id
                );
            }
        }
    }

    /// **No label lands on a box, and no two labels land on each other.**
    ///
    /// This is the guard the first draft of this figure needed and did not have.
    /// Rendered from a model that read perfectly, it put `abdicate / demote`
    /// across the console's gRPC label, ran `replication add --scope public-only`
    /// through the `promote` curve, and stacked two more pairs — three
    /// collisions, in markup that was valid SVG throughout and that no assertion
    /// in this module could see. A diagram whose text overlaps is not a
    /// stylistic complaint; it is a diagram an operator reads the wrong verb off.
    ///
    /// The width is an ESTIMATE ([`MONO_ADV`] × size × characters) and it is the
    /// honest tool here: the real advance depends on the reader's monospace
    /// face, so a test that demanded exact metrics would be asserting something
    /// about a font nobody has. An estimate that is right to a few per cent
    /// catches a 90-pixel overlap and does not fire on a 2-pixel one.
    #[test]
    fn nothing_is_drawn_on_top_of_anything() {
        for sc in SCENES {
            assert_scene_layout(sc);
        }
    }

    /// The layout guard for ONE scene — shared with `variant_svg`.
    pub(crate) fn assert_scene_layout(sc: &Scene) {
        fn overlap(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> bool {
            a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3
        }
        {
            let (ox, oy, w, h) = sc.frame();
            let canvas = |r: (f64, f64, f64, f64)| (r.0 - ox, r.1 - oy, r.2 - ox, r.3 - oy);
            let labels = sc.wire_labels();
            let units = sc.units();
            // Every box is inside its scene's canvas, under the header band.
            for u in &units {
                let r = canvas((u.x, u.y - 14.0, u.right(), u.bottom()));
                assert!(
                    r.0 >= 0.0 && r.2 <= w && r.1 >= 60.0 && r.3 <= h,
                    "setup_svg[{}]: the `{}` box is off the canvas at {r:?}",
                    sc.asset,
                    u.id
                );
            }
            for l in &labels {
                let r = canvas(l.rect());
                assert!(
                    r.0 >= 0.0 && r.2 <= w && r.1 >= 60.0 && r.3 <= h,
                    "setup_svg[{}]: {:?} runs off the canvas at {r:?}",
                    sc.asset,
                    l.text
                );
                for u in &units {
                    assert!(
                        !overlap(l.rect(), (u.x, u.y, u.right(), u.bottom())),
                        "setup_svg[{}]: the label {:?} is drawn over the `{}` box \
                         (label {:?}, box {:?}) \u{2014} move the route, not the text",
                        sc.asset,
                        l.text,
                        u.id,
                        l.rect(),
                        (u.x, u.y, u.right(), u.bottom())
                    );
                }
            }
            for (i, a) in labels.iter().enumerate() {
                for b in &labels[i + 1..] {
                    assert!(
                        !overlap(a.rect(), b.rect()),
                        "setup_svg[{}]: {:?} and {:?} are drawn on top of each other",
                        sc.asset,
                        a.text,
                        b.text
                    );
                }
            }
            // No wire may be drawn THROUGH a label. This is the guard the
            // 2026-09-10 picture lacked: every label cleared every box and every
            // other label, and four of them had their own wire struck through
            // their x-height. RED direction: put a note back at `y + 5.0` in
            // `geometry` and this names it.
            for l in &labels {
                let r = l.rect();
                for wire in sc.wires() {
                    for ((x0, y0), (x1, y1)) in wire_segments(wire, sc.model) {
                        let (sx0, sx1) = (x0.min(x1), x0.max(x1));
                        let (sy0, sy1) = (y0.min(y1), y0.max(y1));
                        let crosses = sx0 < r.2 && r.0 < sx1 && sy0 < r.3 && r.1 < sy1;
                        assert!(
                            !crosses,
                            "setup_svg[{}]: the wire {:?} ({:?}) is drawn through the label {:?} \
                             (label {r:?}, segment ({x0},{y0})-({x1},{y1})) \u{2014} an operator \
                             would read a struck-through address",
                            sc.asset, wire.label, wire.route, l.text
                        );
                    }
                }
                // Nor through a box it does not end on.
                for wire in sc.wires() {
                    for ((x0, y0), (x1, y1)) in wire_segments(wire, sc.model) {
                        for u in &units {
                            if u.id == wire.from || u.id == wire.to {
                                continue;
                            }
                            let (sx0, sx1) = (x0.min(x1), x0.max(x1));
                            let (sy0, sy1) = (y0.min(y1), y0.max(y1));
                            let crosses =
                                sx0 < u.right() && u.x < sx1 && sy0 < u.bottom() && u.y < sy1;
                            assert!(
                                !crosses,
                                "setup_svg[{}]: the wire {:?} is drawn through the `{}` box",
                                sc.asset, wire.label, u.id
                            );
                        }
                    }
                }
            }
            // The subtitle and the footer are text like any other: they must fit
            // the canvas minus its margin. The one-line footer once ran 171
            // characters at 13 px past a 1020 px edge and nothing said so.
            for f in sc
                .footer_labels()
                .into_iter()
                .chain(std::iter::once(sc.subtitle_label()))
            {
                let r = f.rect();
                assert!(
                    r.2 <= w - 16.0 && r.3 <= h && r.0 >= 0.0,
                    "setup_svg[{}]: the line {:?} runs off the canvas (or into its 16 px margin) \
                     at {r:?} on a {w} px canvas",
                    sc.asset,
                    f.text
                );
                for u in &units {
                    assert!(
                        !overlap(r, canvas((u.x, u.y, u.right(), u.bottom()))),
                        "setup_svg[{}]: the line {:?} is drawn over the `{}` box",
                        sc.asset,
                        f.text,
                        u.id
                    );
                }
            }
            // The title itself, at 15 px bold.
            let t = Label {
                text: sc.title.to_string(),
                size: 15.0,
                x: 16.0,
                y: 28.0,
                anchor: "start",
            };
            assert!(
                t.rect().2 <= w - 16.0,
                "setup_svg[{}]: the title runs off the canvas",
                sc.asset
            );
        }
    }

    /// **Every scene draws every unit and every verb its section's commands
    /// touch, and nothing the section does not.** Pinned by name so a scene
    /// cannot silently lose the one arrow the section is about.
    #[test]
    fn each_scene_draws_its_section() {
        let by_asset = |a: &str| SCENES.iter().find(|s| s.asset.ends_with(a)).expect(a);
        let verbs = |sc: &Scene| sc.wires().iter().map(|w| w.label).collect::<Vec<_>>();
        let s1 = by_asset("gunnar-setup-1.svg");
        assert_eq!(s1.units, &["main", "client"]);
        assert_eq!(
            verbs(s1),
            vec!["SSH :2222", "gRPC :50051"],
            "\u{a7}1 is push and control, nothing else"
        );
        let s2 = by_asset("gunnar-setup-2.svg");
        assert_eq!(
            verbs(s2),
            vec!["gRPC :50051"],
            "\u{a7}2 is the console's one wire"
        );
        let s3 = by_asset("gunnar-setup-3.svg");
        assert_eq!(
            verbs(s3),
            vec![
                "replication add",
                "resync",
                "promote",
                "replication add --scope public-only",
                "abdicate / demote"
            ],
            "\u{a7}3 is the pairing, the flips and the browse replica"
        );
        let s4 = by_asset("gunnar-setup-4.svg");
        assert_eq!(
            verbs(s4),
            vec!["boot the medium"],
            "\u{a7}4 is the medium road"
        );
        // The whole picture never draws the builder: the person's box IS the laptop.
        assert!(!whole().has("builder"));
        // Every unit is drawn somewhere.
        for u in UNITS {
            assert!(
                SCENES.iter().any(|s| s.has(u.id)),
                "setup_svg: unit {:?} is in no scene",
                u.id
            );
        }
    }

    /// **The committed asset is what the model renders**, in every directory it
    /// is published to. Same rule as `cycle_svg` and `datapath_svg`: the picture
    /// is what ships, and a checked-in copy that differs from the generator is
    /// stale by definition.
    #[test]
    fn the_committed_asset_is_fresh() {
        for sc in SCENES {
            crate::cycle_svg::assert_asset_fresh(
                &repo_root(),
                sc.asset,
                &render_scene(sc),
                "cargo xtask svg",
            );
        }
    }
}