use std::path::PathBuf;
use cached::Cached as _;
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, 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,
pub ignore: Vec<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::LazyLock<
cached::sync_sync::RwLock<cached::LruCache<(PathBuf, String), String>>,
> = std::sync::LazyLock::new(|| cached::sync_sync::RwLock::new(new_hash_memo(HASH_MEMO_FLOOR)));
const HASH_MEMO_FLOOR: usize = 1024;
const HASH_MEMO_PER_ITEM: usize = 2;
fn new_hash_memo(capacity: usize) -> cached::LruCache<(PathBuf, String), String> {
cached::LruCache::builder()
.max_size(capacity.max(1))
.build()
.expect("a non-zero max_size is the only failure mode")
}
fn hash_memo_capacity_for(item_count: usize) -> usize {
(item_count * HASH_MEMO_PER_ITEM).max(HASH_MEMO_FLOOR)
}
fn fit_hash_memo(live_paths: &std::collections::HashSet<PathBuf>, item_count: usize) {
fit_memo(&mut HASH_MEMO.write(), live_paths, item_count);
}
fn fit_memo(
memo: &mut cached::LruCache<(PathBuf, String), String>,
live_paths: &std::collections::HashSet<PathBuf>,
item_count: usize,
) {
memo.retain(|(path, _), _| live_paths.contains(path));
memo.set_max_size(hash_memo_capacity_for(item_count));
}
#[cfg(test)]
pub(crate) fn poison_memo_for_test(
path: &std::path::Path,
ignore: &crate::ignore::IgnoreSet,
fake_hash: &str,
) {
if let Ok(fp) = crate::hash::stat_fingerprint_ignoring(path, ignore) {
HASH_MEMO
.write()
.cache_set((path.to_path_buf(), fp), fake_hash.to_string());
}
}
fn memoized_hash(path: &std::path::Path, ignore: &crate::ignore::IgnoreSet) -> Option<String> {
let Ok(fingerprint) = crate::hash::stat_fingerprint_ignoring(path, ignore) else {
return crate::hash::hash_path_ignoring(path, ignore).ok();
};
let key = (path.to_path_buf(), fingerprint);
if let Some(hit) = HASH_MEMO.write().cache_get(&key) {
return Some(hit.clone());
}
let hash = crate::hash::hash_path_ignoring(path, ignore).ok()?;
HASH_MEMO.write().cache_set(key, 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();
fit_hash_memo(&live_paths, catalog_items.len());
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 ignore_set = matched.and_then(|ci| ci.ignore_set().ok());
let stale = matched.is_some_and(|ci| {
let cur = ignore_set
.as_ref()
.and_then(|ig| memoized_hash(&ci.path, ig));
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(),
ignore: matched
.map(|ci| ci.ignore.clone().unwrap_or_default())
.unwrap_or_default(),
});
}
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::hash::hash_path;
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, &crate::ignore::IgnoreSet::default()).expect("hash");
assert_eq!(
first,
hash_path(&item).unwrap(),
"cold value must be correct"
);
let second = memoized_hash(&item, &crate::ignore::IgnoreSet::default()).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, &crate::ignore::IgnoreSet::default()).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, &crate::ignore::IgnoreSet::default()).expect("hash");
std::fs::write(item.join("SKILL.md"), b"bbbbb").unwrap();
let after = memoized_hash(&item, &crate::ignore::IgnoreSet::default()).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 fitting_the_memo_grows_capacity_past_a_large_catalog() {
let mut memo = new_hash_memo(HASH_MEMO_FLOOR);
let many: std::collections::HashSet<PathBuf> = (0..5000)
.map(|i| PathBuf::from(format!("/fake/item-{i}")))
.collect();
fit_memo(&mut memo, &many, many.len());
let capacity = memo.capacity();
assert!(
capacity >= many.len(),
"capacity must cover the catalog ({} items), got {capacity}: below \
it the memo does not degrade, it stops working",
many.len()
);
fit_memo(&mut memo, &std::collections::HashSet::new(), 0);
let shrunk = memo.capacity();
assert_eq!(
shrunk, HASH_MEMO_FLOOR,
"an empty catalog must return the memo to the floor, not keep the \
capacity a since-uninstalled set once needed"
);
assert!(
shrunk < capacity,
"the shrink must actually reduce the bound ({capacity} -> {shrunk})"
);
}
#[test]
fn fitting_the_memo_drops_entries_whose_path_left_the_catalog() {
let live = PathBuf::from("/fake/still-in-catalog");
let dead = PathBuf::from("/fake/dropped-from-catalog");
let mut memo = new_hash_memo(HASH_MEMO_FLOOR);
memo.cache_set((live.clone(), "fp".into()), "hash-live".into());
memo.cache_set((dead.clone(), "fp".into()), "hash-dead".into());
let mut live_paths = std::collections::HashSet::new();
live_paths.insert(live.clone());
fit_memo(&mut memo, &live_paths, live_paths.len());
assert!(
memo.cache_get(&(live, "fp".to_string())).is_some(),
"a path still in the catalog must survive the prune"
);
assert!(
memo.cache_get(&(dead, "fp".to_string())).is_none(),
"a path no longer in the catalog must be dropped, not retained"
);
}
#[test]
fn fitting_the_memo_prunes_before_it_resizes() {
let live: Vec<PathBuf> = (0..40)
.map(|i| PathBuf::from(format!("/live/{i}")))
.collect();
let mut memo = new_hash_memo(HASH_MEMO_FLOOR);
for path in &live {
memo.cache_set((path.clone(), "fp".into()), "live".into());
}
for i in 0..200 {
memo.cache_set(
(PathBuf::from(format!("/dead/{i}")), "fp".into()),
"dead".into(),
);
}
let live_paths: std::collections::HashSet<PathBuf> = live.iter().cloned().collect();
fit_memo(&mut memo, &live_paths, 1);
let survivors = live
.iter()
.filter(|p| memo.cache_get(&((*p).clone(), "fp".to_string())).is_some())
.count();
assert_eq!(
survivors,
live.len(),
"every live entry must survive: pruning the dead ones first leaves \
the resize nothing worth evicting"
);
}
#[cfg(unix)]
#[test]
fn a_failed_hash_is_not_cached_and_is_retried() {
use std::os::unix::fs::PermissionsExt as _;
let (_paths, base) = temp_paths();
let item = base.join("unreadable-item");
crate::paths::mkdir_p(&item).unwrap();
let file = item.join("SKILL.md");
std::fs::write(&file, b"unreadable").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
let ignore = crate::ignore::IgnoreSet::default();
if crate::hash::hash_path_ignoring(&item, &ignore).is_ok() {
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
cleanup(&base);
return;
}
let key_before = crate::hash::stat_fingerprint_ignoring(&item, &ignore)
.expect("the fingerprint must still succeed: only the read fails");
assert!(
memoized_hash(&item, &ignore).is_none(),
"an unreadable file must fail the hash"
);
assert!(
HASH_MEMO
.write()
.cache_get(&(item.clone(), key_before))
.is_none(),
"a failed hash must leave NO entry under the key it failed at: a \
stored failure would be served instead of being retried"
);
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(
memoized_hash(&item, &ignore).is_some(),
"the next poll must recompute rather than serve a remembered failure"
);
cleanup(&base);
}
#[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, &crate::ignore::IgnoreSet::default(), fake_hash);
let served = memoized_hash(&item, &crate::ignore::IgnoreSet::default())
.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![
"skill:re\x1b[31mview".to_string(),
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);
}
}