libnoa 0.1.1

AI-native distributed version control system with per-agent workspace isolation, JSONL append-only logs, snapshot-based history, and full git protocol compatibility
Documentation
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use redb::ReadableTable;

use crate::{
    error::{NoaError, Result},
    snapshot::SnapshotId,
};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Workspace {
    pub name: String,
    pub head: SnapshotId,
    pub base: SnapshotId,
    pub agent_id: Option<String>,
    pub last_seq: u64,
    pub created_at: u64,
    pub updated_at: u64,
}

const WORKSPACES: redb::TableDefinition<&str, &[u8]> = redb::TableDefinition::new("workspaces");

macro_rules! redb_err {
    ($result:expr) => {
        $result.map_err(|e| NoaError::Redb(e.to_string()))
    };
}

#[derive(Clone)]
pub struct WorkspaceManager {
    db: Arc<redb::Database>,
}

impl WorkspaceManager {
    pub fn new(db: Arc<redb::Database>) -> Result<Self> {
        let mgr = WorkspaceManager { db };
        mgr.ensure_table()?;
        Ok(mgr)
    }

    fn ensure_table(&self) -> Result<()> {
        let txn = redb_err!(self.db.begin_write())?;
        {
            let _ = redb_err!(txn.open_table(WORKSPACES));
        }
        redb_err!(txn.commit())
    }

    pub async fn create(&self, workspace: &Workspace) -> Result<()> {
        if self.get(&workspace.name).await?.is_some() {
            return Err(NoaError::WorkspaceAlreadyExists(workspace.name.clone()));
        }
        self.put(workspace).await
    }

    pub async fn get(&self, name: &str) -> Result<Option<Workspace>> {
        let txn = redb_err!(self.db.begin_read())?;
        let table = redb_err!(txn.open_table(WORKSPACES))?;
        match redb_err!(table.get(name))? {
            Some(guard) => {
                let ws: Workspace = rmp_serde::from_slice(guard.value())
                    .map_err(|e| NoaError::Serialization(e.to_string()))?;
                Ok(Some(ws))
            }
            None => Ok(None),
        }
    }

    pub async fn put(&self, workspace: &Workspace) -> Result<()> {
        let data =
            rmp_serde::to_vec(workspace).map_err(|e| NoaError::Serialization(e.to_string()))?;
        let txn = redb_err!(self.db.begin_write())?;
        {
            let mut table = redb_err!(txn.open_table(WORKSPACES))?;
            redb_err!(table.insert(workspace.name.as_str(), data.as_slice()))?;
        }
        redb_err!(txn.commit())
    }

    pub async fn delete(&self, name: &str) -> Result<bool> {
        let txn = redb_err!(self.db.begin_write())?;
        {
            let mut table = redb_err!(txn.open_table(WORKSPACES))?;
            redb_err!(table.remove(name))?;
        }
        redb_err!(txn.commit())?;
        Ok(true)
    }

    pub async fn list(&self) -> Result<Vec<Workspace>> {
        let txn = redb_err!(self.db.begin_read())?;
        let table = redb_err!(txn.open_table(WORKSPACES))?;
        let mut result = Vec::new();
        for entry in redb_err!(table.iter())? {
            let (_, value) = redb_err!(entry)?;
            let ws: Workspace = rmp_serde::from_slice(value.value())
                .map_err(|e| NoaError::Serialization(e.to_string()))?;
            result.push(ws);
        }
        Ok(result)
    }

    pub async fn update_head(&self, name: &str, new_head: &SnapshotId) -> Result<()> {
        let mut ws = self
            .get(name)
            .await?
            .ok_or_else(|| NoaError::WorkspaceNotFound(name.to_string()))?;
        ws.head = new_head.clone();
        ws.updated_at = chrono::Utc::now().timestamp_micros() as u64;
        self.put(&ws).await
    }

    pub async fn update_head_and_seq(
        &self,
        name: &str,
        new_head: &SnapshotId,
        last_seq: u64,
    ) -> Result<()> {
        let mut ws = self
            .get(name)
            .await?
            .ok_or_else(|| NoaError::WorkspaceNotFound(name.to_string()))?;
        ws.head = new_head.clone();
        ws.last_seq = last_seq;
        ws.updated_at = chrono::Utc::now().timestamp_micros() as u64;
        self.put(&ws).await
    }
}

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

    fn make_manager() -> (TempDir, WorkspaceManager) {
        let tmp = TempDir::new().unwrap();
        let db = Arc::new(
            redb::Database::builder()
                .create(tmp.path().join("test.redb"))
                .unwrap(),
        );
        let mgr = WorkspaceManager::new(db).unwrap();
        (tmp, mgr)
    }

    fn make_workspace(name: &str) -> Workspace {
        Workspace {
            name: name.to_string(),
            head: SnapshotId("noa_base".to_string()),
            base: SnapshotId("noa_base".to_string()),
            agent_id: None,
            last_seq: 0,
            created_at: 1000,
            updated_at: 1000,
        }
    }

    #[tokio::test]
    async fn test_create_and_get() {
        let (_tmp, mgr) = make_manager();
        let ws = make_workspace("default");
        mgr.create(&ws).await.unwrap();
        let got = mgr.get("default").await.unwrap().unwrap();
        assert_eq!(got, ws);
    }

    #[tokio::test]
    async fn test_create_duplicate_fails() {
        let (_tmp, mgr) = make_manager();
        mgr.create(&make_workspace("ws1")).await.unwrap();
        let result = mgr.create(&make_workspace("ws1")).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_list() {
        let (_tmp, mgr) = make_manager();
        mgr.create(&make_workspace("a")).await.unwrap();
        mgr.create(&make_workspace("b")).await.unwrap();
        let list = mgr.list().await.unwrap();
        assert_eq!(list.len(), 2);
    }

    #[tokio::test]
    async fn test_delete() {
        let (_tmp, mgr) = make_manager();
        mgr.create(&make_workspace("ws1")).await.unwrap();
        mgr.delete("ws1").await.unwrap();
        assert!(mgr.get("ws1").await.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_update_head() {
        let (_tmp, mgr) = make_manager();
        mgr.create(&make_workspace("ws1")).await.unwrap();
        let new_head = SnapshotId("noa_new".to_string());
        mgr.update_head("ws1", &new_head).await.unwrap();
        let ws = mgr.get("ws1").await.unwrap().unwrap();
        assert_eq!(ws.head, new_head);
    }

    #[tokio::test]
    async fn test_get_nonexistent() {
        let (_tmp, mgr) = make_manager();
        assert!(mgr.get("missing").await.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_update_head_nonexistent() {
        let (_tmp, mgr) = make_manager();
        let result = mgr
            .update_head("missing", &SnapshotId("noa_x".to_string()))
            .await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_put_overwrites() {
        let (_tmp, mgr) = make_manager();
        let ws = make_workspace("ws1");
        mgr.create(&ws).await.unwrap();
        let mut updated = ws.clone();
        updated.head = SnapshotId("noa_new_head".to_string());
        mgr.put(&updated).await.unwrap();
        let got = mgr.get("ws1").await.unwrap().unwrap();
        assert_eq!(got.head, SnapshotId("noa_new_head".to_string()));
    }

    #[tokio::test]
    async fn test_workspace_with_agent_id() {
        let (_tmp, mgr) = make_manager();
        let ws = Workspace {
            name: "agent-ws".to_string(),
            head: SnapshotId("noa_base".to_string()),
            base: SnapshotId("noa_base".to_string()),
            agent_id: Some("agent-007".to_string()),
            last_seq: 0,
            created_at: 1000,
            updated_at: 1000,
        };
        mgr.create(&ws).await.unwrap();
        let got = mgr.get("agent-ws").await.unwrap().unwrap();
        assert_eq!(got.agent_id, Some("agent-007".to_string()));
    }

    #[tokio::test]
    async fn test_update_head_updates_timestamp() {
        let (_tmp, mgr) = make_manager();
        let ws = make_workspace("ws1");
        mgr.create(&ws).await.unwrap();
        let before = mgr.get("ws1").await.unwrap().unwrap().updated_at;
        std::thread::sleep(std::time::Duration::from_micros(100));
        mgr.update_head("ws1", &SnapshotId("noa_new".to_string()))
            .await
            .unwrap();
        let after = mgr.get("ws1").await.unwrap().unwrap().updated_at;
        assert!(after >= before);
    }

    #[tokio::test]
    async fn test_list_empty_after_delete_all() {
        let (_tmp, mgr) = make_manager();
        mgr.create(&make_workspace("a")).await.unwrap();
        mgr.create(&make_workspace("b")).await.unwrap();
        mgr.delete("a").await.unwrap();
        mgr.delete("b").await.unwrap();
        let list = mgr.list().await.unwrap();
        assert!(list.is_empty());
    }

    #[tokio::test]
    async fn test_workspace_serialization_roundtrip() {
        let (_tmp, mgr) = make_manager();
        let ws = Workspace {
            name: "test".to_string(),
            head: SnapshotId("noa_head".to_string()),
            base: SnapshotId("noa_base".to_string()),
            agent_id: Some("agent-001".to_string()),
            last_seq: 5,
            created_at: 12345,
            updated_at: 67890,
        };
        mgr.create(&ws).await.unwrap();
        let got = mgr.get("test").await.unwrap().unwrap();
        assert_eq!(got, ws);
    }
}