nornir 0.4.2

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Launcher for the Urðr Threads visualizer.
//!
//! Usage (embedded — opens the warehouse directly):
//!     cargo run --release --features viz --bin urdr-threads \
//!         -- --warehouse /tmp/nornir-demo-warehouse --workspace norninr-demo
//!
//! Usage (thin client — reads from a running nornir-server over gRPC):
//!     NORNIR_SERVER=http://127.0.0.1:7878 NORNIR_SERVER_TOKEN=… \
//!     cargo run --release --features viz --bin urdr-threads -- --workspace my-ws
//! `NORNIR_WORKSPACE`, if set, selects the served workspace via the gRPC header.

use std::path::PathBuf;

use anyhow::Result;
use clap::Parser;

use nornir::viz::UrdrThreadsApp;

#[derive(Parser)]
#[command(name = "urdr-threads")]
#[command(about = "Time-travel visualizer for the Urðr warehouse", long_about = None)]
struct Cli {
    /// Path to the Iceberg warehouse root directory.
    #[arg(long, default_value = "/tmp/nornir-demo-warehouse")]
    warehouse: PathBuf,

    /// Workspace name (matches the `[workspace] name =` from the
    /// descriptor used at release time).
    #[arg(long, default_value = "norninr-demo")]
    workspace: String,

    /// Optional path to `nornir.toml`. If omitted we try to discover one
    /// from the current directory; if that also fails the Knowledge tab
    /// just renders with no repos.
    #[arg(long)]
    config: Option<PathBuf>,
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    let (workspace_root, repos) = match cli.config.as_deref() {
        Some(p) => match nornir::config::load_explicit(p) {
            Ok(loaded) => (
                loaded.workspace_root,
                loaded.nornir.repo.keys().cloned().collect(),
            ),
            Err(_) => (PathBuf::new(), Vec::new()),
        },
        None => match std::env::current_dir()
            .ok()
            .and_then(|cwd| nornir::config::discover(&cwd).ok())
        {
            Some(loaded) => (
                loaded.workspace_root,
                loaded.nornir.repo.keys().cloned().collect(),
            ),
            None => (PathBuf::new(), Vec::new()),
        },
    };

    let native_options = eframe::NativeOptions {
        viewport: eframe::egui::ViewportBuilder::default()
            .with_inner_size([1200.0, 700.0])
            .with_title("Urðr Threads — nornir time machine"),
        ..Default::default()
    };
    // Thin-client mode: `NORNIR_SERVER` set → read the timeline from a running
    // nornir-server over gRPC; otherwise open the local warehouse directly.
    let server = std::env::var("NORNIR_SERVER").ok().filter(|s| !s.is_empty());
    // `NORNIR_WORKSPACE` (the same selector the CLI/MCP use) overrides --workspace
    // so the viz targets a chosen served workspace (e.g. a monitored one). It is
    // also sent as the `nornir-workspace` gRPC header (see viz::remote).
    let workspace = std::env::var("NORNIR_WORKSPACE")
        .ok()
        .filter(|s| !s.is_empty())
        .unwrap_or(cli.workspace);

    eframe::run_native(
        "Urðr Threads",
        native_options,
        Box::new(move |_cc| {
            let app = match server {
                Some(endpoint) => {
                    let token = std::env::var("NORNIR_SERVER_TOKEN").unwrap_or_default();
                    UrdrThreadsApp::with_remote(endpoint, token, workspace, workspace_root, repos)
                }
                None => UrdrThreadsApp::with_repos(cli.warehouse, workspace, workspace_root, repos),
            };
            Ok(Box::new(app))
        }),
    )
    .map_err(|e| anyhow::anyhow!("eframe error: {e}"))?;
    Ok(())
}