use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use redb::{Database, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};
const TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("workspaces");
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 `\\`)");
}
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"),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
Monitored,
Pushed,
External,
}
impl Mode {
pub fn as_str(&self) -> &'static str {
match self {
Mode::Monitored => "monitored",
Mode::Pushed => "pushed",
Mode::External => "external",
}
}
pub fn parse(s: &str) -> Self {
match s {
"monitored" => Mode::Monitored,
"external" => Mode::External,
_ => Mode::Pushed,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemberState {
pub name: String,
pub remote: String,
pub git_ref: String,
pub last_seen_sha: String,
pub last_synced: String,
pub sync_state: String,
#[serde(default)]
pub worktree_digest: String,
#[serde(default)]
pub worktree_dirty: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub name: String,
pub mode: Mode,
pub descriptor: String,
pub poll: String,
pub current_snapshot: String,
pub members: Vec<MemberState>,
#[serde(default)]
pub descriptor_content: String,
pub created_at: String,
pub updated_at: String,
}
impl Workspace {
pub fn new(
name: String,
descriptor: String,
mode: Mode,
poll: String,
created_at: Option<String>,
) -> Self {
let mut members = Vec::new();
let mut content = String::new();
let dpath = std::path::Path::new(&descriptor);
if dpath.exists()
&& let Ok(text) = std::fs::read_to_string(dpath)
{
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)
}
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)
}
fn members_from(desc: &crate::workspace::WorkspaceDescriptor) -> Vec<MemberState> {
desc.repos
.iter()
.map(|(mname, spec)| MemberState {
name: mname.clone(),
remote: spec.git.clone().unwrap_or_default(),
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,
}
}
}
pub struct Registry {
db: Database,
root: PathBuf,
}
impl Registry {
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()))?;
let w = db.begin_write()?;
{
let _ = w.open_table(TABLE)?;
}
w.commit()?;
Ok(Self { db, root: root.to_path_buf() })
}
pub fn builds_dir(&self, name: &str) -> PathBuf {
self.root.join(name).join("builds")
}
pub fn root(&self) -> &Path {
&self.root
}
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(())
}
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),
}
}
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)
}
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)
}
pub fn remove(&self, name: &str, purge: bool) -> Result<bool> {
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() {
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: "git@codeberg.org:nordisk/holger".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(),
}
}
#[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");
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();
}
#[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();
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());
}
assert!(reg.remove("keep", false).unwrap());
assert!(reg.get("keep").unwrap().is_none());
assert!(reg.builds_dir("keep").exists(), "non-purge must leave builds/");
assert!(reg.remove("nuke", true).unwrap());
assert!(reg.get("nuke").unwrap().is_none());
assert!(!reg.builds_dir("nuke").exists(), "purge must remove builds/");
assert!(!reg.remove("nuke", true).unwrap());
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");
assert!(!reg.remove("orphan", false).unwrap());
std::fs::remove_dir_all(&dir).ok();
}
#[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");
assert_eq!(got.members[0].remote, "git@codeberg.org:nordisk/holger");
assert_eq!(got.mode, Mode::Monitored);
std::fs::remove_dir_all(&dir).ok();
}
#[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:?}");
}
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();
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct RosterRow {
pub name: String,
pub mode: String,
pub members: usize,
pub last_synced: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub populate: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub failing_member: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub last_error: String,
}
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 …`",
);
}
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));
}
#[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());
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");
assert!(holger.get("failing_member").is_none());
assert!(o.human.contains("POPULATE"), "populate column shown: {}", o.human);
assert!(o.human.contains("✗ failed"));
assert!(o.human.contains("korp: Couldn't obtain Username"));
}
}