use std::path::PathBuf;
use crate::catalog;
use crate::config::Config;
use crate::error::{ItemKind, Result};
use crate::hash::hash_path;
use crate::lock;
use crate::manifest::Manifest;
use crate::paths::Paths;
use crate::sanitize::strip_ansi;
use crate::source::Registry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
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, PartialEq, Eq)]
pub struct SnapshotInstalled {
pub key: String,
pub display_key: String,
pub name: String,
pub source: String,
pub kind: ItemKind,
pub commit: String,
pub description: Option<String>,
pub deps: Vec<String>,
pub stale: bool,
pub path: Option<PathBuf>,
pub recorded_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotAvailable {
pub key: String,
pub display_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, PartialEq, Eq)]
pub struct SnapshotUnmanaged {
pub key: String,
pub display_key: String,
pub name: String,
pub kind: ItemKind,
pub paths: Vec<PathBuf>,
}
static HASH_MEMO: std::sync::Mutex<std::collections::BTreeMap<PathBuf, (String, String)>> =
std::sync::Mutex::new(std::collections::BTreeMap::new());
#[cfg(test)]
pub(crate) fn poison_memo_for_test(path: &std::path::Path, fake_hash: &str) {
if let Ok(fp) = crate::hash::stat_fingerprint(path)
&& let Ok(mut memo) = HASH_MEMO.lock()
{
memo.insert(path.to_path_buf(), (fp, fake_hash.to_string()));
}
}
fn prune_hash_memo(live_paths: &std::collections::HashSet<PathBuf>) {
if let Ok(mut memo) = HASH_MEMO.lock() {
prune_memo_map(&mut memo, live_paths);
}
}
fn prune_memo_map(
memo: &mut std::collections::BTreeMap<PathBuf, (String, String)>,
live_paths: &std::collections::HashSet<PathBuf>,
) {
memo.retain(|path, _| live_paths.contains(path));
}
fn memoized_hash(path: &std::path::Path) -> Option<String> {
let fingerprint = crate::hash::stat_fingerprint(path).ok();
if let Some(fp) = &fingerprint
&& let Ok(memo) = HASH_MEMO.lock()
&& let Some((cached_fp, cached_hash)) = memo.get(path)
&& cached_fp == fp
{
return Some(cached_hash.clone());
}
let hash = hash_path(path).ok()?;
if let Some(fp) = fingerprint
&& let Ok(mut memo) = HASH_MEMO.lock()
{
memo.insert(path.to_path_buf(), (fp, hash.clone()));
}
Some(hash)
}
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 sanitize_dep_keys(keys: Vec<String>) -> Vec<String> {
keys.iter().map(|k| strip_ansi(k)).collect()
}
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 live_paths: std::collections::HashSet<PathBuf> =
catalog_items.iter().map(|c| c.path.clone()).collect();
prune_hash_memo(&live_paths);
let dep_index = crate::deps::DepIndex::new(&catalog_items);
let source_names: Vec<String> = registry
.sources
.iter()
.map(|s| strip_ansi(&s.name))
.collect();
let mut installed: Vec<SnapshotInstalled> = Vec::with_capacity(manifest.items.len());
for it in manifest.items.values() {
let matched_node = catalog_items
.iter()
.position(|ci| ci.source == it.source && ci.kind == it.kind && ci.name == it.bare_name);
let matched = matched_node.map(|n| &catalog_items[n]);
let deps: Vec<String> = matched_node
.map(|n| sanitize_dep_keys(dep_index.direct_keys(n, &read_item_text)))
.unwrap_or_default();
let stale = matched.is_some_and(|ci| {
let cur = memoized_hash(&ci.path);
let hash_drift = cur.as_deref().is_none_or(|h| h != it.hash);
let rename_drift = ci.effective_name() != it.name;
hash_drift || rename_drift
});
installed.push(SnapshotInstalled {
key: it.key().into(),
display_key: it.display_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,
stale,
path: matched.map(|ci| ci.path.clone()),
recorded_hash: it.hash.clone(),
});
}
let available: Vec<SnapshotAvailable> = catalog_items
.iter()
.enumerate()
.map(|(node, it)| {
let deps = sanitize_dep_keys(dep_index.direct_keys(node, &read_item_text));
SnapshotAvailable {
key: it.key().into(),
display_key: it.display_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().into(),
display_key: u.display_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 {
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 memoized_hash_matches_hash_path_and_refreshes_on_change() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let item = base.join("item");
crate::paths::mkdir_p(&item).unwrap();
std::fs::write(item.join("SKILL.md"), b"v1").unwrap();
let first = memoized_hash(&item).expect("hash");
assert_eq!(
first,
hash_path(&item).unwrap(),
"cold value must be correct"
);
let second = memoized_hash(&item).expect("hash");
assert_eq!(second, first, "a memo hit must return the same hash");
std::fs::write(item.join("SKILL.md"), b"v2 is longer than v1").unwrap();
let after = memoized_hash(&item).expect("hash");
assert_ne!(after, first, "a content change must invalidate the memo");
assert_eq!(
after,
hash_path(&item).unwrap(),
"the refreshed value must equal a direct hash_path"
);
cleanup(&base);
}
#[test]
fn memoized_hash_detects_same_size_content_change() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let item = base.join("item-same-size");
crate::paths::mkdir_p(&item).unwrap();
std::fs::write(item.join("SKILL.md"), b"aaaaa").unwrap();
let first = memoized_hash(&item).expect("hash");
std::fs::write(item.join("SKILL.md"), b"bbbbb").unwrap();
let after = memoized_hash(&item).expect("hash");
assert_ne!(
first, after,
"a same-size content rewrite must invalidate the memo"
);
assert_eq!(
after,
hash_path(&item).unwrap(),
"the refreshed memo value must equal a direct hash_path"
);
cleanup(&base);
}
#[test]
fn prune_memo_map_evicts_paths_no_longer_in_the_catalog() {
let p1 = PathBuf::from("/fake/still-in-catalog");
let p2 = PathBuf::from("/fake/dropped-from-catalog");
let mut memo = std::collections::BTreeMap::new();
memo.insert(p1.clone(), ("fp1".to_string(), "hash1".to_string()));
memo.insert(p2.clone(), ("fp2".to_string(), "hash2".to_string()));
let mut live = std::collections::HashSet::new();
live.insert(p1.clone());
prune_memo_map(&mut memo, &live);
assert!(
memo.contains_key(&p1),
"a path still in the catalog must survive pruning"
);
assert!(
!memo.contains_key(&p2),
"a path no longer in the catalog must be evicted, not retained forever"
);
}
#[test]
fn poisoned_memo_serves_the_seeded_hash_instead_of_the_real_one() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let item = base.join("poison-target");
crate::paths::mkdir_p(&item).unwrap();
std::fs::write(item.join("SKILL.md"), b"v1").unwrap();
std::fs::write(item.join("SKILL.md"), b"v2 is longer than v1").unwrap();
let real_hash = hash_path(&item).expect("real hash");
let fake_hash = "0000000000000000000000000000000000000000000000000000000000000000";
poison_memo_for_test(&item, fake_hash);
let served = memoized_hash(&item).expect("memoized_hash after poisoning");
assert_eq!(
served, fake_hash,
"a poisoned memo must serve the seeded fake hash, not recompute"
);
assert_ne!(
served, real_hash,
"the seeded fake hash must differ from the item's real current hash \
-- otherwise this test cannot distinguish 'served from memo' from \
'recomputed and happened to match'"
);
cleanup(&base);
}
#[test]
fn two_loads_with_no_change_produce_equal_snapshots() {
use std::process::Command;
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![crate::config::LobeEntry::bare(
paths.claude_home.to_str().unwrap(),
)],
..Default::default()
}
.save(&paths)
.unwrap();
let src = base.join("determinism-source");
std::fs::create_dir_all(src.join("skills/review")).unwrap();
std::fs::write(
src.join("skills/review/SKILL.md"),
"---\ndescription: review skill\n---\n# review\n",
)
.unwrap();
std::fs::create_dir_all(src.join("skills/extra")).unwrap();
std::fs::write(
src.join("skills/extra/SKILL.md"),
"---\ndescription: extra skill\n---\n# extra\n",
)
.unwrap();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.expect("git");
};
git(&["-c", "init.defaultBranch=main", "init", "-q"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "t"]);
git(&["add", "-A"]);
git(&["commit", "-qm", "initial"]);
crate::commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
crate::commands::PinRequest::None,
None,
false,
)
.expect("meld");
crate::commands::learn(
&paths,
"skill:review",
false,
crate::commands::InstallFlow {
yes: true,
clobber: crate::commands::Clobber::Force,
dangerously_skip: true,
dangerously_skip_build: true,
},
)
.expect("learn");
let snap1 = load(&paths).unwrap();
let snap2 = load(&paths).unwrap();
assert!(
!snap1.installed.is_empty(),
"fixture must have an installed item for this test to be meaningful"
);
assert!(
!snap1.available.is_empty(),
"fixture must have an available item for this test to be meaningful"
);
assert_eq!(
snap1, snap2,
"two loads over unchanged state (melded source + installed item) must be \
equal so the poll gate can skip the rebuild"
);
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);
}
#[test]
fn sanitize_dep_keys_strips_ansi_and_bidi() {
let bidi = '\u{202E}';
let raw = vec![
format!("skill:re\x1b[31mview"),
format!("agent:d{bidi}ev"),
"rule:clean".to_string(),
];
let sanitized = sanitize_dep_keys(raw);
assert_eq!(
sanitized,
vec![
"skill:review".to_string(),
"agent:dev".to_string(),
"rule:clean".to_string(),
]
);
assert!(
sanitized
.iter()
.all(|s| !s.contains('\x1b') && !s.contains(bidi)),
"no dep key may retain a raw ANSI escape or bidi override: {sanitized:?}"
);
}
#[test]
fn snapshot_installed_marks_stale_after_in_place_content_edit() {
use std::process::Command;
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![crate::config::LobeEntry::bare(
paths.claude_home.to_str().unwrap(),
)],
..Default::default()
}
.save(&paths)
.unwrap();
let src = base.join("stale-source");
std::fs::create_dir_all(src.join("skills/review")).unwrap();
std::fs::write(
src.join("skills/review/SKILL.md"),
"---\ndescription: review skill\n---\n# review\noriginal content\n",
)
.unwrap();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.expect("git");
};
git(&["-c", "init.defaultBranch=main", "init", "-q"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "t"]);
git(&["add", "-A"]);
git(&["commit", "-qm", "initial"]);
crate::commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
crate::commands::PinRequest::None,
None,
false,
)
.expect("meld");
crate::commands::learn(
&paths,
"skill:review",
false,
crate::commands::InstallFlow {
yes: true,
clobber: crate::commands::Clobber::Force,
dangerously_skip: true,
dangerously_skip_build: true,
},
)
.expect("learn");
let snap = load(&paths).expect("load should succeed");
assert_eq!(snap.installed.len(), 1, "one installed item");
assert!(
!snap.installed[0].stale,
"a freshly installed item must not be marked stale"
);
std::fs::write(
src.join("skills/review/SKILL.md"),
"---\ndescription: review skill\n---\n# review\nmodified content\n",
)
.unwrap();
let snap2 = load(&paths).expect("load should succeed after edit");
assert_eq!(snap2.installed.len(), 1, "still one installed item");
assert!(
snap2.installed[0].stale,
"an in-place content edit must mark the item stale (TUI-63)"
);
cleanup(&base);
}
}