use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use synwire_storage::{ProjectRegistry, StorageError, StorageLayout, WorktreeId};
use tokio::sync::RwLock;
use tokio::time::Instant;
use tracing::{debug, info, warn};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ManagerError {
#[error("storage error: {0}")]
Storage(#[from] StorageError),
#[error("worktree not found: {0}")]
NotFound(String),
#[error("worktree already registered: {0}")]
AlreadyRegistered(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WorktreeStatus {
Idle,
Indexing,
Ready,
}
#[derive(Debug, Clone)]
pub struct WorktreeHandle {
pub worktree_id: WorktreeId,
pub root_path: PathBuf,
pub last_accessed: Instant,
pub status: WorktreeStatus,
}
pub struct RepoManager {
layout: StorageLayout,
registry: Arc<RwLock<ProjectRegistry>>,
active_worktrees: Arc<RwLock<HashMap<String, WorktreeHandle>>>,
max_active: usize,
}
impl RepoManager {
pub fn new(layout: StorageLayout, max_active: usize) -> Result<Self, ManagerError> {
let registry = ProjectRegistry::load(&layout)?;
Ok(Self {
layout,
registry: Arc::new(RwLock::new(registry)),
active_worktrees: Arc::new(RwLock::new(HashMap::new())),
max_active,
})
}
pub async fn register(&self, root_path: &Path) -> Result<WorktreeId, ManagerError> {
let wid = WorktreeId::for_path(root_path)?;
let key = wid.key();
{
let active = self.active_worktrees.read().await;
if active.contains_key(&key) {
return Err(ManagerError::AlreadyRegistered(key));
}
}
{
let mut reg = self.registry.write().await;
reg.upsert(&wid, root_path);
if let Err(e) = reg.save(&self.layout) {
warn!(key = %key, "failed to persist registry after upsert: {e}");
}
}
let canonical = root_path.canonicalize().map_err(StorageError::from)?;
let handle = WorktreeHandle {
worktree_id: wid.clone(),
root_path: canonical,
last_accessed: Instant::now(),
status: WorktreeStatus::Idle,
};
{
let mut active = self.active_worktrees.write().await;
let _ = active.insert(key.clone(), handle);
}
info!(key = %key, "worktree registered");
Ok(wid)
}
pub async fn get(&self, worktree_id: &WorktreeId) -> Option<WorktreeHandle> {
let active = self.active_worktrees.read().await;
active.get(&worktree_id.key()).cloned()
}
pub async fn touch(&self, worktree_id: &WorktreeId) {
{
let mut active = self.active_worktrees.write().await;
if let Some(handle) = active.get_mut(&worktree_id.key()) {
handle.last_accessed = Instant::now();
debug!(key = %worktree_id.key(), "worktree touched");
}
}
let mut reg = self.registry.write().await;
reg.touch(worktree_id);
if let Err(e) = reg.save(&self.layout) {
warn!(key = %worktree_id.key(), "failed to persist registry after touch: {e}");
}
drop(reg);
}
pub async fn list_active(&self) -> Vec<WorktreeHandle> {
let active = self.active_worktrees.read().await;
active.values().cloned().collect()
}
pub async fn evict_idle(&self) -> Vec<WorktreeId> {
let mut active = self.active_worktrees.write().await;
if active.len() <= self.max_active {
return Vec::new();
}
let to_evict = active.len() - self.max_active;
let mut candidates: Vec<(String, Instant)> = active
.iter()
.filter(|(_, h)| h.status != WorktreeStatus::Indexing)
.map(|(k, h)| (k.clone(), h.last_accessed))
.collect();
candidates.sort_by_key(|(_k, t)| *t);
let mut evicted = Vec::with_capacity(to_evict);
for (key, _) in candidates.into_iter().take(to_evict) {
if let Some(handle) = active.remove(&key) {
info!(key = %key, "evicting idle worktree");
evicted.push(handle.worktree_id);
}
}
evicted
}
pub async fn unregister(&self, worktree_id: &WorktreeId) -> bool {
let key = worktree_id.key();
let removed = {
let mut active = self.active_worktrees.write().await;
active.remove(&key).is_some()
};
if removed {
let mut reg = self.registry.write().await;
reg.remove(worktree_id);
if let Err(e) = reg.save(&self.layout) {
warn!(key = %key, "failed to persist registry after unregister: {e}");
}
drop(reg);
info!(key = %key, "worktree unregistered");
}
removed
}
pub async fn set_status(
&self,
worktree_id: &WorktreeId,
status: WorktreeStatus,
) -> Result<(), ManagerError> {
let key = worktree_id.key();
self.active_worktrees
.write()
.await
.get_mut(&key)
.ok_or_else(|| ManagerError::NotFound(key.clone()))?
.status = status;
debug!(key = %key, ?status, "worktree status updated");
Ok(())
}
pub async fn active_count(&self) -> usize {
self.active_worktrees.read().await.len()
}
#[must_use]
pub const fn max_active(&self) -> usize {
self.max_active
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use synwire_storage::identity::RepoId;
use tempfile::tempdir;
fn test_layout() -> (StorageLayout, tempfile::TempDir) {
let dir = tempdir().expect("tempdir");
let layout = StorageLayout::with_root(dir.path(), "synwire");
(layout, dir)
}
fn dummy_worktree(name: &str) -> WorktreeId {
WorktreeId::from_parts(
RepoId::from_string(format!("repo-{name}")),
format!("{name}hash000000"),
format!("{name}@main"),
)
}
#[tokio::test]
async fn new_manager_starts_empty() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 10).expect("new");
assert_eq!(mgr.active_count().await, 0);
assert_eq!(mgr.max_active(), 10);
}
#[tokio::test]
async fn register_and_get_with_real_path() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 10).expect("new");
let worktree_dir = tempdir().expect("worktree_dir");
let wid = mgr.register(worktree_dir.path()).await.expect("register");
let handle = mgr.get(&wid).await.expect("get returned None");
assert_eq!(handle.worktree_id, wid);
assert_eq!(handle.status, WorktreeStatus::Idle);
}
#[tokio::test]
async fn double_register_is_error() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 10).expect("new");
let worktree_dir = tempdir().expect("worktree_dir");
let _wid = mgr.register(worktree_dir.path()).await.expect("register");
let err = mgr.register(worktree_dir.path()).await.unwrap_err();
assert!(matches!(err, ManagerError::AlreadyRegistered(_)));
}
#[tokio::test]
async fn unregister_removes_worktree() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 10).expect("new");
let worktree_dir = tempdir().expect("worktree_dir");
let wid = mgr.register(worktree_dir.path()).await.expect("register");
assert!(mgr.unregister(&wid).await);
assert!(mgr.get(&wid).await.is_none());
assert!(!mgr.unregister(&wid).await);
}
#[tokio::test]
async fn evict_idle_respects_max_active() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 2).expect("new");
let ids: Vec<WorktreeId> = (0..3).map(|i| dummy_worktree(&format!("w{i}"))).collect();
{
let mut active = mgr.active_worktrees.write().await;
for (i, wid) in ids.iter().enumerate() {
let _ = active.insert(
wid.key(),
WorktreeHandle {
worktree_id: wid.clone(),
root_path: PathBuf::from(format!("/tmp/w{i}")),
last_accessed: Instant::now()
- std::time::Duration::from_secs((3 - i as u64) * 10),
status: WorktreeStatus::Idle,
},
);
}
drop(active);
}
assert_eq!(mgr.active_count().await, 3);
let evicted = mgr.evict_idle().await;
assert_eq!(evicted.len(), 1);
assert_eq!(mgr.active_count().await, 2);
assert_eq!(evicted[0].key(), ids[0].key());
}
#[tokio::test]
async fn evict_skips_indexing_worktrees() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 1).expect("new");
let idle_wid = dummy_worktree("idle");
let indexing_wid = dummy_worktree("indexing");
{
let mut active = mgr.active_worktrees.write().await;
let _ = active.insert(
idle_wid.key(),
WorktreeHandle {
worktree_id: idle_wid.clone(),
root_path: PathBuf::from("/tmp/idle"),
last_accessed: Instant::now(),
status: WorktreeStatus::Idle,
},
);
let _ = active.insert(
indexing_wid.key(),
WorktreeHandle {
worktree_id: indexing_wid.clone(),
root_path: PathBuf::from("/tmp/indexing"),
last_accessed: Instant::now() - std::time::Duration::from_secs(100),
status: WorktreeStatus::Indexing,
},
);
}
let evicted = mgr.evict_idle().await;
assert_eq!(evicted.len(), 1);
assert_eq!(evicted[0].key(), idle_wid.key());
}
#[tokio::test]
async fn list_active_returns_all() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 10).expect("new");
let wid_a = dummy_worktree("a");
let wid_b = dummy_worktree("b");
{
let mut active = mgr.active_worktrees.write().await;
for wid in [&wid_a, &wid_b] {
let _ = active.insert(
wid.key(),
WorktreeHandle {
worktree_id: wid.clone(),
root_path: PathBuf::from("/tmp"),
last_accessed: Instant::now(),
status: WorktreeStatus::Ready,
},
);
}
}
let listed = mgr.list_active().await;
assert_eq!(listed.len(), 2);
}
#[tokio::test]
async fn set_status_updates_handle() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 10).expect("new");
let wid = dummy_worktree("s");
{
let mut active = mgr.active_worktrees.write().await;
let _ = active.insert(
wid.key(),
WorktreeHandle {
worktree_id: wid.clone(),
root_path: PathBuf::from("/tmp"),
last_accessed: Instant::now(),
status: WorktreeStatus::Idle,
},
);
}
mgr.set_status(&wid, WorktreeStatus::Indexing)
.await
.expect("set_status");
let handle = mgr.get(&wid).await.expect("get");
assert_eq!(handle.status, WorktreeStatus::Indexing);
}
#[tokio::test]
async fn set_status_not_found() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 10).expect("new");
let wid = dummy_worktree("missing");
let err = mgr
.set_status(&wid, WorktreeStatus::Ready)
.await
.unwrap_err();
assert!(matches!(err, ManagerError::NotFound(_)));
}
#[tokio::test]
async fn no_eviction_when_under_limit() {
let (layout, _dir) = test_layout();
let mgr = RepoManager::new(layout, 10).expect("new");
let wid = dummy_worktree("only");
{
let mut active = mgr.active_worktrees.write().await;
let _ = active.insert(
wid.key(),
WorktreeHandle {
worktree_id: wid.clone(),
root_path: PathBuf::from("/tmp"),
last_accessed: Instant::now(),
status: WorktreeStatus::Idle,
},
);
}
let evicted = mgr.evict_idle().await;
assert!(evicted.is_empty());
assert_eq!(mgr.active_count().await, 1);
}
}