nornir 0.5.3

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! 📖 **Manual** tab — nornir's own documentation, rendered live in the viz.
//!
//! `nornir docs book --format svg` writes the whole-doc book as one crisp vector
//! SVG per page plus a `manifest.json` under `<repo>/docs/book-svg/` (see
//! [`crate::docs`]). This tab loads that on-disk contract into a
//! [`facett_docview::DocView`] — a pure-Rust (resvg/usvg) vector manual viewer
//! that parses + rasterizes pages **multi-core** (rayon) — so the manual is a
//! click away inside `nornir viz`, crisp at any zoom, no PDF reader.
//!
//! Loading is **lazy** (first time the tab is shown) and best-effort: a missing
//! `book-svg/` dir renders a one-line hint telling you to run `nornir docs book
//! --format svg`, never an error.

use std::path::{Path, PathBuf};

use eframe::egui::{self};

use facett_docview::{DocView, Facet};

/// 📖 The Manual tab: a lazily-loaded [`DocView`] over `docs/book-svg/`.
pub struct ManualTab {
    /// The viewer, built on first show; `None` until then or on load failure.
    view: Option<DocView>,
    /// Whether a load was attempted (so we don't retry every frame).
    loaded: bool,
    /// Load error (shown as a hint), or `None`.
    error: Option<String>,
    /// The resolved `book-svg` dir (for `state_json` / the hint).
    dir: Option<PathBuf>,
    /// True when the rendered book is the BUILT-IN fallback (no generated book
    /// was found on disk / embedded).
    builtin: bool,
}

impl Default for ManualTab {
    fn default() -> Self {
        Self { view: None, loaded: false, error: None, dir: None, builtin: false }
    }
}

impl ManualTab {
    pub fn new() -> Self {
        Self::default()
    }

    /// Force a reload on next draw (e.g. after `docs book` regenerates the SVGs).
    pub fn reload(&mut self) {
        self.loaded = false;
        self.view = None;
        self.error = None;
        self.builtin = false;
    }

    /// Whether the manual is loaded and has at least one page.
    pub fn is_loaded(&self) -> bool {
        self.view.as_ref().is_some_and(|v| v.page_count() > 0)
    }

    fn load(&mut self) {
        if self.loaded {
            return;
        }
        self.loaded = true;

        // `manual-embed`: the archive is baked into the binary (travels with
        // `cargo install`). Materialize it once to the data dir, so the znippy
        // resolver below finds it like any on-disk archive.
        #[cfg(feature = "manual-embed")]
        pluck_out_embedded();

        // Preferred: a packed `book.znippy` → the LAZY viewer reading pages O(1)
        // out of the archive (only the visible band is ever decompressed/parsed).
        // Falls through to the raw page dir if no archive is found or it fails.
        #[cfg(feature = "manual-znippy")]
        if let Some(path) = resolve_book_znippy() {
            match load_manual_znippy(&path) {
                Ok(view) => {
                    self.dir = Some(path);
                    self.view = Some(view);
                    return;
                }
                Err(e) => eprintln!("⚠ manual: book.znippy load failed, trying page dir: {e}"),
            }
        }

        match resolve_book_svg_dir() {
            Some(dir) => {
                self.dir = Some(dir.clone());
                match load_manual(&dir) {
                    Ok(view) => self.view = Some(view),
                    // A present-but-unreadable dir falls back to the built-in book
                    // rather than dead-ending on an error.
                    Err(e) => {
                        eprintln!("⚠ manual: {e}; using the built-in fallback book");
                        self.load_builtin();
                    }
                }
            }
            // No generated book anywhere (the common fresh-checkout / lean-install
            // state — `docs/book-svg` + `docs/book.znippy` are gitignored, generated
            // on demand). Render the BUILT-IN fallback book so the 📖 Manual tab is
            // never a dead "not available" hint; `nornir docs book --format svg`
            // regenerates the full manual and takes precedence next load.
            None => self.load_builtin(),
        }
    }

    /// Build a [`DocView`] from the [`BUILTIN_PAGES`] baked into the binary — the
    /// always-available minimal manual (cover + what-is-nornir + how-to-regenerate).
    fn load_builtin(&mut self) {
        let bytes: Vec<Vec<u8>> = BUILTIN_PAGES.iter().map(|s| s.as_bytes().to_vec()).collect();
        let toc = vec![
            ("Cover".to_string(), 0usize),
            ("What is nornir".to_string(), 1usize),
            ("The book pipeline".to_string(), 2usize),
        ];
        self.dir = Some(PathBuf::from("<built-in>"));
        self.builtin = true;
        self.view = Some(DocView::from_svgs("nornir (built-in manual)".to_string(), bytes, toc));
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        self.load();
        // EMIT-DOCTRINE (§selftest): the manual is loaded lazily on first draw — ok ⇔
        // a DocView with at least one page materialized; a missing `book-svg/` (the
        // "run `nornir docs book`" hint) is the honest RED empty-state.
        #[cfg(feature = "testmatrix")]
        crate::selftest::emit(
            "viz/manual (egui draw)",
            "manual_loaded",
            self.is_loaded(),
            &match (&self.view, &self.error) {
                (Some(v), _) => format!("{} page(s)", v.page_count()),
                (None, Some(e)) => e.clone(),
                (None, None) => "loading…".to_string(),
            },
        );
        if let Some(e) = &self.error {
            ui.add_space(20.0);
            ui.label(egui::RichText::new("📖 Manual not available").strong());
            ui.label(e);
            return;
        }
        match &mut self.view {
            Some(v) => Facet::ui(v, ui),
            None => {
                ui.label("loading manual…");
            }
        }
    }

    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "dir": self.dir.as_ref().map(|p| p.display().to_string()),
            "loaded": self.is_loaded(),
            // True when the rendered book is the baked-in fallback (no generated
            // book found) — the tab still renders, just the minimal built-in one.
            "builtin": self.builtin,
            "error": self.error,
            "doc": self.view.as_ref().map(|v| v.state_json()),
        })
    }
}

/// The BUILT-IN fallback manual — three valid vector pages baked into the binary
/// so the 📖 Manual tab always renders a book, even on a fresh checkout / lean
/// install where the generated `docs/book-svg` + `docs/book.znippy` (both
/// gitignored, produced on demand by `nornir docs book --format svg`) are absent.
static BUILTIN_PAGES: [&str; 3] = [
    // Page 1 — cover.
    r##"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1130" viewBox="0 0 800 1130">
  <rect width="800" height="1130" fill="#0e1116"/>
  <rect x="0" y="0" width="800" height="8" fill="#5aa9e6"/>
  <text x="64" y="300" font-family="sans-serif" font-size="72" font-weight="bold" fill="#e6edf3">nornir</text>
  <text x="64" y="360" font-family="sans-serif" font-size="26" fill="#9aa7b4">the Norns' loom — release lineage, architecture,</text>
  <text x="64" y="396" font-family="sans-serif" font-size="26" fill="#9aa7b4">benchmarks and CI woven across a Rust workspace</text>
  <line x1="64" y1="440" x2="736" y2="440" stroke="#30363d" stroke-width="2"/>
  <text x="64" y="500" font-family="sans-serif" font-size="22" fill="#c9d1d9">Built-in manual — rendered in the viz 📖 Manual tab</text>
  <text x="64" y="540" font-family="sans-serif" font-size="20" fill="#9aa7b4">via facett-docview (pure-Rust resvg vector viewer).</text>
  <text x="64" y="1060" font-family="sans-serif" font-size="18" fill="#6e7681">Full book: nornir docs book nornir --format svg</text>
</svg>"##,
    // Page 2 — what is nornir.
    r##"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1130" viewBox="0 0 800 1130">
  <rect width="800" height="1130" fill="#ffffff"/>
  <text x="64" y="96" font-family="sans-serif" font-size="34" font-weight="bold" fill="#0e1116">What is nornir</text>
  <line x1="64" y1="118" x2="736" y2="118" stroke="#d0d7de" stroke-width="2"/>
  <text x="64" y="176" font-family="sans-serif" font-size="20" fill="#24292f">nornir reads a Rust workspace and weaves its release lineage,</text>
  <text x="64" y="208" font-family="sans-serif" font-size="20" fill="#24292f">dependency + call graphs, architecture wiring, benchmarks and CI</text>
  <text x="64" y="240" font-family="sans-serif" font-size="20" fill="#24292f">into one Iceberg warehouse, then paints it in an egui viz.</text>
  <text x="64" y="312" font-family="sans-serif" font-size="24" font-weight="bold" fill="#0e1116">Modes</text>
  <text x="80" y="352" font-family="sans-serif" font-size="19" fill="#24292f">• FAT / embedded — one binary owns the local warehouse.</text>
  <text x="80" y="384" font-family="sans-serif" font-size="19" fill="#24292f">• SERVER — a monitored daemon clones + republishes members.</text>
  <text x="80" y="416" font-family="sans-serif" font-size="19" fill="#24292f">• THIN client — the viz talks to a server over gRPC.</text>
  <text x="64" y="488" font-family="sans-serif" font-size="24" font-weight="bold" fill="#0e1116">Viz tabs</text>
  <text x="80" y="528" font-family="sans-serif" font-size="19" fill="#24292f">🧬 nornir · 🧵 Timeline · 🏛 Architecture · 🚇 Metro · 🩸 Bloodstream</text>
  <text x="80" y="560" font-family="sans-serif" font-size="19" fill="#24292f">🗂 Funnel · 🚀 Release · 🧪 Test · 📈 Bench · 🛡 Security</text>
  <text x="80" y="592" font-family="sans-serif" font-size="19" fill="#24292f">⚒ Dwarfs (build + CI forge) · 📦 Holger · 📖 Manual · 🤖 Claude</text>
</svg>"##,
    // Page 3 — the book pipeline / regenerate.
    r##"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1130" viewBox="0 0 800 1130">
  <rect width="800" height="1130" fill="#ffffff"/>
  <text x="64" y="96" font-family="sans-serif" font-size="34" font-weight="bold" fill="#0e1116">The book pipeline</text>
  <line x1="64" y1="118" x2="736" y2="118" stroke="#d0d7de" stroke-width="2"/>
  <text x="64" y="176" font-family="sans-serif" font-size="20" fill="#24292f">This 📖 Manual tab reads docs/book-svg/ (one vector SVG per page +</text>
  <text x="64" y="208" font-family="sans-serif" font-size="20" fill="#24292f">manifest.json) through facett-docview. With the manual-embed feature</text>
  <text x="64" y="240" font-family="sans-serif" font-size="20" fill="#24292f">a packed docs/book.znippy is baked into the binary so the manual</text>
  <text x="64" y="272" font-family="sans-serif" font-size="20" fill="#24292f">survives cargo install.</text>
  <text x="64" y="344" font-family="sans-serif" font-size="24" font-weight="bold" fill="#0e1116">Regenerate the full manual</text>
  <rect x="64" y="368" width="672" height="52" rx="6" fill="#0e1116"/>
  <text x="82" y="402" font-family="monospace" font-size="19" fill="#7ee787">nornir docs book nornir --format svg</text>
  <text x="64" y="470" font-family="sans-serif" font-size="19" fill="#24292f">That assembles every .nornir/*.md + repo *.md into the whole-doc book,</text>
  <text x="64" y="502" font-family="sans-serif" font-size="19" fill="#24292f">writes page-NNNN.svg + manifest.json, and packs docs/book.znippy</text>
  <text x="64" y="534" font-family="sans-serif" font-size="19" fill="#24292f">(pages read O(1) by the lazy viewer). Override with NORNIR_MANUAL_DIR.</text>
</svg>"##,
];

/// The per-user manual data dir: `$HOME/.nornir/manual`. This is where a packed
/// bundle is installed so the manual survives a `cargo install` (which copies
/// ONLY the binary — never sibling data files), and where a bundle is extracted
/// to once.
fn manual_data_dir() -> Option<PathBuf> {
    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".nornir/manual"))
}

/// Resolve a usable `book-svg` page directory. Tries, in order:
///   1. `$NORNIR_MANUAL_DIR` (explicit override, pointed at a `book-svg` dir);
///   2. an already-present page dir — the data dir (`~/.nornir/manual/book-svg`,
///      survives `cargo install`), `<cwd>/docs/book-svg`, or `<exe>/../../docs/book-svg`;
///   3. a packed bundle (`book-svg.nbook` / `book.nbook`) in those same places —
///      extracted ONCE into `~/.nornir/manual/book-svg`, then reused as a dir.
/// `None` if nothing is found.
fn resolve_book_svg_dir() -> Option<PathBuf> {
    if let Some(env) = std::env::var_os("NORNIR_MANUAL_DIR") {
        let p = PathBuf::from(env);
        if p.is_dir() {
            return Some(p);
        }
    }

    // Base locations to look under (in priority order).
    let mut bases: Vec<PathBuf> = Vec::new();
    if let Some(d) = manual_data_dir() {
        bases.push(d);
    }
    if let Ok(cwd) = std::env::current_dir() {
        bases.push(cwd.join("docs"));
    }
    if let Ok(exe) = std::env::current_exe() {
        // target/debug/nornir → repo root is two levels up → its docs/.
        if let Some(root) = exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) {
            bases.push(root.join("docs"));
        }
    }

    // 2. An already-extracted page dir wins (no unpack cost).
    for b in &bases {
        let dir = b.join("book-svg");
        if dir.is_dir() {
            return Some(dir);
        }
    }
    // 3. Else a packed bundle → extract once into the data dir, then use it.
    for b in &bases {
        for name in ["book-svg.nbook", "book.nbook"] {
            let bundle = b.join(name);
            if bundle.is_file() {
                if let Some(dir) = extract_bundle(&bundle) {
                    return Some(dir);
                }
            }
        }
    }
    None
}

/// Extract a packed manual bundle into `~/.nornir/manual/book-svg` (once) and
/// return that dir. Uses the airgap `Archive` engine (TarZstd default; znippy
/// under `airgap-znippy`). `None` on any failure (the caller falls through to the
/// "run `nornir docs book --format svg`" hint).
fn extract_bundle(bundle: &Path) -> Option<PathBuf> {
    use skidbladnir::archive::{Archive, TarZstd};
    let dest = manual_data_dir()?;
    std::fs::create_dir_all(&dest).ok()?;
    // The bundle packs the `book-svg/` dir, so extract_all lands `book-svg/` under dest.
    TarZstd::new().extract_all(bundle, &dest).ok()?;
    let dir = dest.join("book-svg");
    dir.is_dir().then_some(dir)
}

/// Load the manual from `dir`: read `manifest.json` for the title + table of
/// contents, read every `page-NNNN.svg` (sorted), and build a [`DocView`]
/// (which parses the pages multi-core). The TOC uses the manifest's `toc`
/// (`[{title,page}]`) when present; otherwise it is empty (chapter→page offsets
/// are written by a later `docs book` revision — the viewer still scrolls/zooms
/// every page without them).
fn load_manual(dir: &Path) -> Result<DocView, String> {
    let manifest_path = dir.join("manifest.json");
    let (title, toc) = match std::fs::read_to_string(&manifest_path) {
        Ok(s) => parse_manifest(&s),
        Err(_) => ("manual".to_string(), Vec::new()),
    };

    // Collect page-*.svg in filename order (zero-padded → lexical == numeric).
    let mut svg_paths: Vec<PathBuf> = std::fs::read_dir(dir)
        .map_err(|e| format!("read {}: {e}", dir.display()))?
        .filter_map(|e| e.ok().map(|e| e.path()))
        .filter(|p| {
            p.extension().and_then(|x| x.to_str()) == Some("svg")
                && p.file_name()
                    .and_then(|n| n.to_str())
                    .is_some_and(|n| n.starts_with("page-"))
        })
        .collect();
    svg_paths.sort();
    if svg_paths.is_empty() {
        return Err(format!("no page-*.svg in {}", dir.display()));
    }

    // Read the bytes, then build through from_svgs so the parse runs multi-core.
    let bytes: Vec<Vec<u8>> = svg_paths.iter().filter_map(|p| std::fs::read(p).ok()).collect();
    Ok(DocView::from_svgs(title, bytes, toc))
}

/// Pull the title + a `(chapter_title, 0-based page)` TOC out of the manifest.
/// Accepts either a future `toc: [{title,page}]` (page-accurate) or the current
/// flat `chapters: [string]` (mapped to no offset → an empty TOC, so the sidebar
/// doesn't mislead by jumping every entry to page 1).
fn parse_manifest(s: &str) -> (String, Vec<(String, usize)>) {
    let v: serde_json::Value = match serde_json::from_str(s) {
        Ok(v) => v,
        Err(_) => return ("manual".to_string(), Vec::new()),
    };
    let title = v
        .get("title")
        .and_then(|t| t.as_str())
        .unwrap_or("manual")
        .to_string();
    let toc = v
        .get("toc")
        .and_then(|t| t.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|e| {
                    let title = e.get("title")?.as_str()?.to_string();
                    let page = e.get("page")?.as_u64()? as usize;
                    Some((title, page))
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    (title, toc)
}

// ── embedded manual (feature `manual-embed`) ─────────────────────────────────
// The packed archive baked into the binary by build.rs (real `docs/book.znippy`
// if it existed at build time, else a 0-byte placeholder). Travels with `cargo
// install`. `znippy::ZnippyArchive::open` reads a path, so we pluck it out to the
// data dir once rather than seek into rodata.
#[cfg(feature = "manual-embed")]
static EMBEDDED_MANUAL: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/book.znippy"));

/// Write the embedded archive to `~/.nornir/manual/book.znippy` once (skipped
/// when empty, or already present at the same size). After this, the normal
/// znippy resolver finds it on disk. Best-effort.
#[cfg(feature = "manual-embed")]
fn pluck_out_embedded() {
    if EMBEDDED_MANUAL.is_empty() {
        return; // no manual was baked in — fall back to on-disk resolution
    }
    let Some(dir) = manual_data_dir() else { return };
    let path = dir.join("book.znippy");
    // Already plucked out at this exact size? skip the rewrite.
    if std::fs::metadata(&path).map(|m| m.len() as usize == EMBEDDED_MANUAL.len()).unwrap_or(false) {
        return;
    }
    if std::fs::create_dir_all(&dir).is_ok() {
        if let Err(e) = std::fs::write(&path, EMBEDDED_MANUAL) {
            eprintln!("⚠ manual: pluck-out to {} failed (non-fatal): {e}", path.display());
        }
    }
}

// ── znippy-backed lazy manual (feature `manual-znippy`) ──────────────────────
// A `book.znippy` archive serves each page via an O(1) `extract_file`, so the
// facett-docview lazy viewer decompresses + parses ONLY the visible band — a
// 247 MB manual opens instantly from a ~10 MB archive. The archive read lives
// here (nornir); facett-docview stays storage-agnostic (it only sees `PageSource`).
#[cfg(feature = "manual-znippy")]
use znippy_source::{load_manual_znippy, resolve_book_znippy};

#[cfg(feature = "manual-znippy")]
mod znippy_source {
    use super::*;
    use facett_docview::PageSource;
    use znippy_common::{ZnippyArchive, ZnippyReader};

    /// A [`PageSource`] backed by a znippy archive: `page_svg(i)` is an O(1)
    /// `extract_file("page-NNNN.svg")` (index lookup → pread → decompress). The
    /// archive fd is shared + positioned-read, so the viewer's parallel band
    /// fetch is safe.
    struct ZnippyManual {
        archive: ZnippyArchive,
        count: usize,
    }

    impl PageSource for ZnippyManual {
        fn page_count(&self) -> usize {
            self.count
        }
        fn page_svg(&self, i: usize) -> Option<Vec<u8>> {
            self.archive.extract_file(&format!("page-{:04}.svg", i + 1)).ok()
        }
    }

    fn is_page(name: &str) -> bool {
        let f = name.rsplit('/').next().unwrap_or(name);
        f.starts_with("page-") && f.ends_with(".svg")
    }

    /// Open `book.znippy` → a LAZY [`DocView`] over it. Title + TOC come from the
    /// archive's embedded `manifest.json` (best-effort).
    pub(super) fn load_manual_znippy(path: &Path) -> Result<DocView, String> {
        let archive =
            ZnippyArchive::open(path).map_err(|e| format!("open {}: {e:#}", path.display()))?;
        let files = archive.list_files().map_err(|e| format!("list {}: {e:#}", path.display()))?;
        let count = files.iter().filter(|f| is_page(f)).count();
        if count == 0 {
            return Err(format!("no page-*.svg in {}", path.display()));
        }
        let (title, toc) = archive
            .extract_file("manifest.json")
            .ok()
            .map(|b| parse_manifest(&String::from_utf8_lossy(&b)))
            .unwrap_or_else(|| ("manual".to_string(), Vec::new()));
        Ok(DocView::from_source(title, Box::new(ZnippyManual { archive, count }), toc))
    }

    /// Find a `book.znippy`: `$NORNIR_MANUAL_DIR` (the archive file itself, or a
    /// dir holding it), the data dir, `<cwd>/docs`, `<exe>/../../docs`.
    pub(super) fn resolve_book_znippy() -> Option<PathBuf> {
        let mut cands: Vec<PathBuf> = Vec::new();
        if let Some(env) = std::env::var_os("NORNIR_MANUAL_DIR") {
            let p = PathBuf::from(env);
            if p.is_file() {
                return Some(p);
            }
            cands.push(p.join("book.znippy"));
        }
        if let Some(d) = manual_data_dir() {
            cands.push(d.join("book.znippy"));
        }
        if let Ok(cwd) = std::env::current_dir() {
            cands.push(cwd.join("docs/book.znippy"));
        }
        if let Ok(exe) = std::env::current_exe() {
            if let Some(root) = exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) {
                cands.push(root.join("docs/book.znippy"));
            }
        }
        cands.into_iter().find(|p| p.is_file())
    }
}