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, Default, 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>>,
pub source_profiles: std::collections::HashMap<String, SourceProfiles>,
pub hook_results: Vec<crate::hook_log::HookRecord>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SourceProfiles {
pub declared: Vec<String>,
pub enabled: Vec<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>,
pub profiles: Vec<String>,
pub explicit: bool,
}
#[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)
}
struct SourceScan {
items: Vec<catalog::CatalogItem>,
deps: Vec<Vec<String>>,
available: Vec<SnapshotAvailable>,
}
pub const OFF_SCREEN_REFRESH: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Debug, Clone, Copy)]
pub enum Refresh<'a> {
All,
Focused(&'a std::collections::HashSet<String>),
}
impl Refresh<'_> {
fn due(&self, source: &str, at: std::time::Instant, now: std::time::Instant) -> bool {
match self {
Refresh::All => true,
Refresh::Focused(names) => {
names.contains(strip_ansi(source).as_str())
|| now.saturating_duration_since(at) >= OFF_SCREEN_REFRESH
}
}
}
}
struct CachedScan {
scan: std::sync::Arc<SourceScan>,
at: std::time::Instant,
}
type StaleKey = (String, String, String, PathBuf, String);
struct ScanCache {
mind_home: PathBuf,
by_record: std::collections::HashMap<String, CachedScan>,
stale: std::collections::HashMap<StaleKey, (bool, std::time::Instant)>,
}
static SCAN_CACHE: std::sync::Mutex<Option<ScanCache>> = std::sync::Mutex::new(None);
fn build_source_scan(items: Vec<catalog::CatalogItem>) -> SourceScan {
let dep_index = crate::deps::DepIndex::new(&items);
let deps: Vec<Vec<String>> = (0..items.len())
.map(|node| sanitize_dep_keys(dep_index.direct_keys(node, &read_item_text)))
.collect();
let available = items
.iter()
.zip(&deps)
.map(|(it, deps)| 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.display_description().as_deref().map(strip_ansi),
path: it.path.clone(),
deps: deps.clone(),
})
.collect();
SourceScan {
items,
deps,
available,
}
}
fn scan_catalog_with(
slot: &mut Option<ScanCache>,
paths: &Paths,
registry: &Registry,
refresh: Refresh<'_>,
now: std::time::Instant,
) -> Result<Vec<std::sync::Arc<SourceScan>>> {
let (mut prev, stale) = match slot.take() {
Some(c) if c.mind_home == paths.mind_home => (c.by_record, c.stale),
_ => Default::default(),
};
let mut next = std::collections::HashMap::new();
let mut scans = Vec::with_capacity(registry.sources.len());
for source in ®istry.sources {
let record = serde_json::to_string(source).ok();
let cached = record
.as_ref()
.and_then(|r| prev.remove(r))
.filter(|c| !source.is_linked() || !refresh.due(&source.name, c.at, now));
let cached = match cached {
Some(c) => c,
None => match catalog::scan_one(paths, source) {
Ok(items) => CachedScan {
scan: std::sync::Arc::new(build_source_scan(items)),
at: now,
},
Err(e) => {
next.extend(prev);
*slot = Some(ScanCache {
mind_home: paths.mind_home.clone(),
by_record: next,
stale,
});
return Err(e);
}
},
};
scans.push(cached.scan.clone());
if let Some(r) = record {
next.insert(r, cached);
}
}
*slot = Some(ScanCache {
mind_home: paths.mind_home.clone(),
by_record: next,
stale,
});
Ok(scans)
}
pub fn load(paths: &Paths) -> Result<Snapshot> {
let lock = lock::open(paths)?;
let _guard = lock.read()?;
load_inner(paths, Refresh::All)
}
pub fn try_poll(paths: &Paths) -> Option<Snapshot> {
try_poll_with(paths, Refresh::All)
}
pub fn try_poll_focused(
paths: &Paths,
on_screen: &std::collections::HashSet<String>,
) -> Option<Snapshot> {
try_poll_with(paths, Refresh::Focused(on_screen))
}
fn try_poll_with(paths: &Paths, refresh: Refresh<'_>) -> Option<Snapshot> {
let lock = lock::open(paths).ok()?;
let _guard = lock.try_read()?;
load_inner(paths, refresh).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 !crate::namespace::item_expands_tokens(item, &item.path, &file) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&file) {
buf.push_str(&content);
buf.push('\n');
}
}
buf
}
type StaleFlags<'a> = (
&'a mut std::collections::HashMap<StaleKey, (bool, std::time::Instant)>,
&'a mut std::collections::HashMap<StaleKey, (bool, std::time::Instant)>,
);
fn tiered_stale(
(prev, next): StaleFlags<'_>,
key: StaleKey,
refresh: Refresh<'_>,
source: &str,
now: std::time::Instant,
compute: impl FnOnce() -> bool,
) -> bool {
let (stale, at) = match prev.remove(&key) {
Some((stale, at)) if !refresh.due(source, at, now) => (stale, at),
_ => (compute(), now),
};
next.insert(key, (stale, at));
stale
}
fn load_inner(paths: &Paths, refresh: Refresh<'_>) -> Result<Snapshot> {
let registry = Registry::load(paths)?;
let manifest = Manifest::load(paths)?;
let now = std::time::Instant::now();
let mut slot = SCAN_CACHE.lock().unwrap_or_else(|e| e.into_inner());
let scans = scan_catalog_with(&mut slot, paths, ®istry, refresh, now)?;
let cache = slot.as_mut().expect("scan_catalog_with fills the slot");
let mut prev_stale = std::mem::take(&mut cache.stale);
let mut next_stale = std::collections::HashMap::with_capacity(manifest.items.len());
let catalog_items: Vec<&catalog::CatalogItem> =
scans.iter().flat_map(|s| s.items.iter()).collect();
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 mut by_identity: std::collections::HashMap<
(&str, ItemKind, &str),
(&catalog::CatalogItem, &Vec<String>),
> = std::collections::HashMap::with_capacity(catalog_items.len());
for scan in &scans {
for (ci, deps) in scan.items.iter().zip(&scan.deps) {
by_identity
.entry((ci.source.as_str(), ci.kind, ci.name.as_str()))
.or_insert((ci, deps));
}
}
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 entry = by_identity.get(&(it.source.as_str(), it.kind, it.bare_name.as_str()));
let matched = entry.map(|(ci, _)| *ci);
let deps: Vec<String> = entry.map(|(_, d)| (*d).clone()).unwrap_or_default();
let ignore_set = matched.and_then(|ci| ci.ignore_set().ok());
let stale = matched.is_some_and(|ci| {
let effective = ci.effective_name();
let key: StaleKey = (
it.key().into(),
it.hash.clone(),
it.name.clone(),
ci.path.clone(),
effective.clone(),
);
let flags = (&mut prev_stale, &mut next_stale);
tiered_stale(flags, key, refresh, &ci.source, now, || {
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 = effective != 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(),
profiles: it.profiles.clone(),
explicit: it.explicit,
});
}
cache.stale = next_stale;
drop(slot);
let available: Vec<SnapshotAvailable> = scans
.iter()
.flat_map(|s| s.available.iter().cloned())
.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.iter() {
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
};
let source_profiles: std::collections::HashMap<String, SourceProfiles> = registry
.sources
.iter()
.filter_map(|s| {
let table = crate::commands::source_profile_table(paths, s).ok()??;
Some((
s.name.clone(),
SourceProfiles {
declared: table.keys().cloned().collect(),
enabled: s.profiles.clone().unwrap_or_default(),
},
))
})
.collect();
Ok(Snapshot {
installed,
available,
unmanaged,
source_names,
suggestions,
lobes,
source_namespaces,
source_profiles,
hook_results: crate::hook_log::read_history(&crate::hook_log::history_file(
&paths.mind_home,
))
.unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash::hash_path;
use crate::paths::Paths;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Instant;
#[test]
fn an_off_screen_stale_flag_is_reused_until_the_slow_interval() {
let key: StaleKey = (
"skill:a".into(),
"h".into(),
"a".into(),
PathBuf::from("/x"),
"a".into(),
);
let (mut prev, mut next) = Default::default();
let t0 = Instant::now();
let none = std::collections::HashSet::new();
let off = Refresh::Focused(&none);
let shown = std::collections::HashSet::from(["src".to_string()]);
let on = Refresh::Focused(&shown);
let mut run = |refresh, now, value: bool| {
let prev_map: &mut std::collections::HashMap<_, _> = &mut prev;
let next_map: &mut std::collections::HashMap<_, _> = &mut next;
let got = tiered_stale(
(prev_map, next_map),
key.clone(),
refresh,
"src",
now,
|| value,
);
std::mem::swap(prev_map, next_map);
next_map.clear();
got
};
assert!(!run(off, t0, false), "no previous flag: computed");
let soon = t0 + std::time::Duration::from_secs(1);
assert!(!run(off, soon, true), "off screen: reused");
assert!(run(on, soon, true), "on screen: recomputed");
let later = soon + OFF_SCREEN_REFRESH;
assert!(run(off, soon, false), "off screen: reused again");
assert!(
!run(off, later, false),
"off screen, interval passed: recomputed"
);
assert!(run(Refresh::All, later, true), "a full load recomputes");
}
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 meld_fixture(paths: &Paths, base: &std::path::Path, files: &[(&str, &str)]) -> PathBuf {
meld_fixture_pinned(paths, base, files, crate::commands::PinRequest::None)
}
fn meld_fixture_pinned(
paths: &Paths,
base: &std::path::Path,
files: &[(&str, &str)],
pin: crate::commands::PinRequest,
) -> PathBuf {
use std::process::Command;
let src = base.join("fixture-source");
for (rel, content) in files {
let file = src.join(rel);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, content).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,
pin,
None,
crate::hook::Accept::default(),
None,
)
.expect("meld");
src
}
fn scan_all(
slot: &mut Option<ScanCache>,
paths: &Paths,
registry: &Registry,
) -> Result<Vec<std::sync::Arc<SourceScan>>> {
scan_catalog_with(slot, paths, registry, Refresh::All, Instant::now())
}
fn skill_names(scans: &[std::sync::Arc<SourceScan>]) -> Vec<String> {
let mut names: Vec<String> = scans
.iter()
.flat_map(|s| s.items.iter().map(|i| i.name.clone()))
.collect();
names.sort();
names
}
#[test]
fn a_cloned_source_is_rescanned_only_when_its_record_changes() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
meld_fixture_pinned(
&paths,
&base,
&[("skills/a/SKILL.md", "---\ndescription: a\n---\n# a\n")],
crate::commands::PinRequest::Freeze(None),
);
let mut registry = Registry::load(&paths).unwrap();
assert!(!registry.sources[0].is_linked(), "fixture must be a clone");
let mut slot = None;
let first = scan_all(&mut slot, &paths, ®istry).unwrap();
assert_eq!(skill_names(&first), ["a"]);
let clone = registry.sources[0].clone_dir(&paths);
std::fs::create_dir_all(clone.join("skills/b")).unwrap();
std::fs::write(clone.join("skills/b/SKILL.md"), "# b\n").unwrap();
let second = scan_all(&mut slot, &paths, ®istry).unwrap();
assert!(std::sync::Arc::ptr_eq(&first[0], &second[0]));
assert_eq!(skill_names(&second), ["a"]);
registry.sources[0].commit = Some("0".repeat(40));
let third = scan_all(&mut slot, &paths, ®istry).unwrap();
assert_eq!(skill_names(&third), ["a", "b"]);
let (other, other_base) = temp_paths();
let fourth = scan_all(&mut slot, &other, ®istry);
assert!(!fourth.is_ok_and(|s| std::sync::Arc::ptr_eq(&s[0], &third[0])));
cleanup(&base);
cleanup(&other_base);
}
#[test]
fn a_source_that_fails_to_scan_keeps_the_others_cached() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
meld_fixture_pinned(
&paths,
&base,
&[("skills/a/SKILL.md", "---\ndescription: a\n---\n# a\n")],
crate::commands::PinRequest::Freeze(None),
);
let registry = Registry::load(&paths).unwrap();
let mut slot = None;
let first = scan_all(&mut slot, &paths, ®istry).unwrap();
let mut broken = Registry::load(&paths).unwrap();
let mut missing = broken.sources[0].clone();
missing.name = format!("{}-missing", missing.name);
missing.repo = format!("{}-missing", missing.repo);
assert!(!missing.clone_dir(&paths).exists());
broken.sources.push(missing);
assert!(scan_all(&mut slot, &paths, &broken).is_err());
let again = scan_all(&mut slot, &paths, ®istry).unwrap();
assert!(
std::sync::Arc::ptr_eq(&first[0], &again[0]),
"the healthy source's scan must survive the failed load"
);
cleanup(&base);
}
#[test]
fn a_linked_source_is_rescanned_on_every_full_load() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = meld_fixture(
&paths,
&base,
&[("skills/a/SKILL.md", "---\ndescription: a\n---\n# a\n")],
);
let registry = Registry::load(&paths).unwrap();
assert!(registry.sources[0].is_linked(), "fixture must be linked");
let mut slot = None;
let first = scan_all(&mut slot, &paths, ®istry).unwrap();
assert_eq!(skill_names(&first), ["a"]);
std::fs::create_dir_all(src.join("skills/b")).unwrap();
std::fs::write(src.join("skills/b/SKILL.md"), "# b\n").unwrap();
let second = scan_all(&mut slot, &paths, ®istry).unwrap();
assert_eq!(skill_names(&second), ["a", "b"]);
cleanup(&base);
}
#[test]
fn a_linked_source_off_screen_is_rescanned_on_the_slow_interval() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = meld_fixture(
&paths,
&base,
&[("skills/a/SKILL.md", "---\ndescription: a\n---\n# a\n")],
);
let registry = Registry::load(&paths).unwrap();
let name = registry.sources[0].name.clone();
let add = |n: &str| {
std::fs::create_dir_all(src.join("skills").join(n)).unwrap();
std::fs::write(src.join("skills").join(n).join("SKILL.md"), "# x\n").unwrap();
};
let t0 = Instant::now();
let mut slot = None;
scan_catalog_with(&mut slot, &paths, ®istry, Refresh::All, t0).unwrap();
add("b");
let none = std::collections::HashSet::new();
let off = Refresh::Focused(&none);
let early = t0 + OFF_SCREEN_REFRESH - std::time::Duration::from_millis(1);
let s = scan_catalog_with(&mut slot, &paths, ®istry, off, early).unwrap();
assert_eq!(skill_names(&s), ["a"]);
let shown = std::collections::HashSet::from([name]);
let on = Refresh::Focused(&shown);
let s = scan_catalog_with(&mut slot, &paths, ®istry, on, early).unwrap();
assert_eq!(skill_names(&s), ["a", "b"]);
add("c");
let s =
scan_catalog_with(&mut slot, &paths, ®istry, off, t0 + OFF_SCREEN_REFRESH).unwrap();
assert_eq!(
skill_names(&s),
["a", "b"],
"the on-screen scan restarted the clock"
);
let late = early + OFF_SCREEN_REFRESH;
let s = scan_catalog_with(&mut slot, &paths, ®istry, off, late).unwrap();
assert_eq!(skill_names(&s), ["a", "b", "c"]);
cleanup(&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 snapshot_carries_declared_and_enabled_profiles() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
meld_fixture(
&paths,
&base,
&[
("skills/a/SKILL.md", "---\ndescription: a\n---\n# a\n"),
("skills/b/SKILL.md", "---\ndescription: b\n---\n# b\n"),
(
"mind.toml",
"[profiles]\nfull = [\"skill:b\"]\ndefault = [\"skill:a\"]\n",
),
],
);
let name = Registry::load(&paths).unwrap().sources[0].name.clone();
let snap = load(&paths).unwrap();
assert_eq!(
snap.source_profiles.get(&name),
Some(&SourceProfiles {
declared: vec!["default".to_string(), "full".to_string()],
enabled: vec![],
})
);
let mut reg = Registry::load(&paths).unwrap();
reg.sources[0].profiles = Some(vec!["full".to_string()]);
reg.save(&paths).unwrap();
let snap = load(&paths).unwrap();
assert_eq!(
snap.source_profiles[&name].enabled,
vec!["full".to_string()]
);
cleanup(&base);
}
#[test]
fn snapshot_has_no_profiles_entry_for_a_plain_source() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
meld_fixture(
&paths,
&base,
&[("skills/a/SKILL.md", "---\ndescription: a\n---\n# a\n")],
);
assert!(load(&paths).unwrap().source_profiles.is_empty());
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,
crate::hook::Accept::default(),
None,
)
.expect("meld");
crate::commands::learn(
&paths,
"skill:review",
false,
crate::commands::InstallFlow {
yes: true,
clobber: crate::commands::Clobber::Force,
accept: crate::hook::Accept::ALL,
},
)
.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,
crate::hook::Accept::default(),
None,
)
.expect("meld");
crate::commands::learn(
&paths,
"skill:review",
false,
crate::commands::InstallFlow {
yes: true,
clobber: crate::commands::Clobber::Force,
accept: crate::hook::Accept::ALL,
},
)
.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);
}
#[test]
fn available_workflow_description_is_composed_with_when_to_use() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
meld_fixture(
&paths,
&base,
&[(
"workflows/review.js",
"export const meta = {\n name: 'review-changes',\n \
description: 'Review changed files',\n whenToUse: 'before a PR',\n}\n",
)],
);
let snap = load(&paths).expect("load should succeed");
let wf = snap
.available
.iter()
.find(|a| a.kind == ItemKind::Workflow)
.expect("the melded workflow item must appear in `available`");
assert_eq!(
wf.description.as_deref(),
Some("Review changed files - before a PR"),
"an available workflow's description must be the composed \
\"description - whenToUse\" pair (display_description, WF-51), \
not the bare `meta.description` alone: {:?}",
wf.description
);
cleanup(&base);
}
#[test]
fn an_installed_workflow_row_is_bare_where_the_available_row_composes() {
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();
meld_fixture(
&paths,
&base,
&[(
"workflows/review.js",
"export const meta = {\n name: 'review',\n \
description: 'Review changed files',\n whenToUse: 'before a PR',\n}\n",
)],
);
crate::commands::learn(
&paths,
"workflow:review",
false,
crate::commands::InstallFlow {
yes: true,
clobber: crate::commands::Clobber::Force,
accept: crate::hook::Accept::ALL,
},
)
.expect("learn");
let snap = load(&paths).expect("load should succeed");
let installed = snap
.installed
.iter()
.find(|i| i.kind == ItemKind::Workflow)
.expect("the learned workflow must appear in `installed`");
assert_eq!(
installed.description.as_deref(),
Some("Review changed files"),
"an INSTALLED workflow row is manifest-derived, and the manifest \
never records `whenToUse` (WF-51), so its description must be the \
bare one: {:?}",
installed.description
);
let available = snap
.available
.iter()
.find(|a| a.kind == ItemKind::Workflow)
.expect("the same workflow must still appear in `available`");
assert_eq!(
available.description.as_deref(),
Some("Review changed files - before a PR"),
"the AVAILABLE row for the same workflow is catalog-derived, so it \
must still compose the pair even once the item is installed: {:?}",
available.description
);
cleanup(&base);
}
#[test]
fn an_available_workflow_with_only_when_to_use_shows_it_as_the_description() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
meld_fixture(
&paths,
&base,
&[(
"workflows/review.js",
"export const meta = {\n name: 'review',\n whenToUse: 'before a PR',\n}\n",
)],
);
let snap = load(&paths).expect("load should succeed");
let wf = snap
.available
.iter()
.find(|a| a.kind == ItemKind::Workflow)
.expect("the melded workflow item must appear in `available`");
assert_eq!(
wf.description.as_deref(),
Some("before a PR"),
"a workflow with a `whenToUse` and no `description` must show the \
`whenToUse` alone, not nothing and not a dangling \" - \" \
separator: {:?}",
wf.description
);
cleanup(&base);
}
#[test]
fn a_token_in_an_unlisted_bundled_file_draws_no_tui_dependency_edge() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
meld_fixture(
&paths,
&base,
&[
(
"skills/review/SKILL.md",
"---\ndescription: review skill\n---\n# review\nsee {{self}}\n",
),
("skills/review/resources/pr.py", "# {{ns:helper}}\n"),
(
"skills/linked/SKILL.md",
"---\ndescription: linked skill, references {{ns:helper}} in prose\n---\n# linked\n",
),
(
"skills/helper/SKILL.md",
"---\ndescription: helper skill\n---\n# helper\n",
),
],
);
let snap = load(&paths).expect("load should succeed");
let review = snap
.available
.iter()
.find(|a| a.kind == ItemKind::Skill && a.name == "review")
.expect("the review skill must appear in `available`");
assert!(
!review.deps.iter().any(|d| d.contains("helper")),
"a {{{{ns:helper}}}} token in a non-markdown, non-expand-listed \
bundled file must not draw a dependency edge (install never \
expands it there either): {:?}",
review.deps
);
let linked = snap
.available
.iter()
.find(|a| a.kind == ItemKind::Skill && a.name == "linked")
.expect("the linked skill must appear in `available`");
assert!(
linked.deps.iter().any(|d| d.contains("helper")),
"a {{{{ns:helper}}}} token in SKILL.md itself must still draw a \
dependency edge, proving the negative case above is a real gate \
and not an always-empty dep list: {:?}",
linked.deps
);
cleanup(&base);
}
}