use std::path::PathBuf;
use crate::catalog;
use crate::config::Config;
use crate::error::{ItemKind, Result};
use crate::lock;
use crate::manifest::Manifest;
use crate::paths::Paths;
use crate::source::Registry;
#[derive(Debug, Clone)]
pub struct Snapshot {
pub generation: u64,
pub installed: Vec<SnapshotInstalled>,
pub available: Vec<SnapshotAvailable>,
pub unmanaged: Vec<SnapshotUnmanaged>,
#[allow(dead_code)]
pub source_names: Vec<String>,
pub suggestions: Vec<crate::tui::preview::RegistrySuggestion>,
pub lobes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SnapshotInstalled {
pub key: String,
pub name: String,
pub source: String,
pub kind: ItemKind,
pub commit: String,
pub description: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SnapshotAvailable {
pub key: String,
pub name: String,
pub source: String,
pub kind: ItemKind,
pub description: Option<String>,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct SnapshotUnmanaged {
pub key: String,
pub name: String,
pub kind: ItemKind,
pub paths: Vec<PathBuf>,
}
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_generation() -> u64 {
GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
pub fn load(paths: &Paths) -> Result<Snapshot> {
let lock = lock::open(paths)?;
let _guard = lock.read()?;
load_inner(paths)
}
pub fn try_poll(paths: &Paths) -> Option<Snapshot> {
let lock = lock::open(paths).ok()?;
let _guard = lock.try_read()?;
load_inner(paths).ok()
}
fn load_inner(paths: &Paths) -> Result<Snapshot> {
let registry = Registry::load(paths)?;
let manifest = Manifest::load(paths)?;
let catalog_items = catalog::scan(paths, ®istry)?;
let source_names: Vec<String> = registry.sources.iter().map(|s| s.name.clone()).collect();
let installed: Vec<SnapshotInstalled> = manifest
.items
.values()
.map(|it| SnapshotInstalled {
key: it.key(),
name: it.name.clone(),
source: it.source.clone(),
kind: it.kind,
commit: it.commit.clone(),
description: it.description.clone(),
})
.collect();
let available: Vec<SnapshotAvailable> = catalog_items
.iter()
.map(|it| SnapshotAvailable {
key: it.key(),
name: it.effective_name(),
source: it.source.clone(),
kind: it.kind,
description: it.description.clone(),
path: it.path.clone(),
})
.collect();
let unmanaged: Vec<SnapshotUnmanaged> = crate::unmanaged::scan(paths, &manifest)
.unwrap_or_default()
.into_iter()
.map(|u| SnapshotUnmanaged {
key: u.key(),
name: u.name,
kind: u.kind,
paths: u.paths,
})
.collect();
let suggestions = crate::tui::preview::suggested_registry(paths).unwrap_or_default();
let lobes = Config::load(paths).map(|c| c.lobes).unwrap_or_default();
Ok(Snapshot {
generation: next_generation(),
installed,
available,
unmanaged,
source_names,
suggestions,
lobes,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::Paths;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_paths() -> (Paths, std::path::PathBuf) {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-tui-data-{}-{n}", std::process::id()));
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
(paths, base)
}
fn cleanup(base: &std::path::Path) {
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn load_returns_empty_snapshot_on_fresh_home() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let snap = load(&paths).expect("load should succeed on fresh home");
assert!(snap.installed.is_empty(), "fresh home: no installed items");
assert!(snap.available.is_empty(), "fresh home: no available items");
assert!(snap.unmanaged.is_empty(), "fresh home: no unmanaged items");
assert!(snap.source_names.is_empty(), "fresh home: no sources");
cleanup(&base);
}
#[test]
fn try_poll_succeeds_when_no_exclusive_lock_held() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let snap = try_poll(&paths);
assert!(
snap.is_some(),
"try_poll should succeed when no exclusive lock is held"
);
cleanup(&base);
}
#[test]
fn try_poll_returns_none_when_exclusive_lock_held() {
use fd_lock::RwLock;
use std::fs::OpenOptions;
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let lock_path = paths.lock_file();
std::fs::write(&lock_path, b"").unwrap();
let f = OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let mut raw_lock = RwLock::new(f);
let _excl = raw_lock.write().expect("acquire exclusive lock");
let snap = try_poll(&paths);
assert!(
snap.is_none(),
"try_poll must return None when exclusive lock is held"
);
drop(_excl);
cleanup(&base);
}
#[test]
fn generation_increments_on_each_load() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let snap1 = load(&paths).unwrap();
let snap2 = load(&paths).unwrap();
assert!(
snap2.generation > snap1.generation,
"generation should increment on each load"
);
cleanup(&base);
}
}