holger-ui 0.1.4

Operator/admin UI for holger over the HolgerObject core API โ€” egui via facett, embedded (LocalHolger, direct core calls) or remote (RemoteHolger gRPC).
//! ๐ŸŽฌ **Demo mode** โ€” a self-running guided tour of holger-ui.
//!
//! Two halves:
//!
//! 1. [`fill_demo_store`] FILLS a dev znippy store with REAL packages pulled from
//!    crates.io via the existing **agent/world connector**
//!    ([`world_connector::WorldConnector`]) โ€” bounded (a curated ~12-package set),
//!    SSRF-guarded by the connector itself, and **offline-safe**: any download that
//!    fails (no network) falls back to a small bundled synthetic-crate fixture, so
//!    the demo always opens a populated server. Packages are written into two
//!    writable [`RustRepoZnippy`] archives so the repo roster + 3D graph have real
//!    rows/nodes to show.
//!
//! 2. [`DemoWalk`] is the scripted **walk**: a deterministic, time-INJECTED list of
//!    beats that drive the real [`HolgerUiApp`](crate::app::HolgerUiApp) through its
//!    views โ€” the repo roster, Browse/Archive/Artifact, the 3D dependency Graph
//!    (with node selection so the neon glow/shockwave/dim lights up), every Tools
//!    pane (incl. the Mรณรฐguรฐr security verdict graph), and look re-themes โ€” with a
//!    pause between beats so the egui animations (spinning logo, Graph3D, ice decor,
//!    look transitions) actually render.
//!
//! FC-7: the walk never reads a wall clock โ€” [`DemoWalk::due`] takes the elapsed-ms
//! as an argument. The live app feeds it egui's frame time (`ui.input(|i| i.time)`),
//! a headless test feeds synthetic values, so the tour is fully deterministic.

use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

use anyhow::Result;
use traits::{ArtifactId, ConnectorTrait, RepositoryBackendTrait};

use crate::app::{Tab, ToolsFn};

/// The curated set of real, popular crates the demo pulls โ€” bounded so a demo
/// launch is quick and polite to crates.io. Split across two repos so the roster
/// and graph have structure. `(name, version)`.
const CURATED: &[(&str, &str)] = &[
    // repo A โ€” "crates-cache"
    ("serde", "1.0.210"),
    ("serde_json", "1.0.128"),
    ("tokio", "1.40.0"),
    ("anyhow", "1.0.89"),
    ("clap", "4.5.20"),
    ("regex", "1.11.0"),
    // repo B โ€” "crates-sparring"
    ("rand", "0.8.5"),
    ("log", "0.4.22"),
    ("itertools", "0.13.0"),
    ("bytes", "1.7.2"),
    ("once_cell", "1.20.2"),
    ("thiserror", "1.0.64"),
];

/// Per-download budget โ€” keep a no-network launch snappy (the connector falls back
/// to the synthetic fixture as soon as a download times out).
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(8);

/// One package that landed in the demo store.
#[derive(Clone, Debug)]
pub struct DemoPackage {
    pub repo: String,
    pub name: String,
    pub version: String,
    pub bytes: usize,
    /// `true` = the real `.crate` was pulled from crates.io; `false` = the
    /// offline-safe synthetic fixture was used.
    pub real: bool,
}

/// The result of filling the demo store: the in-process holger handle the UI runs
/// against, plus a report of what landed (and whether the network was reached).
pub struct DemoFill {
    /// The two repo names, in roster order.
    pub repos: Vec<String>,
    /// Every package written, across both repos.
    pub packages: Vec<DemoPackage>,
    /// `true` if at least one real package was pulled from crates.io.
    pub online: bool,
    /// The wired routes (repo name โ†’ backend) for building a `LocalHolger`.
    pub routes: Vec<(String, Arc<dyn RepositoryBackendTrait>)>,
}

impl DemoFill {
    /// How many packages were pulled for real vs. faked offline.
    pub fn real_count(&self) -> usize {
        self.packages.iter().filter(|p| p.real).count()
    }

    /// Build the in-process `LocalHolger` the demo UI runs against (no gRPC, no
    /// network) โ€” wraps the filled writable repos in a `FastRoutes` route table.
    pub fn local_holger(&self) -> server_lib::LocalHolger {
        server_lib::LocalHolger::new(server_lib::exposed::fast_routes::FastRoutes::new(
            self.routes.clone(),
        ))
    }
}

/// A small, deterministic synthetic `.crate`-ish payload used as the offline-safe
/// fixture when a real download is unavailable. Not a valid gzip archive (the
/// store keeps raw member bytes; the demo never decompresses them), but
/// recognizable, non-empty, and uniquely named so the roster/graph look real.
fn synthetic_crate(name: &str, version: &str) -> Vec<u8> {
    let header = format!(
        "# holger demo offline fixture\n# crate: {name}\n# version: {version}\n# (real download unavailable โ€” bundled fixture)\n"
    );
    let mut bytes = header.into_bytes();
    // Pad to a plausible small-crate size so archive stats look real.
    let pad = format!("// {name}-{version} padding\n").repeat(64);
    bytes.extend_from_slice(pad.as_bytes());
    bytes
}

/// Download one real crate from crates.io via the world connector (SSRF-guarded
/// by the connector). Returns `None` on any failure so the caller can fall back to
/// the offline fixture.
async fn try_download(
    conn: &world_connector::WorldConnector,
    name: &str,
    version: &str,
) -> Option<Vec<u8>> {
    let pkg = world_connector::Package {
        name: name.to_string(),
        version: version.to_string(),
        source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()),
        checksum: None,
        dependencies: None,
    };
    let assets = world_connector::packages_to_assets(std::slice::from_ref(&pkg));
    let asset = assets.into_iter().next()?;
    match tokio::time::timeout(DOWNLOAD_TIMEOUT, conn.download_asset(&asset)).await {
        Ok(Ok(bytes)) if !bytes.is_empty() => Some(bytes),
        _ => None,
    }
}

/// FILL a dev znippy store with the curated package set. Writes two on-disk
/// writable znippy archives under `store_dir` and returns the wired routes + a
/// report. `allow_network` gates the real crates.io pull โ€” `false` (or any
/// download failure) uses the bundled synthetic fixture, so the demo is fully
/// offline-safe. Pure of egui; reusable from the CLI or a test.
pub fn fill_demo_store(store_dir: &Path, allow_network: bool) -> Result<DemoFill> {
    std::fs::create_dir_all(store_dir)?;
    let repo_a = "crates-cache".to_string();
    let repo_b = "crates-sparring".to_string();
    let backend_a =
        znippy_rust_repo::RustRepoZnippy::with_writable_archive(repo_a.clone(), store_dir.join("crates-cache.znippy"));
    let backend_b = znippy_rust_repo::RustRepoZnippy::with_writable_archive(
        repo_b.clone(),
        store_dir.join("crates-sparring.znippy"),
    );

    // A short-lived runtime + connector only when we're allowed to reach the net.
    let net = if allow_network {
        match (
            tokio::runtime::Builder::new_current_thread().enable_all().build(),
            world_connector::WorldConnector::new(),
        ) {
            (Ok(rt), Ok(conn)) => Some((rt, conn)),
            _ => None,
        }
    } else {
        None
    };

    let mut packages = Vec::new();
    let mut online = false;
    for (i, (name, version)) in CURATED.iter().enumerate() {
        let (repo_name, backend): (&str, &znippy_rust_repo::RustRepoZnippy) =
            if i < 6 { (&repo_a, &backend_a) } else { (&repo_b, &backend_b) };

        let (bytes, real) = match &net {
            Some((rt, conn)) => match rt.block_on(try_download(conn, name, version)) {
                Some(b) => {
                    online = true;
                    (b, true)
                }
                None => (synthetic_crate(name, version), false),
            },
            None => (synthetic_crate(name, version), false),
        };

        let id = ArtifactId { namespace: None, name: name.to_string(), version: version.to_string() };
        backend.put(&id, &bytes)?;
        packages.push(DemoPackage {
            repo: repo_name.to_string(),
            name: name.to_string(),
            version: version.to_string(),
            bytes: bytes.len(),
            real,
        });
    }

    let routes: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
        (repo_a.clone(), Arc::new(backend_a) as Arc<dyn RepositoryBackendTrait>),
        (repo_b.clone(), Arc::new(backend_b) as Arc<dyn RepositoryBackendTrait>),
    ];
    Ok(DemoFill { repos: vec![repo_a, repo_b], packages, online, routes })
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ the walk โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// One scripted action the demo applies to the live app โ€” each maps to the exact
/// `pub` hook a human click would run (`select_repo`, `select_graph_node`,
/// `set_look_preset`, or a tab/tool switch).
#[derive(Clone, Copy, Debug)]
pub(crate) enum DemoAction {
    Goto(Tab),
    SelectRepo(usize),
    SelectGraphNode(usize),
    SelectTool(ToolsFn),
    SecurityGraph(bool),
    Look(usize),
}

/// One beat of the tour: at `at_ms`, apply `action` and log `label`.
#[derive(Clone, Debug)]
pub(crate) struct DemoStep {
    pub at_ms: u64,
    pub label: String,
    pub action: DemoAction,
}

/// The scripted-tour driver โ€” pure + time-injected (FC-7). Holds the beats and a
/// cursor; emits each due beat once. The app wraps it with egui frame time; a test
/// feeds synthetic elapsed-ms.
pub struct DemoWalk {
    steps: Vec<DemoStep>,
    next: usize,
}

impl DemoWalk {
    /// The default showcase tour: walk every holger-ui view โ€” the repo roster (with
    /// row selections), Browse/Archive/Artifact, the 3D dependency Graph (selecting
    /// nodes so the glow/shockwave plays), every Tools pane (incl. the Mรณรฐguรฐr
    /// security verdict graph), Upload, and two look re-themes โ€” returning home.
    /// `beat_ms` is the pause between beats so each transition's motion renders.
    pub fn tour(beat_ms: u64) -> Self {
        let mut b = WalkBuilder::new(beat_ms);
        b.go(Tab::Repos, "Repos โ€” the repository roster");
        b.act(DemoAction::SelectRepo(0), "select crates-cache");
        b.act(DemoAction::SelectRepo(1), "select crates-sparring");
        b.go(Tab::Browse, "Browse โ€” artifacts in the repo");
        b.go(Tab::Archive, "Archive โ€” raw znippy member files");
        b.go(Tab::Artifact, "Artifact โ€” fetch a package by id");
        b.go(Tab::Graph, "Graph โ€” 3D serverโ†’repoโ†’artifact graph");
        b.act(DemoAction::SelectGraphNode(1), "light a node (neon glow/shockwave)");
        b.act(DemoAction::SelectGraphNode(2), "light another subgraph");
        b.go(Tab::Helix, "Helix โ€” time-helix dependency DAG (traverse along time)");
        b.act(DemoAction::Look(1), "๐ŸŽจ re-theme โ†’ light");
        b.go(Tab::Tools, "Tools โ€” operator functions");
        b.act(DemoAction::SelectTool(ToolsFn::HolgerDs), "Tool 1 ยท holger-ds");
        b.act(DemoAction::SelectTool(ToolsFn::Migrate), "Tool 2 ยท migrate / suck");
        b.act(DemoAction::SelectTool(ToolsFn::Security), "Tool 3 ยท security ยท Mรณรฐguรฐr");
        b.act(DemoAction::SecurityGraph(true), "Mรณรฐguรฐr โ€” dependency verdict graph");
        b.act(DemoAction::SelectTool(ToolsFn::Deploy), "Tool 4 ยท deploy ยท Skรญรฐblaรฐnir");
        b.act(DemoAction::SelectTool(ToolsFn::Airgap), "Tool 5 ยท airgap populate (plan)");
        b.act(DemoAction::Look(2), "๐ŸŽจ re-theme โ†’ device");
        b.go(Tab::Upload, "Upload โ€” writable ingest");
        b.go(Tab::Graph, "Graph โ€” back to the wiring graph");
        b.act(DemoAction::SelectGraphNode(0), "light the server anchor");
        b.act(DemoAction::Look(0), "๐ŸŽจ re-theme โ†’ dark");
        b.go(Tab::Status, "โ†ฉ Status โ€” home");
        DemoWalk { steps: b.steps, next: 0 }
    }

    /// Total beats.
    pub fn len(&self) -> usize {
        self.steps.len()
    }
    pub fn is_empty(&self) -> bool {
        self.steps.is_empty()
    }
    /// `true` once every beat in this pass has been emitted.
    pub fn is_done(&self) -> bool {
        self.next >= self.steps.len()
    }
    /// The `at_ms` of the last beat โ€” one pass's run length.
    pub fn duration_ms(&self) -> u64 {
        self.steps.last().map(|s| s.at_ms).unwrap_or(0)
    }
    /// Reset the cursor for a looped restart on a rebased clock.
    pub fn rewind(&mut self) {
        self.next = 0;
    }

    /// The distinct tabs the tour visits, in first-seen order โ€” for the headless
    /// coverage assertion.
    #[allow(dead_code)] // used by the in-crate unit test; the live walk reads tabs via state_json
    pub(crate) fn tabs(&self) -> Vec<Tab> {
        let mut seen: Vec<Tab> = Vec::new();
        for s in &self.steps {
            if let DemoAction::Goto(t) = s.action {
                if !seen.contains(&t) {
                    seen.push(t);
                }
            }
        }
        seen
    }

    /// **FC-7, time injected.** Return the beat due at `elapsed_ms` (advancing the
    /// cursor), or `None` if nothing is due yet / the pass is done. At most one
    /// beat per call.
    pub(crate) fn due(&mut self, elapsed_ms: u64) -> Option<DemoStep> {
        let step = self.steps.get(self.next)?;
        if elapsed_ms >= step.at_ms {
            let out = step.clone();
            self.next += 1;
            Some(out)
        } else {
            None
        }
    }
}

/// Assigns each beat a cumulative `at_ms` so the script reads as a sequence.
struct WalkBuilder {
    beat_ms: u64,
    t: u64,
    steps: Vec<DemoStep>,
}

impl WalkBuilder {
    fn new(beat_ms: u64) -> Self {
        let beat_ms = beat_ms.max(1);
        Self { beat_ms, t: beat_ms, steps: Vec::new() }
    }
    fn act(&mut self, action: DemoAction, label: &str) {
        self.steps.push(DemoStep { at_ms: self.t, label: label.to_string(), action });
        self.t += self.beat_ms;
    }
    fn go(&mut self, tab: Tab, label: &str) {
        self.act(DemoAction::Goto(tab), label);
    }
}

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

    #[test]
    fn tour_walks_every_tab() {
        let w = DemoWalk::tour(1000);
        // All 8 holger-ui tabs are visited.
        assert!(w.tabs().len() >= 7, "tour visits โ‰ฅ7 distinct tabs, got {}", w.tabs().len());
        assert!(w.len() >= 15, "the tour is a real script, got {} beats", w.len());
    }

    #[test]
    fn due_is_time_injected_and_fires_once() {
        let mut w = DemoWalk::tour(500);
        let n = w.len();
        assert!(w.due(0).is_none(), "nothing due before the first beat");
        let mut fired = 0usize;
        for ms in (0..=w.duration_ms() + 10).step_by(25) {
            while w.due(ms).is_some() {
                fired += 1;
            }
        }
        assert_eq!(fired, n, "every beat fires exactly once across one pass");
        assert!(w.is_done());
    }

    #[test]
    fn offline_fill_is_safe_and_populated() {
        let dir = std::env::temp_dir().join(format!("holger-demo-fill-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let fill = fill_demo_store(&dir, false).expect("offline fill");
        assert_eq!(fill.repos.len(), 2, "two repos");
        assert_eq!(fill.packages.len(), CURATED.len(), "every curated pkg landed");
        assert!(!fill.online, "offline fill never reaches the network");
        assert_eq!(fill.real_count(), 0, "offline โ‡’ all synthetic");
        // The bytes are actually fetchable back out of the writable store.
        let (_, backend) = &fill.routes[0];
        let id = ArtifactId { namespace: None, name: "serde".into(), version: "1.0.210".into() };
        assert!(backend.fetch(&id).expect("fetch").is_some(), "serde is served from the store");
        let _ = std::fs::remove_dir_all(&dir);
    }
}