use std::path::Path;
use anyhow::{Context, Result};
use redb::{Database, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};
const TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("workspaces");
#[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,
}
#[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>,
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 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,
}
}
}
pub struct Registry {
db: Database,
}
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 })
}
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 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");
assert!(reg.remove("a").unwrap());
assert!(!reg.remove("a").unwrap());
assert_eq!(reg.list().unwrap().len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
}