nornir 0.4.49

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 📖 **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>,
}

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

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;
    }

    /// 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),
                    Err(e) => self.error = Some(e),
                }
            }
            None => {
                self.error = Some(
                    "no docs/book-svg/ found — run `nornir docs book --format svg` \
                     (or set NORNIR_MANUAL_DIR) to generate the manual"
                        .to_string(),
                );
            }
        }
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        self.load();
        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(),
            "error": self.error,
            "doc": self.view.as_ref().map(|v| v.state_json()),
        })
    }
}

/// 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 nornir_airgap::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())
    }
}