1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use synwire_storage::{ProjectRegistry, StorageError, StorageLayout, WorktreeId};
18use tokio::sync::RwLock;
19use tokio::time::Instant;
20use tracing::{debug, info, warn};
21
22#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum ManagerError {
30 #[error("storage error: {0}")]
32 Storage(#[from] StorageError),
33
34 #[error("worktree not found: {0}")]
36 NotFound(String),
37
38 #[error("worktree already registered: {0}")]
40 AlreadyRegistered(String),
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum WorktreeStatus {
51 Idle,
53 Indexing,
55 Ready,
57}
58
59#[derive(Debug, Clone)]
65pub struct WorktreeHandle {
66 pub worktree_id: WorktreeId,
68 pub root_path: PathBuf,
70 pub last_accessed: Instant,
72 pub status: WorktreeStatus,
74}
75
76pub struct RepoManager {
86 layout: StorageLayout,
88 registry: Arc<RwLock<ProjectRegistry>>,
90 active_worktrees: Arc<RwLock<HashMap<String, WorktreeHandle>>>,
92 max_active: usize,
94}
95
96impl RepoManager {
97 pub fn new(layout: StorageLayout, max_active: usize) -> Result<Self, ManagerError> {
103 let registry = ProjectRegistry::load(&layout)?;
104 Ok(Self {
105 layout,
106 registry: Arc::new(RwLock::new(registry)),
107 active_worktrees: Arc::new(RwLock::new(HashMap::new())),
108 max_active,
109 })
110 }
111
112 pub async fn register(&self, root_path: &Path) -> Result<WorktreeId, ManagerError> {
126 let wid = WorktreeId::for_path(root_path)?;
127 let key = wid.key();
128
129 {
131 let active = self.active_worktrees.read().await;
132 if active.contains_key(&key) {
133 return Err(ManagerError::AlreadyRegistered(key));
134 }
135 }
136
137 {
139 let mut reg = self.registry.write().await;
140 reg.upsert(&wid, root_path);
141 if let Err(e) = reg.save(&self.layout) {
142 warn!(key = %key, "failed to persist registry after upsert: {e}");
143 }
145 }
146
147 let canonical = root_path.canonicalize().map_err(StorageError::from)?;
149 let handle = WorktreeHandle {
150 worktree_id: wid.clone(),
151 root_path: canonical,
152 last_accessed: Instant::now(),
153 status: WorktreeStatus::Idle,
154 };
155
156 {
157 let mut active = self.active_worktrees.write().await;
158 let _ = active.insert(key.clone(), handle);
159 }
160
161 info!(key = %key, "worktree registered");
162 Ok(wid)
163 }
164
165 pub async fn get(&self, worktree_id: &WorktreeId) -> Option<WorktreeHandle> {
169 let active = self.active_worktrees.read().await;
170 active.get(&worktree_id.key()).cloned()
171 }
172
173 pub async fn touch(&self, worktree_id: &WorktreeId) {
176 {
177 let mut active = self.active_worktrees.write().await;
178 if let Some(handle) = active.get_mut(&worktree_id.key()) {
179 handle.last_accessed = Instant::now();
180 debug!(key = %worktree_id.key(), "worktree touched");
181 }
182 }
183
184 let mut reg = self.registry.write().await;
186 reg.touch(worktree_id);
187 if let Err(e) = reg.save(&self.layout) {
189 warn!(key = %worktree_id.key(), "failed to persist registry after touch: {e}");
190 }
191 drop(reg);
192 }
193
194 pub async fn list_active(&self) -> Vec<WorktreeHandle> {
198 let active = self.active_worktrees.read().await;
199 active.values().cloned().collect()
200 }
201
202 pub async fn evict_idle(&self) -> Vec<WorktreeId> {
210 let mut active = self.active_worktrees.write().await;
211
212 if active.len() <= self.max_active {
213 return Vec::new();
214 }
215
216 let to_evict = active.len() - self.max_active;
217
218 let mut candidates: Vec<(String, Instant)> = active
220 .iter()
221 .filter(|(_, h)| h.status != WorktreeStatus::Indexing)
222 .map(|(k, h)| (k.clone(), h.last_accessed))
223 .collect();
224 candidates.sort_by_key(|(_k, t)| *t);
225
226 let mut evicted = Vec::with_capacity(to_evict);
227 for (key, _) in candidates.into_iter().take(to_evict) {
228 if let Some(handle) = active.remove(&key) {
229 info!(key = %key, "evicting idle worktree");
230 evicted.push(handle.worktree_id);
231 }
232 }
233
234 evicted
235 }
236
237 pub async fn unregister(&self, worktree_id: &WorktreeId) -> bool {
242 let key = worktree_id.key();
243 let removed = {
244 let mut active = self.active_worktrees.write().await;
245 active.remove(&key).is_some()
246 };
247
248 if removed {
249 let mut reg = self.registry.write().await;
250 reg.remove(worktree_id);
251 if let Err(e) = reg.save(&self.layout) {
252 warn!(key = %key, "failed to persist registry after unregister: {e}");
253 }
254 drop(reg);
255 info!(key = %key, "worktree unregistered");
256 }
257
258 removed
259 }
260
261 pub async fn set_status(
268 &self,
269 worktree_id: &WorktreeId,
270 status: WorktreeStatus,
271 ) -> Result<(), ManagerError> {
272 let key = worktree_id.key();
273 self.active_worktrees
274 .write()
275 .await
276 .get_mut(&key)
277 .ok_or_else(|| ManagerError::NotFound(key.clone()))?
278 .status = status;
279 debug!(key = %key, ?status, "worktree status updated");
280 Ok(())
281 }
282
283 pub async fn active_count(&self) -> usize {
285 self.active_worktrees.read().await.len()
286 }
287
288 #[must_use]
290 pub const fn max_active(&self) -> usize {
291 self.max_active
292 }
293}
294
295#[cfg(test)]
296#[allow(clippy::unwrap_used, clippy::expect_used)]
297mod tests {
298 use super::*;
299 use synwire_storage::identity::RepoId;
300 use tempfile::tempdir;
301
302 fn test_layout() -> (StorageLayout, tempfile::TempDir) {
303 let dir = tempdir().expect("tempdir");
304 let layout = StorageLayout::with_root(dir.path(), "synwire");
305 (layout, dir)
306 }
307
308 fn dummy_worktree(name: &str) -> WorktreeId {
309 WorktreeId::from_parts(
310 RepoId::from_string(format!("repo-{name}")),
311 format!("{name}hash000000"),
312 format!("{name}@main"),
313 )
314 }
315
316 #[tokio::test]
317 async fn new_manager_starts_empty() {
318 let (layout, _dir) = test_layout();
319 let mgr = RepoManager::new(layout, 10).expect("new");
320 assert_eq!(mgr.active_count().await, 0);
321 assert_eq!(mgr.max_active(), 10);
322 }
323
324 #[tokio::test]
325 async fn register_and_get_with_real_path() {
326 let (layout, _dir) = test_layout();
327 let mgr = RepoManager::new(layout, 10).expect("new");
328
329 let worktree_dir = tempdir().expect("worktree_dir");
331 let wid = mgr.register(worktree_dir.path()).await.expect("register");
332
333 let handle = mgr.get(&wid).await.expect("get returned None");
334 assert_eq!(handle.worktree_id, wid);
335 assert_eq!(handle.status, WorktreeStatus::Idle);
336 }
337
338 #[tokio::test]
339 async fn double_register_is_error() {
340 let (layout, _dir) = test_layout();
341 let mgr = RepoManager::new(layout, 10).expect("new");
342
343 let worktree_dir = tempdir().expect("worktree_dir");
344 let _wid = mgr.register(worktree_dir.path()).await.expect("register");
345 let err = mgr.register(worktree_dir.path()).await.unwrap_err();
346 assert!(matches!(err, ManagerError::AlreadyRegistered(_)));
347 }
348
349 #[tokio::test]
350 async fn unregister_removes_worktree() {
351 let (layout, _dir) = test_layout();
352 let mgr = RepoManager::new(layout, 10).expect("new");
353
354 let worktree_dir = tempdir().expect("worktree_dir");
355 let wid = mgr.register(worktree_dir.path()).await.expect("register");
356 assert!(mgr.unregister(&wid).await);
357 assert!(mgr.get(&wid).await.is_none());
358 assert!(!mgr.unregister(&wid).await);
360 }
361
362 #[tokio::test]
363 async fn evict_idle_respects_max_active() {
364 let (layout, _dir) = test_layout();
365 let mgr = RepoManager::new(layout, 2).expect("new");
366
367 let ids: Vec<WorktreeId> = (0..3).map(|i| dummy_worktree(&format!("w{i}"))).collect();
369 {
370 let mut active = mgr.active_worktrees.write().await;
371 for (i, wid) in ids.iter().enumerate() {
372 let _ = active.insert(
373 wid.key(),
374 WorktreeHandle {
375 worktree_id: wid.clone(),
376 root_path: PathBuf::from(format!("/tmp/w{i}")),
377 last_accessed: Instant::now()
378 - std::time::Duration::from_secs((3 - i as u64) * 10),
379 status: WorktreeStatus::Idle,
380 },
381 );
382 }
383 drop(active);
384 }
385
386 assert_eq!(mgr.active_count().await, 3);
387 let evicted = mgr.evict_idle().await;
388 assert_eq!(evicted.len(), 1);
389 assert_eq!(mgr.active_count().await, 2);
390 assert_eq!(evicted[0].key(), ids[0].key());
392 }
393
394 #[tokio::test]
395 async fn evict_skips_indexing_worktrees() {
396 let (layout, _dir) = test_layout();
397 let mgr = RepoManager::new(layout, 1).expect("new");
398
399 let idle_wid = dummy_worktree("idle");
400 let indexing_wid = dummy_worktree("indexing");
401
402 {
403 let mut active = mgr.active_worktrees.write().await;
404 let _ = active.insert(
405 idle_wid.key(),
406 WorktreeHandle {
407 worktree_id: idle_wid.clone(),
408 root_path: PathBuf::from("/tmp/idle"),
409 last_accessed: Instant::now(),
411 status: WorktreeStatus::Idle,
412 },
413 );
414 let _ = active.insert(
415 indexing_wid.key(),
416 WorktreeHandle {
417 worktree_id: indexing_wid.clone(),
418 root_path: PathBuf::from("/tmp/indexing"),
419 last_accessed: Instant::now() - std::time::Duration::from_secs(100),
421 status: WorktreeStatus::Indexing,
422 },
423 );
424 }
425
426 let evicted = mgr.evict_idle().await;
427 assert_eq!(evicted.len(), 1);
429 assert_eq!(evicted[0].key(), idle_wid.key());
430 }
431
432 #[tokio::test]
433 async fn list_active_returns_all() {
434 let (layout, _dir) = test_layout();
435 let mgr = RepoManager::new(layout, 10).expect("new");
436
437 let wid_a = dummy_worktree("a");
438 let wid_b = dummy_worktree("b");
439 {
440 let mut active = mgr.active_worktrees.write().await;
441 for wid in [&wid_a, &wid_b] {
442 let _ = active.insert(
443 wid.key(),
444 WorktreeHandle {
445 worktree_id: wid.clone(),
446 root_path: PathBuf::from("/tmp"),
447 last_accessed: Instant::now(),
448 status: WorktreeStatus::Ready,
449 },
450 );
451 }
452 }
453
454 let listed = mgr.list_active().await;
455 assert_eq!(listed.len(), 2);
456 }
457
458 #[tokio::test]
459 async fn set_status_updates_handle() {
460 let (layout, _dir) = test_layout();
461 let mgr = RepoManager::new(layout, 10).expect("new");
462
463 let wid = dummy_worktree("s");
464 {
465 let mut active = mgr.active_worktrees.write().await;
466 let _ = active.insert(
467 wid.key(),
468 WorktreeHandle {
469 worktree_id: wid.clone(),
470 root_path: PathBuf::from("/tmp"),
471 last_accessed: Instant::now(),
472 status: WorktreeStatus::Idle,
473 },
474 );
475 }
476
477 mgr.set_status(&wid, WorktreeStatus::Indexing)
478 .await
479 .expect("set_status");
480 let handle = mgr.get(&wid).await.expect("get");
481 assert_eq!(handle.status, WorktreeStatus::Indexing);
482 }
483
484 #[tokio::test]
485 async fn set_status_not_found() {
486 let (layout, _dir) = test_layout();
487 let mgr = RepoManager::new(layout, 10).expect("new");
488
489 let wid = dummy_worktree("missing");
490 let err = mgr
491 .set_status(&wid, WorktreeStatus::Ready)
492 .await
493 .unwrap_err();
494 assert!(matches!(err, ManagerError::NotFound(_)));
495 }
496
497 #[tokio::test]
498 async fn no_eviction_when_under_limit() {
499 let (layout, _dir) = test_layout();
500 let mgr = RepoManager::new(layout, 10).expect("new");
501
502 let wid = dummy_worktree("only");
503 {
504 let mut active = mgr.active_worktrees.write().await;
505 let _ = active.insert(
506 wid.key(),
507 WorktreeHandle {
508 worktree_id: wid.clone(),
509 root_path: PathBuf::from("/tmp"),
510 last_accessed: Instant::now(),
511 status: WorktreeStatus::Idle,
512 },
513 );
514 }
515
516 let evicted = mgr.evict_idle().await;
517 assert!(evicted.is_empty());
518 assert_eq!(mgr.active_count().await, 1);
519 }
520}