nornir 0.4.2

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Server-global **workspace registry** — the persistent index of every
//! workspace the `nornir` server tracks.
//!
//! This is the index the self-sync poll loop updates, `nornir workspace ls`
//! reads, and (next phase) the `Workspaces.*` gRPC service / viz picker query.
//! It is distinct from a *warehouse* (one per workspace, under `builds/`) and
//! from a [`crate::workspace::WorkspaceDescriptor`] (the per-workspace
//! `nornir-workspace.toml` that lists members) — the registry just *indexes*
//! those, keyed by workspace name.
//!
//! **Storage:** a single redb file at `<server.root>/registry.redb`. redb
//! stores **bytes**, not structs, so each row is `workspace_name (&str) →`
//! the [`Workspace`] record serialized with serde_json. The server is the sole
//! writer, matching the redb/iceberg single-writer discipline used for the
//! warehouse.

use std::path::Path;

use anyhow::{Context, Result};
use redb::{Database, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};

/// `name → serde_json(Workspace)`. Bytes in, bytes out.
const TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("workspaces");

/// How a workspace's source is provided.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
    /// Server polls member git URLs and republishes (server-user `nornir`).
    Monitored,
    /// A thin client pushes its working-tree source in; server computes.
    Pushed,
    /// Sources live outside (fat-style external checkout); only `builds/` is
    /// server-owned and `git/` stays empty.
    External,
}

impl Mode {
    pub fn as_str(&self) -> &'static str {
        match self {
            Mode::Monitored => "monitored",
            Mode::Pushed => "pushed",
            Mode::External => "external",
        }
    }
    /// Parse a mode string; anything unrecognized ⇒ [`Mode::Pushed`].
    pub fn parse(s: &str) -> Self {
        match s {
            "monitored" => Mode::Monitored,
            "external" => Mode::External,
            _ => Mode::Pushed,
        }
    }
}

/// Per-member sync state (meaningful in [`Mode::Monitored`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemberState {
    pub name: String,
    /// Git URL fetched/polled (mirrors `RepoSpec.git`).
    pub remote: String,
    /// Tracked ref/branch; empty ⇒ `main`.
    pub git_ref: String,
    /// Last SHA seen via `git ls-remote`; empty ⇒ never polled.
    pub last_seen_sha: String,
    /// RFC3339 of the last successful fetch; empty ⇒ never.
    pub last_synced: String,
    /// `"ok"` | `"fetching"` | `"error: …"` | "".
    pub sync_state: String,
}

/// One registry row: a workspace the server tracks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
    pub name: String,
    pub mode: Mode,
    /// Seed: a local path or git URL to the workspace's `nornir-workspace.toml`.
    pub descriptor: String,
    /// Poll interval for monitored workspaces, e.g. `"60s"`; empty ⇒ default.
    pub poll: String,
    /// Iceberg snapshot id the warehouse currently publishes; empty ⇒ none yet.
    pub current_snapshot: String,
    pub members: Vec<MemberState>,
    pub created_at: String,
    pub updated_at: String,
}

impl Workspace {
    /// Build a record, seeding members from a local `nornir-workspace.toml`
    /// descriptor when it exists (each repo's `git` + `branch` →
    /// `remote`/`git_ref`; `path`-only members get an empty remote). A git-URL
    /// descriptor (not a local path) yields an empty member list until first
    /// fetch. Pass `created_at` to preserve it across a re-register.
    pub fn new(
        name: String,
        descriptor: String,
        mode: Mode,
        poll: String,
        created_at: Option<String>,
    ) -> Self {
        let mut members = Vec::new();
        let dpath = std::path::Path::new(&descriptor);
        if dpath.exists() {
            if let Ok(desc) = crate::workspace::WorkspaceDescriptor::load(dpath) {
                for (mname, spec) in &desc.repos {
                    members.push(MemberState {
                        name: mname.clone(),
                        remote: spec.git.clone().unwrap_or_default(),
                        git_ref: spec.branch.clone().unwrap_or_default(),
                        ..Default::default()
                    });
                }
            }
        }
        let now = chrono::Utc::now().to_rfc3339();
        Workspace {
            name,
            mode,
            descriptor,
            poll,
            current_snapshot: String::new(),
            members,
            created_at: created_at.unwrap_or_else(|| now.clone()),
            updated_at: now,
        }
    }
}

/// The redb-backed registry. Open once; cheap to clone-free reuse.
pub struct Registry {
    db: Database,
}

impl Registry {
    /// Open (creating if absent) the registry at `<root>/registry.redb`.
    pub fn open(root: &Path) -> Result<Self> {
        std::fs::create_dir_all(root)
            .with_context(|| format!("create registry root {}", root.display()))?;
        let path = root.join("registry.redb");
        let db = Database::create(&path)
            .with_context(|| format!("open {}", path.display()))?;
        // Materialize the table so first-time reads don't fail.
        let w = db.begin_write()?;
        {
            let _ = w.open_table(TABLE)?;
        }
        w.commit()?;
        Ok(Self { db })
    }

    /// Insert or replace a workspace row.
    pub fn upsert(&self, ws: &Workspace) -> Result<()> {
        let bytes = serde_json::to_vec(ws).context("encode workspace record")?;
        let w = self.db.begin_write()?;
        {
            let mut t = w.open_table(TABLE)?;
            t.insert(ws.name.as_str(), bytes.as_slice())?;
        }
        w.commit()?;
        Ok(())
    }

    /// Fetch one workspace by name.
    pub fn get(&self, name: &str) -> Result<Option<Workspace>> {
        let r = self.db.begin_read()?;
        let t = r.open_table(TABLE)?;
        match t.get(name)? {
            Some(v) => Ok(Some(
                serde_json::from_slice(v.value()).context("decode workspace record")?,
            )),
            None => Ok(None),
        }
    }

    /// All workspaces, in key (name) order.
    pub fn list(&self) -> Result<Vec<Workspace>> {
        let r = self.db.begin_read()?;
        let t = r.open_table(TABLE)?;
        let mut out = Vec::new();
        for row in t.iter()? {
            let (_k, v) = row?;
            out.push(serde_json::from_slice(v.value()).context("decode workspace record")?);
        }
        Ok(out)
    }

    /// Remove a workspace; returns whether it existed.
    pub fn remove(&self, name: &str) -> Result<bool> {
        let w = self.db.begin_write()?;
        let existed = {
            let mut t = w.open_table(TABLE)?;
            let removed = t.remove(name)?.is_some();
            removed
        };
        w.commit()?;
        Ok(existed)
    }
}

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

    fn rec(name: &str, mode: Mode) -> Workspace {
        Workspace {
            name: name.into(),
            mode,
            descriptor: "/tmp/ws/nornir-workspace.toml".into(),
            poll: "60s".into(),
            current_snapshot: String::new(),
            members: vec![MemberState {
                name: "holger".into(),
                remote: "git@codeberg.org:nordisk/holger".into(),
                git_ref: "main".into(),
                ..Default::default()
            }],
            created_at: "2026-06-08T00:00:00Z".into(),
            updated_at: "2026-06-08T00:00:00Z".into(),
        }
    }

    #[test]
    fn roundtrip_upsert_get_list_remove() {
        let dir = std::env::temp_dir().join(format!("nornir-reg-{}", std::process::id()));
        let reg = Registry::open(&dir).unwrap();

        assert!(reg.get("a").unwrap().is_none());
        reg.upsert(&rec("a", Mode::Monitored)).unwrap();
        reg.upsert(&rec("b", Mode::Pushed)).unwrap();

        let a = reg.get("a").unwrap().unwrap();
        assert_eq!(a.mode, Mode::Monitored);
        assert_eq!(a.members[0].remote, "git@codeberg.org:nordisk/holger");

        let all = reg.list().unwrap();
        assert_eq!(all.len(), 2);
        assert_eq!(all[0].name, "a"); // key order

        assert!(reg.remove("a").unwrap());
        assert!(!reg.remove("a").unwrap());
        assert_eq!(reg.list().unwrap().len(), 1);

        std::fs::remove_dir_all(&dir).ok();
    }
}