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

nordisk/nornir

src/viz/manual_tab.rs

//! 📖 **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">⚒ Dwarves (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()
        }
        /// Plain-text search layer: the SVG pages are outlined glyph paths with no
        /// `<text>`, so search reads the `page-NNNN.txt` sidecar the packer emitted
        /// (one per page that has recoverable text). O(1) `extract_file`; a page
        /// with no sidecar returns `None` and search honestly skips it.
        fn page_text(&self, i: usize) -> Option<String> {
            let bytes = self
                .archive
                .extract_file(&format!("page-{:04}.txt", i + 1))
                .ok()?;
            let s = String::from_utf8_lossy(&bytes).trim().to_string();
            if s.is_empty() { None } else { Some(s) }
        }
    }

    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())
    }
}