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(¬e.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",
);
}
}
}