src/registry.rs
//! 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, PathBuf};
use anyhow::{Context, Result, bail};
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");
/// Validate that a workspace name is a single safe path segment before it is
/// ever joined onto the registry root and handed to `remove_dir_all`/`open`.
///
/// A workspace name keys the registry **and** names an on-disk dir
/// (`<root>/<name>/builds`), so a name like `..`, `/etc`, or `a/b` could make the
/// purge path escape the registry root and `remove_dir_all` an arbitrary
/// directory. Reject empty names, anything containing a path separator (`/` or
/// `\`), the parent ref `..`, the current-dir ref `.`, and absolute / rooted
/// names. This is the data-loss guard the nordisk wipe taught us to add.
pub fn validate_ws_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("workspace name must not be empty");
}
if name == "." || name == ".." {
bail!("workspace name must not be `.` or `..`");
}
if name.contains('/') || name.contains('\\') {
bail!("workspace name `{name}` must be a single path segment (no `/` or `\\`)");
}
// Defense in depth: reject anything the OS would treat as more than one
// normal component (absolute paths, drive prefixes, `..` components).
let p = Path::new(name);
let mut comps = p.components();
match (comps.next(), comps.next()) {
(Some(std::path::Component::Normal(c)), None) if c == name => Ok(()),
_ => bail!("workspace name `{name}` is not a single safe path segment"),
}
}
/// 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",
}
}
/// Does the server open a SERVED warehouse handle for a row in this mode —
/// the ONE writer of that decision, shared by boot, runtime-register and the
/// tests (never re-implemented at a call site).
///
/// Mode gates **fetching**: only [`Mode::Monitored`] has git remotes the poll
/// loop pulls. It must never gate **reading** — all three modes own a
/// server-computed `<root>/<name>/builds` warehouse, so all three are served:
///
/// * `Monitored` — the server clones and republishes into `builds/`.
/// * `Pushed` — a thin client pushes source in and *the server computes*;
/// if it were not served, a pushed workspace could never be read at all,
/// and `republish_served` would bail before writing the result.
/// * `External` — sources live outside, but `builds/` is explicitly
/// server-owned, which is precisely the thing being served.
///
/// The match is exhaustive on purpose: a new mode has to state its answer
/// here rather than inherit a silent `false`.
pub fn is_served(&self) -> bool {
match self {
Mode::Monitored | Mode::Pushed | Mode::External => true,
}
}
/// Parse a mode string; anything unrecognized ⇒ [`Mode::Pushed`].
pub fn parse(s: &str) -> Self {
match s {
"monitored" => Mode::Monitored,
"external" => Mode::External,
_ => Mode::Pushed,
}
}
}
/// The server's startup serve report, in ONE place, so the line the operator
/// reads can never contradict what `nornir workspace ls` returns.
///
/// `registered` is what the registry HOLDS; `served` is how many of those rows
/// got an open warehouse handle; `failed` is `(name, error)` for each row that
/// could not be opened.
///
/// The bug this exists to make unrepresentable: the old line said
/// `serving 0 workspace(s) (empty registry — register one …)` whenever the
/// served map was empty — including when the registry held a fully-populated
/// workspace. That sent every reader down the registration path for a workspace
/// that was already registered. "Empty registry" is now claimed only when the
/// registry is genuinely empty; any other zero has to name its reason.
pub fn serving_line(registered: usize, served: usize, failed: &[(String, String)]) -> String {
if registered == 0 {
return "nornir-server: serving 0 workspace(s) (empty registry — register one with \
`nornir workspace register <name> --descriptor <toml>`)"
.to_string();
}
if failed.is_empty() && served == registered {
return format!("nornir-server: serving {served} workspace(s)");
}
let why = failed
.iter()
.map(|(n, e)| format!("`{n}`: {e}"))
.collect::<Vec<_>>()
.join("; ");
format!(
"nornir-server: serving {served} of {registered} registered workspace(s) — \
{} could NOT be opened: {why}",
registered - served
)
}
/// Per-member sync state (meaningful in [`Mode::Monitored`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemberState {
pub name: String,
/// The member's FETCH SOURCE, as resolved by `RepoSpec::source`: a git URL for
/// a `git =` member, or the absolute local checkout for a FAT `path =` member
/// (`gitio::clone_or_fetch` clones from a local path unchanged). Empty ⇒ no
/// resolvable source, and the monitor SKIPS the member.
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,
/// Working-tree freshness of the server's clone (AUT6). The SHA above can
/// only see *committed* state; this digest sees uncommitted edits too. A
/// thin client compares its own local digest against this to flag staleness.
/// Equals [`crate::gitio::CLEAN_WORKTREE_DIGEST`] when the clone is clean;
/// empty ⇒ never computed. See [`crate::gitio::worktree_freshness`].
#[serde(default)]
pub worktree_digest: String,
/// `true` ⇒ the server's clone has uncommitted changes (tracked mods, staged,
/// or untracked). Mirrors `worktree_digest != CLEAN_WORKTREE_DIGEST`.
#[serde(default)]
pub worktree_dirty: bool,
}
/// What registering a workspace changed in its member list
/// ([`Registry::register`]).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MemberChange {
/// There was no row before: every member is new, and none is "added".
pub first: bool,
/// Members the row did not have (or had from another remote): to clone.
pub added: Vec<String>,
/// Members the row had and the descriptor no longer names.
pub removed: Vec<String>,
}
impl MemberChange {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty()
}
/// `added openzl-sys-rs; removed old-thing (its clone and its rows in the
/// code room stay: nothing retires a member's data)` — empty when nothing
/// changed.
pub fn line(&self) -> String {
let mut parts = Vec::new();
if !self.added.is_empty() {
parts.push(format!("added {}", self.added.join(", ")));
}
if !self.removed.is_empty() {
parts.push(format!("removed {} (its clone and its rows in the code room stay: nothing retires a member's data)", self.removed.join(", ")));
}
parts.join("; ")
}
}
/// 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>,
/// The descriptor's raw TOML **content**, stored IN the registry so the row is
/// the self-contained source of truth — the server re-reads `deep_scan`/members
/// from here, never a sibling `descriptors/*.toml` file. Empty for a git-URL
/// seed whose content isn't known until first fetch. `#[serde(default)]` so rows
/// written before this field round-trip cleanly.
#[serde(default)]
pub descriptor_content: String,
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 — every repo resolved through `RepoSpec::source`,
/// so a `git =` member seeds its URL and a FAT `path =` member seeds its
/// absolute local checkout (see [`Self::members_from`]). 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 {
// Read the descriptor FILE when the seed is a local path, capturing its raw
// content into the row so the registry — not the file — is the source of
// truth thereafter (a git-URL seed leaves both empty until first fetch).
let mut members = Vec::new();
let mut content = String::new();
let dpath = std::path::Path::new(&descriptor);
// A DIRECTORY seed resolves to its descriptor file (FAT-first); a plain
// directory read would fail and silently yield 0 members. A git-URL seed
// (not a local path) resolves to None → members stay empty until fetch.
if let Some(dfile) = crate::workspace::resolve_descriptor_path(dpath)
&& let Ok(text) = std::fs::read_to_string(&dfile)
{
if let Ok(desc) = crate::workspace::WorkspaceDescriptor::from_content(&text) {
members = Self::members_from(&desc);
}
content = text;
}
Self::assemble(name, descriptor, content, mode, poll, members, created_at)
}
/// Build a record from descriptor **content** (thin-mode: the client shipped the
/// TOML). Members come from the content and the content itself is stored in the
/// row — nothing touches disk, so no `descriptors/*.toml` file is needed.
pub fn from_content(
name: String,
descriptor: String,
content: String,
mode: Mode,
poll: String,
created_at: Option<String>,
) -> Self {
let members = crate::workspace::WorkspaceDescriptor::from_content(&content)
.map(|d| Self::members_from(&d))
.unwrap_or_default();
Self::assemble(name, descriptor, content, mode, poll, members, created_at)
}
/// Seed one [`MemberState`] per descriptor repo, routing through the SHARED
/// [`RepoSpec::source`] resolver (LAW 5: one writer both member kinds go
/// through) rather than reading `spec.git` directly.
///
/// Why this matters: a FAT descriptor declares its members as `path =
/// "/abs/checkout"`, not `git = "…"`. Reading only `spec.git` left every such
/// member with an EMPTY `remote`, and an empty remote is *skipped* by
/// [`crate::monitor::fetch_workspace_with_progress`] — so a 19-member FAT
/// workspace materialized 0 checkouts and `republish_with` then failed with
/// "0 of 19 member(s) have a `.git` clone", an error that blames the deploy
/// key / network for members that never had a remote to fetch at all.
///
/// `gitio::clone_or_fetch` passes a LOCAL PATH through unchanged (only
/// `http(s)` is SSH-coerced), so a resolved local checkout works as a clone
/// source with no other change: the monitor clones `path` → `<root>/<ws>/git/
/// <member>` exactly as it clones a git URL, and the whole downstream build
/// (dep graph, knowledge scan, snapshot) is untouched.
///
/// A member whose `path` is RELATIVE and whose descriptor came from content
/// with no base dir (thin mode: `WorkspaceDescriptor::from_content` leaves
/// `descriptor_dir` empty) cannot be resolved — it keeps an empty remote
/// rather than fabricating a wrong one.
fn members_from(desc: &crate::workspace::WorkspaceDescriptor) -> Vec<MemberState> {
use crate::workspace::descriptor::RepoSource;
desc.repos
.iter()
.map(|(mname, spec)| {
let remote = match spec.source(&desc.descriptor_dir) {
// A local (FAT) checkout is a legitimate clone source — but
// only once resolved to an ABSOLUTE path.
Ok(RepoSource::Path { resolved, fallback }) => {
if resolved.is_absolute() {
resolved.to_string_lossy().into_owned()
} else {
// Unresolvable relative path: prefer the declared git
// fallback over a fabricated path.
fallback.map(|g| g.url).unwrap_or_default()
}
}
Ok(RepoSource::Git(g)) => g.url,
// Neither `path` nor `git`: nothing to fetch from.
Err(_) => String::new(),
};
MemberState {
name: mname.clone(),
remote,
git_ref: spec.branch.clone().unwrap_or_default(),
..Default::default()
}
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn assemble(
name: String,
descriptor: String,
descriptor_content: String,
mode: Mode,
poll: String,
members: Vec<MemberState>,
created_at: Option<String>,
) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Workspace {
name,
mode,
descriptor,
poll,
current_snapshot: String::new(),
members,
descriptor_content,
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,
/// The server root the registry lives under (`<root>/registry.redb`). Each
/// workspace's on-disk build data sits beside it at `<root>/<name>/builds/`;
/// [`Registry::remove`] resolves that from here when purging.
root: PathBuf,
}
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,
root: root.to_path_buf(),
})
}
/// The on-disk `builds/` dir for `name` (`<root>/<name>/builds`): the
/// per-workspace warehouse + `catalog.redb` + `jobs.redb`. Resolving it here (off
/// the registry root) keeps the layout in one place for the purge path.
pub fn builds_dir(&self, name: &str) -> PathBuf {
self.root.join(name).join("builds")
}
/// Registry root (the dir holding every `<name>/` workspace dir). Used by the
/// purge path to assert a resolved `builds_dir` cannot escape the root.
pub fn root(&self) -> &Path {
&self.root
}
/// 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.
/// **Register `ws` over whatever row is there**, in ONE transaction, and
/// say what changed: which members are new, which are gone. A member that
/// stays (same name, same remote) keeps what the server already knew of
/// it — its last seen commit, when it was synced — and the row keeps its
/// `current_snapshot`: registering a workspace again is how a member is
/// added, not a reason to forget the other seventeen.
pub fn register(&self, ws: &Workspace) -> Result<(Workspace, MemberChange)> {
let w = self.db.begin_write()?;
let (stored, change) = {
let mut t = w.open_table(TABLE)?;
let old: Option<Workspace> = match t.get(ws.name.as_str())? {
Some(v) => Some(serde_json::from_slice(v.value()).context("decode workspace record")?),
None => None,
};
let mut new = ws.clone();
let mut change = MemberChange { first: old.is_none(), ..Default::default() };
if let Some(old) = &old {
new.current_snapshot = old.current_snapshot.clone();
for m in &mut new.members {
match old.members.iter().find(|o| o.name == m.name) {
Some(o) if o.remote == m.remote => {
let git_ref = m.git_ref.clone();
*m = o.clone();
m.git_ref = git_ref;
}
// the same name from another remote is another clone
Some(_) => change.added.push(m.name.clone()),
None => change.added.push(m.name.clone()),
}
}
change.removed = old.members.iter().filter(|o| !new.members.iter().any(|m| m.name == o.name)).map(|o| o.name.clone()).collect();
}
let bytes = serde_json::to_vec(&new).context("encode workspace record")?;
t.insert(new.name.as_str(), bytes.as_slice())?;
(new, change)
};
w.commit()?;
Ok((stored, change))
}
/// **Write back what a fetch LEARNED about members into the CURRENT row**,
/// in one transaction: a member that is still in the row under the same
/// remote gets its sync fields; nothing else of the row is touched, no
/// member is added and none is dropped. Returns `false` when the
/// workspace is gone.
///
/// NOT [`upsert`](Self::upsert): a fetch reads the row, talks to N remotes
/// — for most of a minute on a real workspace — and then writes. Writing
/// back the row it READ undoes whatever was registered meanwhile; a member
/// added to a workspace was silently gone again before it was ever cloned
/// (seen on a server: `register` said 18 members, `git/` kept 17).
pub fn merge_member_states(&self, name: &str, fetched: &[MemberState]) -> Result<bool> {
let w = self.db.begin_write()?;
let existed = {
let mut t = w.open_table(TABLE)?;
let cur = t.get(name)?.map(|v| v.value().to_vec());
match cur {
Some(bytes) => {
let mut ws: Workspace = serde_json::from_slice(&bytes).context("decode workspace record")?;
for m in &mut ws.members {
if let Some(f) = fetched.iter().find(|f| f.name == m.name && f.remote == m.remote) {
let git_ref = m.git_ref.clone();
*m = f.clone();
m.git_ref = git_ref;
}
}
ws.updated_at = chrono::Utc::now().to_rfc3339();
let out = serde_json::to_vec(&ws).context("encode workspace record")?;
t.insert(name, out.as_slice())?;
true
}
None => false,
}
};
w.commit()?;
Ok(existed)
}
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)
}
/// Atomically set a workspace's `current_snapshot` (and bump `updated_at`)
/// inside a SINGLE redb write transaction — read the row, mutate only those
/// two fields, write it back, all under the one writer lock. This closes the
/// republish read-modify-write window where a concurrent `fetch_workspace`
/// member-SHA write could be clobbered by a stale read-then-upsert in
/// `republish_served`. Returns whether a row existed to update.
pub fn set_snapshot(&self, name: &str, snapshot: &str) -> Result<bool> {
let w = self.db.begin_write()?;
let existed = {
let mut t = w.open_table(TABLE)?;
let cur = t.get(name)?.map(|v| v.value().to_vec());
match cur {
Some(bytes) => {
let mut ws: Workspace =
serde_json::from_slice(&bytes).context("decode workspace record")?;
ws.current_snapshot = snapshot.to_string();
ws.updated_at = chrono::Utc::now().to_rfc3339();
let out = serde_json::to_vec(&ws).context("encode workspace record")?;
t.insert(name, out.as_slice())?;
true
}
None => false,
}
};
w.commit()?;
Ok(existed)
}
/// Remove a workspace; returns whether the registry row existed.
///
/// This is the **shared core** both faces land on: the CLI `nornir workspace rm`
/// (locally, or via the `Workspaces.Remove` RPC) and the viz "🗑 Kill workspace"
/// confirm (always via the RPC) both call here, so the purge semantics are fixed
/// once, not per-face.
///
/// - `purge == false` (default, back-compat): drop ONLY the registry row. The
/// workspace's `builds/` warehouse stays on disk.
/// - `purge == true`: after dropping the row, also `remove_dir_all` the
/// workspace's [`builds_dir`](Self::builds_dir) (`<root>/<name>/builds/` —
/// warehouse + `catalog.redb` + `jobs.redb`), so the workspace is fully gone.
/// The on-disk wipe is **independent of whether a registry row existed**: an
/// earlier non-purge `rm` (or viz Kill without "delete data") leaves an orphan
/// `builds/` behind with no row, and a later `rm <name> --purge` must finish
/// that cleanup rather than bail. A missing builds dir is a no-op.
///
/// Returns `true` if **anything was removed** — either the registry row OR (under
/// `purge`) an orphaned `builds/` dir — so the caller reports success instead of
/// "no such workspace" when it cleaned up disk-only leftovers.
pub fn remove(&self, name: &str, purge: bool) -> Result<bool> {
// Validate BEFORE touching the FS — the purge runs even when no row
// matches, so a malicious/garbage name must be rejected up front.
validate_ws_name(name)?;
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()?;
let mut purged = false;
if purge {
let builds = self.builds_dir(name);
if builds.exists() {
// Canonicalize and assert the resolved dir is still under the
// registry root before the irreversible wipe — a symlink or a
// name that slipped validation must never let remove_dir_all
// escape `<root>/`.
let canon_builds = std::fs::canonicalize(&builds)
.with_context(|| format!("canonicalize builds dir {}", builds.display()))?;
let canon_root = std::fs::canonicalize(&self.root).with_context(|| {
format!("canonicalize registry root {}", self.root.display())
})?;
if !canon_builds.starts_with(&canon_root) {
bail!(
"refusing to purge {}: resolved outside registry root {}",
canon_builds.display(),
canon_root.display()
);
}
std::fs::remove_dir_all(&canon_builds)
.with_context(|| format!("purge builds dir {}", canon_builds.display()))?;
purged = true;
}
}
Ok(existed || purged)
}
}
#[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: "ssh://git@git.gunnar.rs:2222/nordisk/holger.git".into(),
git_ref: "main".into(),
..Default::default()
}],
descriptor_content: String::new(),
created_at: "2026-06-08T00:00:00Z".into(),
updated_at: "2026-06-08T00:00:00Z".into(),
}
}
fn member(name: &str, remote: &str) -> MemberState {
MemberState { name: name.into(), remote: remote.into(), ..Default::default() }
}
/// **Registering a workspace again adds what is new and keeps what it
/// knew**: the members that stay keep their last seen commit and sync
/// time, the row keeps its snapshot, and the change is said — added,
/// removed.
///
/// RED WHEN REVERTED: write the new row over the old one (`upsert`) and
/// `holger` has forgotten its commit, the snapshot is empty.
#[test]
fn registering_again_keeps_what_was_known_and_says_what_changed() {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
let mut first = rec("nordisk", Mode::Monitored);
first.members.push(member("old-thing", "git@x:old-thing"));
let (_, change) = reg.register(&first).unwrap();
assert!(change.first && change.is_empty() && change.line().is_empty(), "a first register adds no member to anything");
// the server learns things about it
let mut known = reg.get("nordisk").unwrap().unwrap();
known.members[0].last_seen_sha = "0b07d30f".into();
known.members[0].last_synced = "2026-10-13T20:00:00Z".into();
known.members[0].sync_state = "ok".into();
reg.merge_member_states("nordisk", &known.members).unwrap();
reg.set_snapshot("nordisk", "holger@7").unwrap();
// the descriptor gains a member and loses one
let mut again = rec("nordisk", Mode::Monitored);
again.members.push(member("openzl-sys-rs", "ssh://git@x/openzl-sys-rs.git"));
let (stored, change) = reg.register(&again).unwrap();
assert_eq!((change.first, change.added.clone(), change.removed.clone()), (false, vec!["openzl-sys-rs".to_string()], vec!["old-thing".to_string()]));
assert!(change.line().starts_with("added openzl-sys-rs; removed old-thing (its clone and its rows"), "{}", change.line());
let holger = &stored.members[0];
assert_eq!((holger.last_seen_sha.as_str(), holger.sync_state.as_str(), stored.current_snapshot.as_str()), ("0b07d30f", "ok", "holger@7"));
assert_eq!(stored.members[1].last_seen_sha, "", "the new member is new");
assert_eq!(reg.get("nordisk").unwrap().unwrap().members.len(), 2);
// the same name from ANOTHER remote is another clone: new
let mut moved = rec("nordisk", Mode::Monitored);
moved.members[0].remote = "git@elsewhere:holger".into();
let (stored, change) = reg.register(&moved).unwrap();
assert_eq!((change.added, stored.members[0].last_seen_sha.as_str()), (vec!["holger".to_string()], ""));
}
/// **A fetch does not undo a register that landed while it ran.** The
/// fetch read the row with one member, talked to the remote, and writes
/// back what it learned: into the row as it is NOW, which has two.
///
/// RED WHEN REVERTED: `upsert` the row the fetch read and the second
/// member is gone again — it was registered and never cloned.
#[test]
fn what_a_fetch_learned_is_merged_into_the_row_as_it_is_now() {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
reg.register(&rec("nordisk", Mode::Monitored)).unwrap();
// a fetch starts: this is what it read
let mut read = reg.get("nordisk").unwrap().unwrap();
// … meanwhile the workspace is registered again, with one more member
let mut again = rec("nordisk", Mode::Monitored);
again.poll = "30s".into();
again.members.push(member("openzl-sys-rs", "ssh://git@x/openzl-sys-rs.git"));
reg.register(&again).unwrap();
// … and the fetch ends
read.members[0].last_seen_sha = "9c1e22aa".into();
read.members[0].sync_state = "ok".into();
assert!(reg.merge_member_states("nordisk", &read.members).unwrap());
let now = reg.get("nordisk").unwrap().unwrap();
assert_eq!(now.members.iter().map(|m| (m.name.as_str(), m.last_seen_sha.as_str())).collect::<Vec<_>>(), vec![("holger", "9c1e22aa"), ("openzl-sys-rs", "")]);
assert_eq!(now.poll, "30s", "nothing else of the row is the fetch's to write");
// a member the row no longer has is not brought back, a workspace that is gone is not created
assert!(reg.merge_member_states("nordisk", &[member("old-thing", "git@x:old-thing")]).unwrap());
assert_eq!(reg.get("nordisk").unwrap().unwrap().members.len(), 2);
assert!(!reg.merge_member_states("gone", &read.members).unwrap());
}
/// INJECT-ASSERT: a FAT descriptor's `path =` members must seed a member whose
/// `remote` is the resolved local checkout — the monitor SKIPS any member with
/// an empty remote, so an empty remote here means the whole workspace silently
/// materializes 0 checkouts and the build dies with "0 of N member(s) have a
/// `.git` clone" (which then misblames the deploy key). Asserts the resolved
/// VALUE, not merely that a member exists.
///
/// RED before the `RepoSpec::source` routing: `members_from` read only
/// `spec.git`, so both members below came back with `remote: ""`.
#[test]
fn fat_path_members_seed_the_local_checkout_as_their_remote() {
// A mixed descriptor: one FAT `path=` member, one `git=` member.
let content = r#"
[workspace]
name = "mixed"
[repos.localmember]
path = "/home/alice/git/dwarves"
[repos.remotemember]
git = "ssh://git@git.gunnar.rs:2222/nordisk/holger.git"
branch = "main"
"#;
let ws = Workspace::from_content(
"mixed".into(),
"/tmp/ws".into(),
content.into(),
Mode::Monitored,
"60s".into(),
None,
);
assert_eq!(ws.members.len(), 2, "both members must be seeded");
let local = ws
.members
.iter()
.find(|m| m.name == "localmember")
.expect("the path= member must be present");
assert_eq!(
local.remote, "/home/alice/git/dwarves",
"a FAT `path=` member must carry its resolved checkout as the fetch \
source; an empty remote is silently skipped by the monitor"
);
let remote = ws
.members
.iter()
.find(|m| m.name == "remotemember")
.expect("the git= member must be present");
assert_eq!(
remote.remote, "ssh://git@git.gunnar.rs:2222/nordisk/holger.git",
"the git= path must be unchanged by the source() routing"
);
assert_eq!(remote.git_ref, "main", "branch must still be carried");
}
/// The `path` + `git` PRECEDENCE, and the unresolvable case.
///
/// * an ABSOLUTE `path=` alongside a `git=` fallback must fetch from the LOCAL
/// path — that is the whole point of a FAT descriptor (codeberg-independent,
/// no deploy key). Reading `spec.git` first inverted this and went to the
/// network for a member that was sitting on local disk. This arm is what makes
/// the test non-blind: under the pre-fix logic it returns the git URL.
/// * a RELATIVE `path=` with no descriptor base dir (thin mode: content shipped
/// without a file, so `descriptor_dir` is empty) is UNRESOLVABLE — it must fall
/// back to the declared git remote, and must never fabricate a bogus path.
#[test]
fn unresolvable_relative_path_member_falls_back_to_git_never_fabricates() {
let content = r#"
[workspace]
name = "rel"
[repos.fatwithfallback]
path = "/home/alice/git/skade"
git = "ssh://git@git.gunnar.rs:2222/nordisk/skade.git"
[repos.bothdeclared]
path = "../sibling"
git = "ssh://git@git.gunnar.rs:2222/nordisk/sibling.git"
[repos.pathonly]
path = "../orphan"
"#;
let ws = Workspace::from_content(
"rel".into(),
"/tmp/ws".into(),
content.into(),
Mode::Monitored,
String::new(),
None,
);
// PRECEDENCE: local path WINS over the git fallback when it resolves.
let fat = ws
.members
.iter()
.find(|m| m.name == "fatwithfallback")
.unwrap();
assert_eq!(
fat.remote, "/home/alice/git/skade",
"an absolute `path=` must win over its `git=` fallback — a FAT member \
must not be fetched over the network"
);
let both = ws
.members
.iter()
.find(|m| m.name == "bothdeclared")
.unwrap();
assert_eq!(
both.remote, "ssh://git@git.gunnar.rs:2222/nordisk/sibling.git",
"an unresolvable relative path must defer to its git fallback"
);
let orphan = ws.members.iter().find(|m| m.name == "pathonly").unwrap();
assert!(
orphan.remote.is_empty(),
"no base dir and no git fallback must leave the remote EMPTY, not a \
fabricated relative path — got {:?}",
orphan.remote
);
}
#[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, "ssh://git@git.gunnar.rs:2222/nordisk/holger.git");
let all = reg.list().unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].name, "a"); // key order
assert!(reg.remove("a", false).unwrap());
assert!(!reg.remove("a", false).unwrap());
assert_eq!(reg.list().unwrap().len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// INJECT-ASSERT: the shared purge core. `remove(.., true)` deletes the
/// workspace's `<root>/<name>/builds/` dir; `remove(.., false)` leaves it (the
/// back-compat registry-only path). Resolution is off the registry root.
#[test]
fn remove_purge_deletes_builds_dir_non_purge_leaves_it() {
let dir = std::env::temp_dir().join(format!("nornir-purge-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let reg = Registry::open(&dir).unwrap();
// Two workspaces, each with an on-disk builds/ tree holding warehouse files.
for ws in ["keep", "nuke"] {
reg.upsert(&rec(ws, Mode::Pushed)).unwrap();
let builds = reg.builds_dir(ws);
std::fs::create_dir_all(builds.join("warehouse")).unwrap();
std::fs::write(builds.join("catalog.redb"), b"x").unwrap();
std::fs::write(builds.join("jobs.redb"), b"y").unwrap();
assert!(builds.exists());
}
// Non-purge: row gone, builds/ stays.
assert!(reg.remove("keep", false).unwrap());
assert!(reg.get("keep").unwrap().is_none());
assert!(
reg.builds_dir("keep").exists(),
"non-purge must leave builds/"
);
// Purge: row gone AND builds/ removed.
assert!(reg.remove("nuke", true).unwrap());
assert!(reg.get("nuke").unwrap().is_none());
assert!(
!reg.builds_dir("nuke").exists(),
"purge must remove builds/"
);
// Purge of a missing builds dir is a no-op (row already gone ⇒ false).
assert!(!reg.remove("nuke", true).unwrap());
// Orphaned builds/ with NO registry row (an earlier non-purge `rm` left it):
// `rm --purge` must still wipe the dir AND report success (true), not bail.
let orphan = reg.builds_dir("orphan");
std::fs::create_dir_all(orphan.join("warehouse")).unwrap();
std::fs::write(orphan.join("catalog.redb"), b"x").unwrap();
assert!(
reg.get("orphan").unwrap().is_none(),
"no registry row for orphan"
);
assert!(
reg.remove("orphan", true).unwrap(),
"purge of orphaned builds/ (no row) must report success"
);
assert!(
!reg.builds_dir("orphan").exists(),
"orphan builds/ must be wiped"
);
// Non-purge on a row-less name with no builds/ is still a no-op (false).
assert!(!reg.remove("orphan", false).unwrap());
std::fs::remove_dir_all(&dir).ok();
}
/// Bug #4: set_snapshot updates only current_snapshot (+ updated_at) in one
/// txn and preserves every other field (e.g. a concurrently-written member
/// SHA), and is a no-op false when the row is absent.
#[test]
fn set_snapshot_is_atomic_field_update() {
let dir = std::env::temp_dir().join(format!("nornir-reg-snap-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let reg = Registry::open(&dir).unwrap();
assert!(
!reg.set_snapshot("missing", "s1").unwrap(),
"no row → false"
);
reg.upsert(&rec("w", Mode::Monitored)).unwrap();
assert!(reg.set_snapshot("w", "snap-123").unwrap());
let got = reg.get("w").unwrap().unwrap();
assert_eq!(got.current_snapshot, "snap-123");
// Untouched fields survive the targeted update.
assert_eq!(got.members[0].remote, "ssh://git@git.gunnar.rs:2222/nordisk/holger.git");
assert_eq!(got.mode, Mode::Monitored);
std::fs::remove_dir_all(&dir).ok();
}
/// Bug #2: an unsafe workspace name must be rejected before the purge path
/// can `remove_dir_all` anything — empty, separators, `..`, `.`, absolute.
#[test]
fn validate_ws_name_rejects_path_escapes() {
assert!(validate_ws_name("nordisk").is_ok());
assert!(validate_ws_name("a-b_c.123").is_ok());
for bad in [
"",
".",
"..",
"/",
"/etc",
"a/b",
"..\\..",
"../sibling",
"\\",
".",
] {
assert!(validate_ws_name(bad).is_err(), "must reject {bad:?}");
}
// And `remove` itself refuses an escaping name even with purge=true.
let dir = std::env::temp_dir().join(format!("nornir-reg-bad-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let reg = Registry::open(&dir).unwrap();
assert!(reg.remove("../escape", true).is_err());
std::fs::remove_dir_all(&dir).ok();
}
}
/// One row of the `workspace ls` roster — the projection BOTH the fat (registry
/// [`Workspace`]) and thin (proto) faces map into, so they render identically
/// (CLI⟺UI parity, the Nornir-tab roster's data). AUT9.
///
/// This is a CORE (ungated) type — `registry` compiles in the default CLI build,
/// which does NOT pull the optional `nornir-wire` leaf. So the wire-crossing twin
/// is a **mirror** in `nornir_wire::view::rows::RosterRow` (the wire-stable shape a
/// thin viz client decodes without a `nornir` edge), and the boundary conversion
/// [`From`] lives below, gated to the feature sets that carry the wire leaf. The
/// two structs are field-identical, so a JSON produced from either decodes as the
/// other (the byte-compatible wire contract).
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct RosterRow {
pub name: String,
pub mode: String,
pub members: usize,
pub last_synced: String,
/// PO1 — the rolled-up POPULATE verdict for this workspace (latest clone/fetch
/// outcome per member → a workspace state). `"green"` (all members' latest ok),
/// `"red"` (≥1 member's latest is an error), `"stale"` (never populated — no
/// clone_events), or `""` when the populate roll-up wasn't requested
/// (`workspace ls` without `--status`). The roster's red/green badge.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub populate: String,
/// The first failing member (when `populate == "red"`), else empty.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub failing_member: String,
/// That member's latest error detail (when `populate == "red"`), else empty.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub last_error: String,
}
/// Boundary conversion into the `nornir-wire` mirror (`RosterRow` → the wire-stable
/// [`nornir_wire::view::rows::RosterRow`]). The one place that maps the core type
/// onto the wire shape, so a server producer can emit the mirror and the
/// read-client deserialize it without a `nornir` edge. Gated to the feature sets
/// that pull the wire leaf.
#[cfg(any(feature = "server", feature = "viz", feature = "mcp"))]
impl From<RosterRow> for nornir_wire::view::rows::RosterRow {
fn from(r: RosterRow) -> Self {
Self {
name: r.name,
mode: r.mode,
members: r.members,
last_synced: r.last_synced,
populate: r.populate,
failing_member: r.failing_member,
last_error: r.last_error,
}
}
}
/// The reverse boundary conversion (wire mirror → core `RosterRow`), so a client
/// that deserialized the wire mirror can hand the core type back to code that
/// still wants it (the viz pane, until it moves to the wire type in Step 4).
#[cfg(any(feature = "server", feature = "viz", feature = "mcp"))]
impl From<nornir_wire::view::rows::RosterRow> for RosterRow {
fn from(r: nornir_wire::view::rows::RosterRow) -> Self {
Self {
name: r.name,
mode: r.mode,
members: r.members,
last_synced: r.last_synced,
populate: r.populate,
failing_member: r.failing_member,
last_error: r.last_error,
}
}
}
/// `workspace ls` as a uniform [`crate::cli_outcome::CommandOutcome`] — the CLI twin
/// of the Nornir-tab workspace roster. `rows` are already projected from the
/// canonical source (fat: `Registry::list`; thin: `Workspaces.List` RPC) — this only
/// shapes + renders. `ok ⟺ ≥1 workspace` (an empty registry is a valid resting state
/// for a user, but for the autopilot a seeded-then-empty roster is RED).
pub fn roster_outcome(rows: &[RosterRow]) -> crate::cli_outcome::CommandOutcome {
use crate::cli_outcome::CommandOutcome;
if rows.is_empty() {
return CommandOutcome::fail(
"workspace ls",
"no workspaces registered — `nornir workspace register …`",
);
}
// PO1: show the POPULATE column only when at least one row carries a verdict
// (i.e. `--status` was passed / the viz roster requested the roll-up).
let has_populate = rows.iter().any(|r| !r.populate.is_empty());
let mut human = if has_populate {
format!(
"{:<20} {:<10} {:>7} {:<22} {:<8} {}",
"NAME", "MODE", "MEMBERS", "LAST SYNCED", "POPULATE", "LAST ERROR"
)
} else {
format!(
"{:<20} {:<10} {:>7} {}",
"NAME", "MODE", "MEMBERS", "LAST SYNCED"
)
};
for r in rows {
if has_populate {
let mark = match r.populate.as_str() {
"green" => "✓ ok",
"red" => "✗ failed",
"stale" => "· stale",
_ => "—",
};
let err = if r.failing_member.is_empty() {
String::new()
} else {
format!("{}: {}", r.failing_member, r.last_error)
};
human.push_str(&format!(
"\n{:<20} {:<10} {:>7} {:<22} {:<8} {}",
r.name, r.mode, r.members, r.last_synced, mark, err
));
} else {
human.push_str(&format!(
"\n{:<20} {:<10} {:>7} {}",
r.name, r.mode, r.members, r.last_synced
));
}
}
CommandOutcome::ok("workspace ls", serde_json::json!(rows), human)
}
#[cfg(test)]
mod roster_outcome_tests {
use super::*;
#[test]
fn empty_registry_roster_is_red() {
let o = roster_outcome(&[]);
assert_eq!(o.command, "workspace ls");
assert!(!o.is_sannr(), "an empty roster is RED for the autopilot");
}
#[test]
fn populated_roster_is_sannr_with_rows() {
let rows = vec![
RosterRow {
name: "nordisk".into(),
mode: "monitored".into(),
members: 8,
last_synced: "2026-06-23".into(),
..Default::default()
},
RosterRow {
name: "holger".into(),
mode: "local".into(),
members: 1,
last_synced: "never".into(),
..Default::default()
},
];
let o = roster_outcome(&rows);
assert!(o.is_sannr(), "a populated roster is a true (sannr) outcome");
let arr = o.data.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], serde_json::json!("nordisk"));
assert_eq!(arr[0]["members"], serde_json::json!(8));
}
/// PO1 (inject-assert): a roster with a RED workspace (a failing member) and a
/// GREEN workspace renders the verdicts + names the failing member in the
/// human view, and carries them in the JSON data. The CLI twin of the
/// Nornir-tab roster's red/green badge.
#[test]
fn roster_with_populate_verdicts_shows_red_and_failing_member() {
let rows = vec![
RosterRow {
name: "nordisk".into(),
mode: "monitored".into(),
members: 8,
last_synced: "2026-06-23".into(),
populate: "red".into(),
failing_member: "korp".into(),
last_error: "Couldn't obtain Username".into(),
},
RosterRow {
name: "holger".into(),
mode: "monitored".into(),
members: 2,
last_synced: "2026-06-23".into(),
populate: "green".into(),
..Default::default()
},
];
let o = roster_outcome(&rows);
assert!(o.is_sannr());
// JSON carries the verdict + failing member for the failed workspace.
let arr = o.data.as_array().unwrap();
let nordisk = arr.iter().find(|r| r["name"] == "nordisk").unwrap();
assert_eq!(nordisk["populate"], "red");
assert_eq!(nordisk["failing_member"], "korp");
assert!(
nordisk["last_error"]
.as_str()
.unwrap()
.contains("Couldn't obtain Username")
);
let holger = arr.iter().find(|r| r["name"] == "holger").unwrap();
assert_eq!(holger["populate"], "green");
// A green workspace omits the failing-member/error keys (skip_serializing_if).
assert!(holger.get("failing_member").is_none());
// The human render shows the POPULATE column + names the failing member.
assert!(
o.human.contains("POPULATE"),
"populate column shown: {}",
o.human
);
assert!(o.human.contains("✗ failed"));
assert!(o.human.contains("korp: Couldn't obtain Username"));
}
}
#[cfg(test)]
mod every_registered_workspace_is_served_tests {
//! REGRESSION (2026-08-04): the live server printed
//! `serving 0 workspace(s) (empty registry — register one with …)` while
//! `nornir workspace ls --server` returned `nordisk pushed 19` and the
//! server had 19 real member clones on disk. Two readers of ONE registry,
//! disagreeing inside one process.
//!
//! Cause: the boot loop gated *serving* on `ws.mode == Mode::Monitored`, and
//! `nordisk` was registered `pushed` (the CLI's default when `--monitored` is
//! not passed). Never served ⇒ `republish_served` bailed with
//! `not served; skipping republish` ⇒ `current_snapshot` stayed empty ⇒
//! `builds/` stayed empty ⇒ every Data/Lenses/Mímir read was refused with
//! `workspace `nordisk` is not served (served: [])`.
//!
//! Both halves are pinned here, against the SHARED writers the boot loop
//! itself calls ([`Mode::is_served`], [`serving_line`]) — not a re-implemented
//! copy of the loop, which is what let the previous `empty_registry_tests`
//! walk stay green through the whole bug.
use super::*;
fn tmp_root(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"nornir-serve-all-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
/// Mode gates FETCHING, never READING: every mode gets a served handle.
#[test]
fn pushed_and_external_rows_are_served_not_just_monitored() {
assert!(Mode::Monitored.is_served(), "monitored is served");
assert!(
Mode::Pushed.is_served(),
"a PUSHED workspace is one the server COMPUTES for — if it is not \
served it can never be read, and republish_served bails before \
writing the result. This is the nordisk bug."
);
assert!(
Mode::External.is_served(),
"an EXTERNAL workspace's `builds/` is explicitly server-owned — that \
warehouse is exactly the thing being served"
);
}
/// The startup line must never claim "empty registry" over a non-empty one,
/// and must still claim it when the registry really IS empty (so the fix
/// cannot degenerate into deleting the message).
#[test]
fn the_startup_line_never_misdescribes_a_non_empty_registry() {
// Genuinely empty ⇒ the onboarding message is correct and must survive.
let empty = serving_line(0, 0, &[]);
assert!(
empty.contains("empty registry"),
"a truly empty registry still says so: {empty}"
);
// The live shape: 1 row registered, 0 served because the open failed.
let live = serving_line(
1,
0,
&[("nordisk".to_string(), "open warehouse failed".to_string())],
);
assert!(
!live.contains("empty registry"),
"THE LIE: 1 registered row must never be reported as an empty \
registry — that is what sent two people down the registration path \
for an already-registered workspace. Got: {live}"
);
assert!(
live.contains("nordisk") && live.contains("open warehouse failed"),
"an unserved row must NAME itself and its reason: {live}"
);
assert!(live.contains('1'), "the registered count is stated: {live}");
// All rows served ⇒ the plain count, and it must agree with `ls`.
let ok = serving_line(1, 1, &[]);
assert_eq!(ok, "nornir-server: serving 1 workspace(s)");
}
/// End-to-end over a REAL redb registry: register exactly the row shape the
/// live server holds (mode `pushed`, members resolved) and assert the boot
/// selection serves it — so `serving N` and `workspace ls` return the same N.
#[test]
fn a_pushed_row_in_a_real_registry_is_selected_for_serving_and_the_counts_agree() {
let root = tmp_root("redb");
let reg = Registry::open(&root).expect("open registry");
let content = "[workspace]\nname = \"nordisk\"\n\n\
[repos.skade]\ngit = \"https://x/skade.git\"\n\
[repos.znippy]\ngit = \"https://x/znippy.git\"\n";
let ws = Workspace::from_content(
"nordisk".into(),
"/home/alice/git/workspace_nordisk/nornir-workspace.toml".into(),
content.into(),
// The live row's mode — the CLI default when `--monitored` is omitted.
Mode::Pushed,
String::new(),
None,
);
reg.upsert(&ws).expect("upsert pushed row");
// READER A — what `nornir workspace ls --server` counts.
let listed = reg.list().expect("list");
assert_eq!(listed.len(), 1, "the registry HOLDS the row");
assert_eq!(listed[0].mode, Mode::Pushed, "and it is `pushed`, as live");
assert_eq!(listed[0].members.len(), 2, "with its members resolved");
// READER B — the boot loop's serve selection, through the SHARED predicate.
let to_serve: Vec<&Workspace> = listed.iter().filter(|w| w.mode.is_served()).collect();
assert_eq!(
to_serve.len(),
listed.len(),
"THE DISAGREEMENT: `ls` counted {} row(s) and the serve selection \
counted {}. A registered, member-resolved workspace that no reader \
can touch is the whole defect.",
listed.len(),
to_serve.len()
);
assert_eq!(to_serve[0].name, "nordisk");
// And the line the operator reads agrees with both.
assert_eq!(
serving_line(listed.len(), to_serve.len(), &[]),
"nornir-server: serving 1 workspace(s)"
);
let _ = std::fs::remove_dir_all(&root);
}
}