//! 🎬 **Demo mode** — a self-running guided tour of holger-server-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-server-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-server-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);
}
}