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::sanitize::strip_ansi;
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>,
pub source_namespaces: std::collections::HashMap<String, Option<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>,
pub deps: Vec<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,
pub deps: Vec<String>,
}
#[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 read_item_text(item: &catalog::CatalogItem) -> String {
let mut buf = String::new();
for file in crate::review::item_files(item) {
if let Ok(content) = std::fs::read_to_string(&file) {
buf.push_str(&content);
buf.push('\n');
}
}
buf
}
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| strip_ansi(&s.name))
.collect();
let installed: Vec<SnapshotInstalled> = manifest
.items
.values()
.map(|it| {
let deps = catalog_items
.iter()
.find(|ci| ci.source == it.source && ci.kind == it.kind && ci.name == it.bare_name)
.map(|ci| crate::deps::direct_dependency_keys(ci, &catalog_items, &read_item_text))
.unwrap_or_default();
SnapshotInstalled {
key: it.key(),
name: strip_ansi(&it.name),
source: strip_ansi(&it.source),
kind: it.kind,
commit: it.commit.clone(),
description: it.description.as_deref().map(strip_ansi),
deps,
}
})
.collect();
let available: Vec<SnapshotAvailable> = catalog_items
.iter()
.map(|it| {
let deps = crate::deps::direct_dependency_keys(it, &catalog_items, &read_item_text);
SnapshotAvailable {
key: it.key(),
name: strip_ansi(&it.effective_name()),
source: strip_ansi(&it.source),
kind: it.kind,
description: it.description.as_deref().map(strip_ansi),
path: it.path.clone(),
deps,
}
})
.collect();
let unmanaged: Vec<SnapshotUnmanaged> = crate::unmanaged::scan(paths, &manifest)
.unwrap_or_default()
.into_iter()
.map(|u| SnapshotUnmanaged {
key: u.key(),
name: strip_ansi(&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.iter().map(|e| e.path().to_string()).collect())
.unwrap_or_default();
let source_namespaces: std::collections::HashMap<String, Option<String>> = {
let mut m = std::collections::HashMap::new();
for item in &catalog_items {
m.entry(item.source.clone())
.or_insert_with(|| item.prefix.clone());
}
for source in ®istry.sources {
m.entry(source.name.clone())
.or_insert_with(|| source.alias.clone().filter(|p| !p.is_empty()));
}
m
};
Ok(Snapshot {
generation: next_generation(),
installed,
available,
unmanaged,
source_names,
suggestions,
lobes,
source_namespaces,
})
}
#[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);
}
#[test]
fn snapshot_installed_strips_ansi_from_source_derived_strings() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let bidi = '\u{202E}';
let manifest_json = format!(
concat!(
"{{\n",
" \"items\": {{\n",
" \"skill:\\u001b[31mevil\\u001b[0m\": {{\n",
" \"kind\": \"skill\",\n",
" \"name\": \"\\u001b[31mevil\\u001b[0m\",\n",
" \"bare_name\": \"evil\",\n",
" \"source\": \"\\u001b[32msrc\\u001b[0m\",\n",
" \"commit\": \"abc1234\",\n",
" \"hash\": \"deadbeef\",\n",
" \"store\": \"store/skill/evil\",\n",
" \"links\": [],\n",
" \"description\": \"\\u001b[1mbold\\u001b[0m with {}bidi\"\n",
" }}\n",
" }}\n",
"}}"
),
bidi
);
std::fs::write(paths.manifest_file(), manifest_json).unwrap();
let snap = load(&paths).expect("load should succeed");
assert_eq!(snap.installed.len(), 1, "one installed item");
let item = &snap.installed[0];
assert_eq!(
item.name, "evil",
"ANSI escapes must be stripped from name; got: {:?}",
item.name
);
assert_eq!(
item.source, "src",
"ANSI escapes must be stripped from source; got: {:?}",
item.source
);
let desc = item.description.as_deref().unwrap_or("");
assert!(
!desc.contains('\x1b'),
"ANSI escapes must be stripped from description; got: {:?}",
desc
);
assert!(
!desc.contains('\u{202E}'),
"bidi-override must be stripped from description; got: {:?}",
desc
);
assert_eq!(item.kind, ItemKind::Skill, "kind field must be preserved");
cleanup(&base);
}
}