use std::cell::RefCell;
use std::path::{Path, PathBuf};
use crate::config::Config;
use crate::error::{ItemKind, MindError, Result};
use crate::policy::Policy;
thread_local! {
static LOCAL_LOBE: RefCell<Option<Lobe>> = const { RefCell::new(None) };
}
fn local_lobe_override() -> Option<Lobe> {
LOCAL_LOBE.with(|c| c.borrow().clone())
}
#[must_use = "dropping the guard immediately ends the --local scope"]
pub struct LocalLobeGuard(());
impl Drop for LocalLobeGuard {
fn drop(&mut self) {
LOCAL_LOBE.with(|c| *c.borrow_mut() = None);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Lobe {
pub path: PathBuf,
pub kinds: Option<Vec<ItemKind>>,
}
impl Lobe {
pub fn all_kinds(path: PathBuf) -> Self {
Lobe { path, kinds: None }
}
pub fn admits(&self, kind: ItemKind) -> bool {
match &self.kinds {
None => true,
Some(kinds) => kinds.contains(&kind),
}
}
pub(crate) fn reachable(&self) -> bool {
self.path.parent().is_none_or(|p| p.exists())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Global,
Project,
}
#[derive(Debug)]
pub struct Preset {
pub name: &'static str,
pub rel_path: &'static str,
pub kinds: &'static [ItemKind],
pub marker_rel: &'static str,
pub scope: Scope,
}
pub const PRESETS: &[Preset] = &[
Preset {
name: "gemini",
rel_path: ".gemini/config",
kinds: &[ItemKind::Skill],
marker_rel: ".gemini",
scope: Scope::Global,
},
Preset {
name: "codex",
rel_path: ".agents",
kinds: &[ItemKind::Skill],
marker_rel: ".codex",
scope: Scope::Global,
},
Preset {
name: "universal",
rel_path: ".agents",
kinds: &[ItemKind::Skill],
marker_rel: ".agents",
scope: Scope::Global,
},
Preset {
name: "windsurf",
rel_path: ".windsurf",
kinds: &[ItemKind::Skill],
marker_rel: ".codeium/windsurf",
scope: Scope::Project,
},
];
pub fn lookup_preset(name: &str) -> Result<&'static Preset> {
PRESETS
.iter()
.find(|p| p.name == name)
.ok_or_else(|| MindError::UnknownPreset {
name: name.to_string(),
})
}
#[derive(Debug, Clone)]
pub struct Paths {
pub mind_home: PathBuf,
pub claude_home: PathBuf,
}
impl Paths {
pub fn resolve() -> Result<Self> {
let mind_home = match std::env::var_os("MIND_HOME") {
Some(p) => PathBuf::from(p),
None => home()?.join(".mind"),
};
let claude_home = match std::env::var_os("MIND_DEFAULT_LOBE")
.or_else(|| std::env::var_os("CLAUDE_HOME"))
{
Some(p) => PathBuf::from(p),
None => home()?.join(".claude"),
};
Ok(Self {
mind_home,
claude_home,
})
}
pub fn lock_file(&self) -> PathBuf {
self.mind_home.join(".lock")
}
pub fn sources_file(&self) -> PathBuf {
self.mind_home.join("sources.json")
}
pub fn manifest_file(&self) -> PathBuf {
self.mind_home.join("manifest.json")
}
pub fn config_file(&self) -> PathBuf {
self.mind_home.join("config.toml")
}
pub fn sources_dir(&self) -> PathBuf {
self.mind_home.join("sources")
}
pub fn store_dir(&self) -> PathBuf {
self.mind_home.join("store")
}
pub fn store_item(&self, kind: ItemKind, name: &str) -> PathBuf {
self.mind_home.join(self.store_rel(kind, name))
}
pub fn store_rel(&self, kind: ItemKind, name: &str) -> String {
format!("store/{}/{}", kind.as_str(), name)
}
pub fn tmp_dir(&self) -> PathBuf {
self.mind_home.join(".tmp")
}
pub fn staging_path(&self, kind: ItemKind, name: &str) -> PathBuf {
self.tmp_dir()
.join("staging")
.join(kind.as_str())
.join(name)
}
pub fn backup_path(&self, kind: ItemKind, name: &str) -> PathBuf {
self.tmp_dir().join("backup").join(kind.as_str()).join(name)
}
pub fn default_link_rel(&self, kind: ItemKind, name: &str) -> Option<String> {
let dir = kind.dir();
match kind {
ItemKind::Skill => Some(format!("{dir}/{name}")),
ItemKind::Agent | ItemKind::Rule | ItemKind::Command => {
Some(format!("{dir}/{name}.md"))
}
ItemKind::Tool => None,
}
}
pub fn agent_homes(&self) -> Result<Vec<Lobe>> {
if let Some(lobe) = local_lobe_override() {
return Ok(vec![lobe]);
}
let user_homes: Vec<Lobe> = {
let mut h: Vec<Lobe> = Vec::new();
if let Some(raw) = std::env::var_os("MIND_AGENT_HOMES") {
let parsed = raw
.to_string_lossy()
.split(':')
.filter(|p| !p.is_empty())
.map(|p| Ok(Lobe::all_kinds(absolute_home(p)?)))
.collect::<Result<Vec<_>>>()?;
if !parsed.is_empty() {
h = parsed;
}
}
if h.is_empty() {
let configured = Config::load(self)?.lobes;
if !configured.is_empty() {
h = configured
.iter()
.map(|e| {
Ok(Lobe {
path: absolute_home(e.path())?,
kinds: e.kinds().map(<[ItemKind]>::to_vec),
})
})
.collect::<Result<Vec<_>>>()?;
}
}
if h.is_empty() {
h = vec![Lobe::all_kinds(make_absolute(self.claude_home.clone())?)];
}
h
};
match Policy::load()? {
Some(policy) if policy.lobes_lock() => {
let targets = policy.lobes_targets();
if targets.is_empty() {
Ok(vec![Lobe::all_kinds(make_absolute(
self.claude_home.clone(),
)?)])
} else {
let resolved: Vec<Lobe> = targets
.iter()
.map(|p| Ok(Lobe::all_kinds(absolute_home(p)?)))
.collect::<Result<_>>()?;
Ok(dedup_lobes(resolved))
}
}
Some(policy) => {
let mut result: Vec<Lobe> = Vec::new();
for p in policy.lobes_targets() {
result.push(Lobe::all_kinds(absolute_home(p)?));
}
for h in user_homes {
result.push(h);
}
Ok(dedup_lobes(result))
}
None => {
Ok(dedup_lobes(user_homes))
}
}
}
pub fn preset_lobe(name: &str) -> Result<Lobe> {
let (lobe, _preset) = resolve_lobe(None, Some(name), None)?;
Ok(lobe)
}
pub fn scope_to_local_lobe(lobe: Lobe) -> LocalLobeGuard {
LOCAL_LOBE.with(|c| *c.borrow_mut() = Some(lobe));
LocalLobeGuard(())
}
pub fn detect_local_lobe(&self) -> Result<Option<Lobe>> {
let cwd = std::env::current_dir().map_err(|e| MindError::io(".", e))?;
let cwd_c = canonicalize_existing(&cwd);
let global_home = make_absolute(self.claude_home.clone())?;
let global_home_c = canonicalize_existing(&global_home);
let home_c = home().ok().map(|h| canonicalize_existing(&h));
let mut best: Option<(usize, Lobe)> = None;
for lobe in self.agent_homes()? {
let lp = canonicalize_existing(&lobe.path);
if lp == cwd_c || !lp.starts_with(&cwd_c) {
continue;
}
if lp == global_home_c {
continue;
}
if let Some(home_c) = &home_c
&& lp.starts_with(home_c)
&& home_c.starts_with(&cwd_c)
{
continue;
}
let depth = lp.components().count();
if best.as_ref().is_none_or(|(d, _)| depth > *d) {
best = Some((depth, lobe));
}
}
Ok(best.map(|(_, l)| l))
}
pub fn detect_base() -> Result<PathBuf> {
match std::env::var_os("MIND_DETECT_HOME") {
Some(p) => Ok(PathBuf::from(p)),
None => home(),
}
}
pub fn detect_homes() -> Result<Vec<(&'static str, Lobe)>> {
let base = Self::detect_base()?;
let mut found = Vec::new();
for preset in PRESETS {
if base.join(preset.marker_rel).is_dir() {
found.push((
preset.name,
Lobe {
path: base.join(preset.rel_path),
kinds: Some(preset.kinds.to_vec()),
},
));
}
}
Ok(found)
}
pub fn default_lobe(&self) -> String {
match std::env::var_os("MIND_DEFAULT_LOBE").or_else(|| std::env::var_os("CLAUDE_HOME")) {
Some(v) => v.to_string_lossy().into_owned(),
None => "~/.claude".to_string(),
}
}
pub fn ensure_config(&self) -> Result<()> {
if !self.config_file().exists() {
Config {
lobes: vec![crate::config::LobeEntry::bare(self.default_lobe())],
..Default::default()
}
.save(self)?;
}
Ok(())
}
pub fn ensure_layout(&self) -> Result<()> {
mkdir_p(&self.mind_home)?;
mkdir_p(&self.sources_dir())?;
mkdir_p(&self.store_dir())?;
self.ensure_config()?;
Ok(())
}
pub fn atomic_write(target: &std::path::Path, bytes: &[u8]) -> Result<()> {
let dir = target
.parent()
.ok_or_else(|| MindError::io(target, std::io::Error::other("no parent directory")))?;
let file_name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".into());
let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
let tmp_path = dir.join(&tmp_name);
use std::io::Write as _;
let write_result = std::fs::File::create(&tmp_path)
.and_then(|mut f| {
f.write_all(bytes)?;
f.sync_all()
})
.map_err(|e| MindError::io(&tmp_path, e));
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp_path);
return Err(e);
}
std::fs::rename(&tmp_path, target).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
MindError::io(target, e)
})
}
}
fn home() -> Result<PathBuf> {
dirs::home_dir().ok_or(MindError::HomeDirNotFound)
}
fn expand_home(path: &str) -> PathBuf {
if path == "~" {
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path));
}
if let Some(rest) = path.strip_prefix("~/")
&& let Some(h) = dirs::home_dir()
{
return h.join(rest);
}
PathBuf::from(path)
}
fn absolute_home(path: &str) -> Result<PathBuf> {
make_absolute(expand_home(path))
}
fn make_absolute(path: PathBuf) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path);
}
let cwd = std::env::current_dir().map_err(|e| MindError::io(".", e))?;
Ok(cwd.join(path))
}
fn canonicalize_existing(path: &Path) -> PathBuf {
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut cur: &Path = path;
loop {
if let Ok(resolved) = cur.canonicalize() {
let mut out = resolved;
for name in tail.iter().rev() {
out.push(name);
}
return out;
}
match (cur.file_name(), cur.parent()) {
(Some(name), Some(parent)) => {
tail.push(name.to_owned());
cur = parent;
}
_ => return path.to_path_buf(),
}
}
}
fn dedup_lobes(lobes: Vec<Lobe>) -> Vec<Lobe> {
let mut seen = std::collections::HashSet::new();
lobes
.into_iter()
.filter(|l| seen.insert(l.path.clone()))
.collect()
}
pub(crate) fn resolve_lobe(
base: Option<&str>,
preset: Option<&str>,
subdir: Option<&str>,
) -> Result<(Lobe, Option<&'static Preset>)> {
let check_base = |base_str: &str| -> Result<PathBuf> {
let expanded = expand_home(base_str);
let home = dirs::home_dir();
let is_home = home.as_deref().is_some_and(|h| expanded == h);
if !is_home && !expanded.exists() {
return Err(MindError::LobeBaseMissing { path: expanded });
}
make_absolute(expanded)
};
if let Some(name) = preset {
let p = lookup_preset(name)?;
let base_path: PathBuf = match base {
Some(b) => check_base(b)?,
None => match p.scope {
Scope::Global => make_absolute(home()?)?,
Scope::Project => {
make_absolute(std::env::current_dir().map_err(|e| MindError::io(".", e))?)?
}
},
};
let lobe_path = make_absolute(base_path.join(p.rel_path))?;
Ok((
Lobe {
path: lobe_path,
kinds: Some(p.kinds.to_vec()),
},
Some(p),
))
} else if let Some(rel) = subdir {
let base_path: PathBuf = match base {
Some(b) => check_base(b)?,
None => make_absolute(std::env::current_dir().map_err(|e| MindError::io(".", e))?)?,
};
let lobe_path = make_absolute(base_path.join(rel))?;
Ok((
Lobe {
path: lobe_path,
kinds: Some(vec![ItemKind::Skill]),
},
None,
))
} else {
match base {
Some(b) => {
let expanded = expand_home(b);
let lobe_path = make_absolute(expanded)?;
Ok((Lobe::all_kinds(lobe_path), None))
}
None => Err(MindError::LobeTargetRequired),
}
}
}
pub fn mkdir_p(path: &Path) -> Result<()> {
std::fs::create_dir_all(path).map_err(|e| classify_mkdir_error(path, e))
}
fn classify_mkdir_error(path: &Path, e: std::io::Error) -> MindError {
if e.kind() == std::io::ErrorKind::AlreadyExists {
match broken_symlink_component(path) {
Ok(Some((link, target))) => {
return MindError::BrokenSymlinkPath {
path: path.to_path_buf(),
link,
target,
};
}
Err(read_err) => return read_err,
Ok(None) => {}
}
}
MindError::io(path, e)
}
fn broken_symlink_component(path: &Path) -> Result<Option<(PathBuf, PathBuf)>> {
let mut prefix = PathBuf::new();
for comp in path.components() {
prefix.push(comp);
let Ok(md) = std::fs::symlink_metadata(&prefix) else {
continue; };
if !md.file_type().is_symlink() {
continue;
}
let Err(follow_err) = std::fs::metadata(&prefix) else {
continue; };
if follow_err.kind() != std::io::ErrorKind::NotFound {
continue;
}
return resolve_broken_link_display(&prefix, std::fs::read_link(&prefix)).map(Some);
}
Ok(None)
}
fn resolve_broken_link_display(
prefix: &Path,
target: std::io::Result<PathBuf>,
) -> Result<(PathBuf, PathBuf)> {
let target = target.map_err(|e| MindError::io(prefix, e))?;
let display_target = if target.is_absolute() {
target
} else {
prefix
.parent()
.map(|parent| parent.join(&target))
.unwrap_or(target)
};
Ok((prefix.to_path_buf(), display_target))
}
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[test]
fn command_links_under_commands_with_its_effective_name() {
let paths = Paths {
mind_home: PathBuf::from("/mind"),
claude_home: PathBuf::from("/claude"),
};
assert_eq!(
paths
.default_link_rel(ItemKind::Command, "review")
.as_deref(),
Some("commands/review.md")
);
assert_eq!(
paths
.default_link_rel(ItemKind::Command, "jk:review")
.as_deref(),
Some("commands/jk:review.md")
);
assert_eq!(
paths.store_rel(ItemKind::Command, "jk:review"),
"store/command/jk:review"
);
}
#[test]
fn absolute_home_resolves_relative_paths() {
let abs = absolute_home("rellobe").unwrap();
assert!(
abs.is_absolute(),
"relative lobe should become absolute: {abs:?}"
);
assert!(abs.ends_with("rellobe"));
assert_eq!(
absolute_home("/tmp/lobe").unwrap(),
PathBuf::from("/tmp/lobe")
);
}
#[cfg(unix)]
#[test]
fn canonicalize_existing_resolves_a_symlinked_ancestor_with_a_missing_tail() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let root = std::env::temp_dir().join(format!("mind-canon-{}-{n}", std::process::id()));
let real = root.join("real");
std::fs::create_dir_all(&real).unwrap();
let link = root.join("link");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&real, &link).unwrap();
let resolved = canonicalize_existing(&link.join("locallobe"));
let expected = real.canonicalize().unwrap().join("locallobe");
assert_eq!(
resolved, expected,
"the symlinked ancestor should resolve and the missing tail be kept"
);
std::fs::remove_dir_all(&root).ok();
}
#[cfg(unix)]
#[test]
fn mkdir_p_names_a_dangling_symlink_instead_of_reporting_file_exists() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let root = std::env::temp_dir().join(format!("mind-brokenlink-{}-{n}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let link = root.join("brokenlobe");
let missing = root.join("gone");
std::os::unix::fs::symlink(&missing, &link).unwrap();
let err = mkdir_p(&link.join("skills")).expect_err("a dangling-symlink parent must fail");
assert!(
matches!(err, MindError::BrokenSymlinkPath { .. }),
"expected BrokenSymlinkPath, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("brokenlobe") && msg.contains("gone"),
"the message must name both the broken link and its target: {msg}"
);
assert!(
!msg.contains("os error 17"),
"the raw File-exists errno must not be the reported cause: {msg}"
);
assert_eq!(err.kind(), "broken-symlink-path");
assert!(
msg.contains("mind config lobes list"),
"the remedy must name the runnable `mind config lobes list`, not a bare \
`mind config lobes`: {msg}"
);
let good = root.join("real").join("skills");
assert!(
mkdir_p(&good).is_ok(),
"a normal mkdir_p must still succeed"
);
assert!(good.is_dir());
std::fs::remove_dir_all(&root).ok();
}
#[cfg(unix)]
#[test]
fn broken_symlink_component_does_not_misclassify_a_symlink_loop_as_dangling() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let root =
std::env::temp_dir().join(format!("mind-brokenlink-loop-{}-{n}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let loop_link = root.join("loopy");
std::os::unix::fs::symlink(&loop_link, &loop_link).unwrap();
let follow_err = std::fs::metadata(&loop_link)
.expect_err("a self-referential symlink must fail to resolve");
assert_ne!(
follow_err.kind(),
std::io::ErrorKind::NotFound,
"premise of this test: a symlink loop must not present as NotFound: {follow_err:?}"
);
let result = broken_symlink_component(&loop_link).unwrap();
assert!(
result.is_none(),
"a symlink loop (not NotFound) must not be classified as a broken/dangling link: \
{result:?}"
);
std::fs::remove_dir_all(&root).ok();
}
#[cfg(unix)]
#[test]
fn classify_mkdir_error_does_not_reclassify_a_non_already_exists_error() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let root =
std::env::temp_dir().join(format!("mind-mkdirp-guard-{}-{n}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let link = root.join("brokenlobe");
let missing = root.join("gone");
std::os::unix::fs::symlink(&missing, &link).unwrap();
let target = link.join("skills");
assert!(
broken_symlink_component(&target).unwrap().is_some(),
"test setup: the dangling symlink must be detected along this path"
);
let synthetic = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let classified = classify_mkdir_error(&target, synthetic);
assert!(
!matches!(classified, MindError::BrokenSymlinkPath { .. }),
"a PermissionDenied error must not be reclassified as BrokenSymlinkPath: \
{classified:?}"
);
assert!(
matches!(classified, MindError::Io { .. }),
"expected a plain Io error, got {classified:?}"
);
let real_already_exists = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "eexist");
let classified = classify_mkdir_error(&target, real_already_exists);
assert!(
matches!(classified, MindError::BrokenSymlinkPath { .. }),
"an AlreadyExists error with a genuine dangling-symlink ancestor must still be \
reclassified: {classified:?}"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn resolve_broken_link_display_propagates_a_read_link_failure() {
let prefix = PathBuf::from("/some/broken/link");
let synthetic = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let err = resolve_broken_link_display(&prefix, Err(synthetic)).unwrap_err();
assert!(
matches!(err, MindError::Io { .. }),
"a read_link failure must propagate as an Io error, not be swallowed: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("/some/broken/link"),
"the Io error must be tagged with the offending path: {msg}"
);
}
#[test]
fn resolve_broken_link_display_resolves_a_relative_target_against_the_links_parent() {
let prefix = PathBuf::from("/home/user/.claude/brokenlobe");
let (link, target) =
resolve_broken_link_display(&prefix, Ok(PathBuf::from("../gone"))).unwrap();
assert_eq!(link, prefix);
assert_eq!(
target,
PathBuf::from("/home/user/.claude/../gone"),
"a relative target must be joined against the link's own parent directory: {target:?}"
);
let (_, target) =
resolve_broken_link_display(&prefix, Ok(PathBuf::from("/elsewhere/gone"))).unwrap();
assert_eq!(target, PathBuf::from("/elsewhere/gone"));
}
#[test]
fn atomic_write_replaces_target_content() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-paths-test-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("data.json");
std::fs::write(&target, b"old").unwrap();
Paths::atomic_write(&target, b"new").unwrap();
let got = std::fs::read(&target).unwrap();
assert_eq!(got, b"new", "target should contain the new bytes");
let leftover: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(
leftover.is_empty(),
"temp file was not cleaned up: {leftover:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_creates_target_if_absent() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-paths-create-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("new.json");
assert!(!target.exists(), "sanity: target should not exist yet");
Paths::atomic_write(&target, b"{\"x\":1}").unwrap();
assert!(target.exists(), "atomic_write should create the target");
assert_eq!(std::fs::read(&target).unwrap(), b"{\"x\":1}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_errors_and_leaves_no_temp_when_dir_is_missing() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let missing_dir =
std::env::temp_dir().join(format!("mind-paths-missing-{}-{n}", std::process::id()));
let target = missing_dir.join("data.json");
let result = Paths::atomic_write(&target, b"data");
match result {
Err(MindError::Io { .. }) => {}
other => panic!("expected Io error for missing parent dir, got {other:?}"),
}
assert!(
!target.exists(),
"target must not exist after a failed atomic_write"
);
assert!(
!missing_dir.exists(),
"atomic_write must not create the parent directory"
);
}
#[test]
fn atomic_write_preserves_existing_target_on_write_failure() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-paths-failkeep-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let blocker = dir.join("blocker");
std::fs::write(&blocker, b"i am a file").unwrap();
let target = blocker.join("data.json");
let result = Paths::atomic_write(&target, b"new");
assert!(
matches!(result, Err(MindError::Io { .. })),
"expected Io error when temp parent is a file, got {result:?}"
);
assert_eq!(
std::fs::read(&blocker).unwrap(),
b"i am a file",
"unrelated sibling content must be preserved on failure"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_to_pathless_target_is_an_error() {
let result = Paths::atomic_write(std::path::Path::new("/"), b"x");
assert!(
matches!(result, Err(MindError::Io { .. })),
"writing to a target with no usable parent must be an Io error, got {result:?}"
);
}
#[test]
fn atomic_write_uses_same_directory_for_temp() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-paths-samedir-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("sources.json");
Paths::atomic_write(&target, b"[]").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"[]");
let _ = std::fs::remove_dir_all(&dir);
}
fn setup_policy_test(
policy_toml: &str,
) -> (Paths, PathBuf, PathBuf, std::sync::MutexGuard<'static, ()>) {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-policy-lobe-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
let policy_file = base.join("policy.toml");
std::fs::write(&policy_file, policy_toml).unwrap();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
std::env::set_var("MIND_POLICY_FILE", &policy_file);
}
let paths = Paths {
mind_home,
claude_home,
};
(paths, base, policy_file, guard)
}
#[test]
fn pol40_lock_true_uses_exactly_policy_targets() {
let managed = {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!("mind-managed-lobe-{}-{n}", std::process::id()))
};
let policy_toml = format!(
"[lobes]\nlock = true\ntargets = [\"{managed}\"]\n",
managed = managed.display()
);
let (paths, base, _policy_file, _guard) = setup_policy_test(&policy_toml);
let other_lobe = base.join("other-lobe");
let config_toml = format!(
"lobes = [\"{other_lobe}\"]\n",
other_lobe = other_lobe.display()
);
std::fs::write(paths.mind_home.join("config.toml"), &config_toml).unwrap();
let env_lobe = base.join("env-lobe");
unsafe {
std::env::set_var("MIND_AGENT_HOMES", env_lobe.to_str().unwrap());
}
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
assert_eq!(
homes,
vec![managed.clone()],
"POL-40: locked policy must return exactly the managed target, not config/env homes"
);
assert!(
!homes.contains(&other_lobe),
"config lobe must be ignored under lock"
);
assert!(
!homes.contains(&env_lobe),
"MIND_AGENT_HOMES must be ignored under lock"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol40_lock_true_empty_targets_falls_back_to_default() {
let policy_toml = "[lobes]\nlock = true\ntargets = []\n";
let (paths, base, _policy_file, _guard) = setup_policy_test(policy_toml);
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert_eq!(
homes,
vec![paths.claude_home.clone()],
"POL-40: empty targets under a lock must fall back to the default (claude_home)"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol41_lock_false_unions_policy_and_user_homes() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir = std::env::temp_dir().join(format!("mind-pol41-{}-{n}", std::process::id()));
let policy_target = base_dir.join("policy-base");
let user_lobe = base_dir.join("user-lobe");
let policy_toml = format!(
"[lobes]\nlock = false\ntargets = [\"{policy_target}\"]\n",
policy_target = policy_target.display()
);
let (paths, base, _policy_file, _guard) = setup_policy_test(&policy_toml);
let config_toml = format!(
"lobes = [\"{user_lobe}\"]\n",
user_lobe = user_lobe.display()
);
std::fs::write(paths.mind_home.join("config.toml"), &config_toml).unwrap();
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert!(
homes.contains(&policy_target),
"POL-41: policy target must be present in union: {homes:?}"
);
assert!(
homes.contains(&user_lobe),
"POL-41: user lobe must also be present: {homes:?}"
);
assert_eq!(
homes[0], policy_target,
"POL-41: policy target must come first in the union"
);
let deduped: Vec<_> = {
let mut seen = std::collections::HashSet::new();
homes.iter().filter(|h| seen.insert(*h)).cloned().collect()
};
assert_eq!(homes, deduped, "POL-41: result must not contain duplicates");
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn pol41_deduplicates_overlapping_target_and_user_home() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let shared =
std::env::temp_dir().join(format!("mind-pol41-shared-{}-{n}", std::process::id()));
let policy_toml = format!(
"[lobes]\nlock = false\ntargets = [\"{shared}\"]\n",
shared = shared.display()
);
let (paths, base, _policy_file, _guard) = setup_policy_test(&policy_toml);
let config_toml = format!("lobes = [\"{shared}\"]\n", shared = shared.display());
std::fs::write(paths.mind_home.join("config.toml"), &config_toml).unwrap();
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert_eq!(
homes.len(),
1,
"POL-41: identical target + user lobe must be deduped to one entry: {homes:?}"
);
assert_eq!(homes[0], shared);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol4_inert_no_policy_uses_user_config() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-pol4-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
unsafe {
std::env::remove_var("MIND_POLICY_FILE");
std::env::remove_var("MIND_AGENT_HOMES");
}
let user_lobe = base.join("user-lobe");
let config_toml = format!(
"lobes = [\"{user_lobe}\"]\n",
user_lobe = user_lobe.display()
);
std::fs::write(mind_home.join("config.toml"), &config_toml).unwrap();
let paths = Paths {
mind_home,
claude_home,
};
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert_eq!(
homes,
vec![user_lobe.clone()],
"POL-4 inert: without a policy, user config lobes must be used as-is"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol41_unions_policy_with_env_agent_homes() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir =
std::env::temp_dir().join(format!("mind-pol41-env-{}-{n}", std::process::id()));
let policy_target = base_dir.join("policy-base");
let env_lobe = base_dir.join("env-lobe");
let policy_toml = format!(
"[lobes]\nlock = false\ntargets = [\"{policy_target}\"]\n",
policy_target = policy_target.display()
);
let (paths, base, _policy_file, _guard) = setup_policy_test(&policy_toml);
unsafe {
std::env::set_var("MIND_AGENT_HOMES", env_lobe.to_str().unwrap());
}
let homes = paths.agent_homes();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
let homes: Vec<PathBuf> = homes.unwrap().into_iter().map(|l| l.path).collect();
assert_eq!(
homes,
vec![policy_target.clone(), env_lobe.clone()],
"POL-41: unlocked union must be [policy target, env home], targets first, deduped"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn pol40_lock_true_multiple_targets_in_order_ignores_user_homes() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir =
std::env::temp_dir().join(format!("mind-pol40-multi-{}-{n}", std::process::id()));
let t1 = base_dir.join("target-a");
let t2 = base_dir.join("target-b");
let t3 = base_dir.join("target-c");
let policy_toml = format!(
"[lobes]\nlock = true\ntargets = [\"{t1}\", \"{t2}\", \"{t3}\"]\n",
t1 = t1.display(),
t2 = t2.display(),
t3 = t3.display(),
);
let (paths, base, _policy_file, _guard) = setup_policy_test(&policy_toml);
let e1 = base_dir.join("env-1");
let e2 = base_dir.join("env-2");
let env_val = format!("{}:{}", e1.display(), e2.display());
let cfg_lobe = base_dir.join("cfg-lobe");
std::fs::write(
paths.mind_home.join("config.toml"),
format!("lobes = [\"{}\"]\n", cfg_lobe.display()),
)
.unwrap();
unsafe {
std::env::set_var("MIND_AGENT_HOMES", &env_val);
}
let homes = paths.agent_homes();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
let homes: Vec<PathBuf> = homes.unwrap().into_iter().map(|l| l.path).collect();
assert_eq!(
homes,
vec![t1.clone(), t2.clone(), t3.clone()],
"POL-40: locked policy must return exactly the targets in order"
);
assert!(!homes.contains(&e1) && !homes.contains(&e2));
assert!(!homes.contains(&cfg_lobe));
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn pol41_multiple_targets_and_homes_exact_deduped_order() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir =
std::env::temp_dir().join(format!("mind-pol41-multi-{}-{n}", std::process::id()));
let t1 = base_dir.join("t1");
let t2 = base_dir.join("t2"); let u_extra = base_dir.join("u-extra");
let policy_toml = format!(
"[lobes]\nlock = false\ntargets = [\"{t1}\", \"{t2}\"]\n",
t1 = t1.display(),
t2 = t2.display(),
);
let (paths, base, _policy_file, _guard) = setup_policy_test(&policy_toml);
let env_val = format!("{}:{}", t2.display(), u_extra.display());
unsafe {
std::env::set_var("MIND_AGENT_HOMES", &env_val);
}
let homes = paths.agent_homes();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
let homes: Vec<PathBuf> = homes.unwrap().into_iter().map(|l| l.path).collect();
assert_eq!(
homes,
vec![t1.clone(), t2.clone(), u_extra.clone()],
"POL-41: targets first in order, then non-duplicate user homes; overlap dropped"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn pol4_inert_no_policy_uses_env_agent_homes_unchanged() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-pol4-env-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
let env1 = base.join("env-home-1");
let env2 = base.join("env-home-2");
let env_val = format!("{}:{}", env1.display(), env2.display());
unsafe {
std::env::remove_var("MIND_POLICY_FILE");
std::env::set_var("MIND_AGENT_HOMES", &env_val);
}
let paths = Paths {
mind_home,
claude_home,
};
let homes = paths.agent_homes();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
let homes: Vec<PathBuf> = homes.unwrap().into_iter().map(|l| l.path).collect();
assert_eq!(
homes,
vec![env1.clone(), env2.clone()],
"POL-4 inert: without a policy, $MIND_AGENT_HOMES homes must be returned unchanged"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol40_lock_target_tilde_is_expanded_to_absolute() {
let policy_toml = "[lobes]\nlock = true\ntargets = [\"~/.claude-managed\"]\n";
let (paths, base, _policy_file, _guard) = setup_policy_test(policy_toml);
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert_eq!(homes.len(), 1);
let got = &homes[0];
assert!(
got.is_absolute(),
"tilde target must resolve absolute: {got:?}"
);
assert!(
got.ends_with(".claude-managed"),
"tilde target must expand under home: {got:?}"
);
let home = dirs::home_dir().expect("home dir for tilde expansion");
assert_eq!(
got,
&home.join(".claude-managed"),
"POL-40: `~` target must expand to <home>/.claude-managed"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol40_lock_relative_target_becomes_absolute() {
let policy_toml = "[lobes]\nlock = true\ntargets = [\"managed-rel-lobe\"]\n";
let (paths, base, _policy_file, _guard) = setup_policy_test(policy_toml);
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert_eq!(homes.len(), 1);
let got = &homes[0];
assert!(
got.is_absolute(),
"POL-40: a relative target must be resolved to absolute: {got:?}"
);
assert!(
got.ends_with("managed-rel-lobe"),
"the relative component must be preserved: {got:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol41_unlocked_relative_target_becomes_absolute() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir =
std::env::temp_dir().join(format!("mind-pol41-rel-{}-{n}", std::process::id()));
let user_lobe = base_dir.join("user-lobe");
let policy_toml = "[lobes]\nlock = false\ntargets = [\"unlocked-rel-lobe\"]\n";
let (paths, base, _policy_file, _guard) = setup_policy_test(policy_toml);
unsafe {
std::env::set_var("MIND_AGENT_HOMES", user_lobe.to_str().unwrap());
}
let homes = paths.agent_homes();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
let homes: Vec<PathBuf> = homes.unwrap().into_iter().map(|l| l.path).collect();
assert_eq!(homes.len(), 2, "target + one user home: {homes:?}");
assert!(
homes[0].is_absolute() && homes[0].ends_with("unlocked-rel-lobe"),
"POL-41: unlocked relative target must resolve absolute, first: {homes:?}"
);
assert_eq!(homes[1], user_lobe, "user home follows the target");
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn pol41_duplicate_targets_collapse_to_one_entry() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir =
std::env::temp_dir().join(format!("mind-pol41-dup-{}-{n}", std::process::id()));
let dup_target = base_dir.join("dup-target");
let user_lobe = base_dir.join("user-lobe");
let policy_toml = format!(
"[lobes]\nlock = false\ntargets = [\"{dup}\", \"{dup}\"]\n",
dup = dup_target.display(),
);
let (paths, base, _policy_file, _guard) = setup_policy_test(&policy_toml);
let config_toml = format!(
"lobes = [\"{user_lobe}\"]\n",
user_lobe = user_lobe.display()
);
std::fs::write(paths.mind_home.join("config.toml"), &config_toml).unwrap();
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert_eq!(
homes,
vec![dup_target.clone(), user_lobe.clone()],
"POL-41: duplicate targets must collapse to one entry, user home follows: {homes:?}"
);
let shared = base_dir.join("shared");
let policy_toml2 = format!(
"[lobes]\nlock = false\ntargets = [\"{shared}\", \"{shared}\"]\n",
shared = shared.display(),
);
std::fs::write(base.join("policy.toml"), &policy_toml2).unwrap();
let config_toml2 = format!("lobes = [\"{shared}\"]\n", shared = shared.display());
std::fs::write(paths.mind_home.join("config.toml"), &config_toml2).unwrap();
let homes2: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert_eq!(
homes2,
vec![shared.clone()],
"POL-41: duplicates across targets and user home must all collapse to one: {homes2:?}"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn pol40_duplicate_targets_collapse_to_one_entry() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir =
std::env::temp_dir().join(format!("mind-pol40-dup-{}-{n}", std::process::id()));
let dup_target = base_dir.join("dup-locked");
let policy_toml = format!(
"[lobes]\nlock = true\ntargets = [\"{dup}\", \"{dup}\"]\n",
dup = dup_target.display(),
);
let (paths, base, _policy_file, _guard) = setup_policy_test(&policy_toml);
let homes: Vec<PathBuf> = paths
.agent_homes()
.unwrap()
.into_iter()
.map(|l| l.path)
.collect();
assert_eq!(
homes,
vec![dup_target.clone()],
"POL-40: duplicate locked targets must collapse to one entry: {homes:?}"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn lobe_admits_respects_kinds_filter() {
let all = Lobe::all_kinds(PathBuf::from("/x"));
assert!(all.admits(ItemKind::Skill));
assert!(all.admits(ItemKind::Agent));
assert!(all.admits(ItemKind::Rule));
assert!(
all.admits(ItemKind::Tool),
"an unfiltered lobe admits all kinds, so a tool with an explicit link surfaces (TOOL-4)"
);
let skills_only = Lobe {
path: PathBuf::from("/y"),
kinds: Some(vec![ItemKind::Skill]),
};
assert!(skills_only.admits(ItemKind::Skill));
assert!(
!skills_only.admits(ItemKind::Agent),
"a skill-only lobe must reject an agent (HARN-1)"
);
assert!(
!skills_only.admits(ItemKind::Rule),
"a skill-only lobe must reject a rule (HARN-3: rules are Claude-only)"
);
}
#[test]
fn preset_lookup_and_resolution() {
let gemini = lookup_preset("gemini").unwrap();
assert_eq!(gemini.rel_path, ".gemini/config");
assert_eq!(gemini.kinds, &[ItemKind::Skill]);
assert_eq!(gemini.scope, Scope::Global);
let codex = lookup_preset("codex").unwrap();
assert_eq!(codex.rel_path, ".agents");
assert_eq!(codex.kinds, &[ItemKind::Skill]);
assert_eq!(codex.scope, Scope::Global);
assert_eq!(lookup_preset("universal").unwrap().rel_path, ".agents");
assert_eq!(lookup_preset("universal").unwrap().scope, Scope::Global);
let windsurf = lookup_preset("windsurf").unwrap();
assert_eq!(windsurf.rel_path, ".windsurf");
assert_eq!(windsurf.kinds, &[ItemKind::Skill]);
assert_eq!(windsurf.scope, Scope::Project);
assert_eq!(windsurf.marker_rel, ".codeium/windsurf");
let ws_lobe = Paths::preset_lobe("windsurf").unwrap();
assert!(
ws_lobe.path.is_absolute(),
"preset path must be absolute (STO-16)"
);
assert!(ws_lobe.path.ends_with(".windsurf"));
assert_eq!(ws_lobe.kinds.as_deref(), Some([ItemKind::Skill].as_slice()));
assert!(matches!(
lookup_preset("antigravity"),
Err(MindError::UnknownPreset { .. })
));
assert!(matches!(
lookup_preset("antigravity-cli"),
Err(MindError::UnknownPreset { .. })
));
assert!(matches!(
lookup_preset("emacs"),
Err(MindError::UnknownPreset { .. })
));
let lobe = Paths::preset_lobe("gemini").unwrap();
assert!(
lobe.path.is_absolute(),
"preset path must be absolute (STO-16)"
);
assert!(lobe.path.ends_with(".gemini/config"));
assert_eq!(lobe.kinds.as_deref(), Some([ItemKind::Skill].as_slice()));
assert!(Paths::preset_lobe("nope").is_err());
}
#[test]
fn detect_homes_reports_existing_marker_dirs() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-detect-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".gemini")).unwrap();
std::fs::create_dir_all(base.join(".agents")).unwrap();
std::fs::create_dir_all(base.join(".codeium/windsurf")).unwrap();
unsafe {
std::env::set_var("MIND_DETECT_HOME", &base);
}
let detected = Paths::detect_homes();
unsafe {
std::env::remove_var("MIND_DETECT_HOME");
}
let detected = detected.unwrap();
let names: Vec<&str> = detected.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"gemini"), "gemini marker exists: {names:?}");
assert!(
names.contains(&"universal"),
"agents marker exists: {names:?}"
);
assert!(
!names.contains(&"codex"),
"no .codex dir, so codex must not be detected: {names:?}"
);
let (_, gemini_lobe) = detected.iter().find(|(n, _)| *n == "gemini").unwrap();
assert_eq!(gemini_lobe.path, base.join(".gemini/config"));
assert_eq!(
gemini_lobe.kinds.as_deref(),
Some([ItemKind::Skill].as_slice())
);
assert!(
names.contains(&"windsurf"),
"windsurf .codeium/windsurf marker exists: {names:?}"
);
let (_, ws_lobe) = detected.iter().find(|(n, _)| *n == "windsurf").unwrap();
assert_eq!(ws_lobe.path, base.join(".windsurf"));
assert_eq!(ws_lobe.kinds.as_deref(), Some([ItemKind::Skill].as_slice()));
assert_eq!(
names.iter().filter(|n| **n == "windsurf").count(),
1,
"windsurf must appear exactly once in detected list"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn detect_homes_windsurf_requires_codeium_marker_not_bare_windsurf_dir() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-detect-ws-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".windsurf")).unwrap();
unsafe {
std::env::set_var("MIND_DETECT_HOME", &base);
}
let detected = Paths::detect_homes();
unsafe {
std::env::remove_var("MIND_DETECT_HOME");
}
let detected = detected.unwrap();
let names: Vec<&str> = detected.iter().map(|(n, _)| *n).collect();
assert!(
!names.contains(&"windsurf"),
"bare .windsurf dir must no longer trigger windsurf detection (marker is .codeium/windsurf): {names:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn agent_homes_carry_config_kinds_filter() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-harn-cfg-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
unsafe {
std::env::remove_var("MIND_POLICY_FILE");
std::env::remove_var("MIND_AGENT_HOMES");
}
std::fs::write(
mind_home.join("config.toml"),
"lobes = [\"/c/bare\", { path = \"/c/gem\", kinds = [\"skill\"] }]\n",
)
.unwrap();
let paths = Paths {
mind_home,
claude_home,
};
let homes = paths.agent_homes().unwrap();
assert_eq!(homes.len(), 2);
assert_eq!(homes[0].path, PathBuf::from("/c/bare"));
assert_eq!(homes[0].kinds, None, "a bare config lobe is all-kinds");
assert_eq!(homes[1].path, PathBuf::from("/c/gem"));
assert_eq!(
homes[1].kinds.as_deref(),
Some([ItemKind::Skill].as_slice()),
"a filtered config lobe must carry its kinds"
);
assert!(homes[1].admits(ItemKind::Skill));
assert!(!homes[1].admits(ItemKind::Rule));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn agent_homes_dedup_collision_first_kinds_win() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-harn-dedup-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
unsafe {
std::env::remove_var("MIND_POLICY_FILE");
std::env::remove_var("MIND_AGENT_HOMES");
}
std::fs::write(
mind_home.join("config.toml"),
"lobes = [{ path = \"/c/dup\", kinds = [\"skill\"] }, { path = \"/c/dup\", kinds = [\"agent\"] }]\n",
)
.unwrap();
let paths = Paths {
mind_home,
claude_home,
};
let homes = paths.agent_homes().unwrap();
assert_eq!(
homes.len(),
1,
"same-path lobes must dedup to one entry: {homes:?}"
);
assert_eq!(homes[0].path, PathBuf::from("/c/dup"));
assert_eq!(
homes[0].kinds.as_deref(),
Some([ItemKind::Skill].as_slice()),
"first-seen kinds must win on a dedup collision: {homes:?}"
);
assert!(homes[0].admits(ItemKind::Skill));
assert!(
!homes[0].admits(ItemKind::Agent),
"the losing entry's [agent] kind must not leak in"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn codex_and_universal_presets_share_a_path() {
let codex = Paths::preset_lobe("codex").unwrap();
let universal = Paths::preset_lobe("universal").unwrap();
assert_eq!(
codex.path, universal.path,
"codex and universal must resolve to the same ~/.agents path"
);
assert!(codex.path.ends_with(".agents"));
assert_eq!(codex.kinds.as_deref(), Some([ItemKind::Skill].as_slice()));
assert_eq!(
universal.kinds.as_deref(),
Some([ItemKind::Skill].as_slice())
);
}
#[test]
fn resolve_lobe_global_preset_defaults_to_home() {
let (lobe, preset) = resolve_lobe(None, Some("gemini"), None).unwrap();
assert!(
lobe.path.is_absolute(),
"path must be absolute: {:?}",
lobe.path
);
assert!(
lobe.path.ends_with(".gemini/config"),
"gemini lobe must be at home/.gemini/config: {:?}",
lobe.path
);
assert_eq!(
lobe.kinds.as_deref(),
Some([ItemKind::Skill].as_slice()),
"gemini lobe must be skill-only"
);
let p = preset.expect("gemini must return a preset");
assert_eq!(p.name, "gemini");
assert_eq!(p.scope, Scope::Global);
}
#[test]
fn resolve_lobe_project_preset_defaults_to_cwd() {
let (lobe, preset) = resolve_lobe(None, Some("windsurf"), None).unwrap();
let cwd = std::env::current_dir().unwrap();
assert!(
lobe.path.is_absolute(),
"path must be absolute: {:?}",
lobe.path
);
assert_eq!(
lobe.path,
cwd.join(".windsurf"),
"windsurf lobe with no base must resolve to cwd/.windsurf"
);
assert_eq!(lobe.kinds.as_deref(), Some([ItemKind::Skill].as_slice()));
let p = preset.expect("windsurf must return a preset");
assert_eq!(p.scope, Scope::Project);
}
#[test]
fn resolve_lobe_preset_with_explicit_base() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir =
std::env::temp_dir().join(format!("mind-rl-base-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base_dir).unwrap();
let (lobe, preset) =
resolve_lobe(Some(base_dir.to_str().unwrap()), Some("gemini"), None).unwrap();
assert_eq!(
lobe.path,
base_dir.join(".gemini/config"),
"explicit base must override the home default for gemini"
);
let p = preset.unwrap();
assert_eq!(p.name, "gemini");
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn resolve_lobe_subdir_defaults_base_to_cwd() {
let (lobe, preset) = resolve_lobe(None, None, Some(".windsurf")).unwrap();
let cwd = std::env::current_dir().unwrap();
assert_eq!(
lobe.path,
cwd.join(".windsurf"),
"--subdir with no base must use cwd"
);
assert_eq!(
lobe.kinds.as_deref(),
Some([ItemKind::Skill].as_slice()),
"--subdir lobe must be skill-only"
);
assert!(
preset.is_none(),
"--subdir with no preset returns None preset"
);
}
#[test]
fn resolve_lobe_subdir_with_explicit_base() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base_dir = std::env::temp_dir().join(format!("mind-rl-sub-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base_dir).unwrap();
let (lobe, _) =
resolve_lobe(Some(base_dir.to_str().unwrap()), None, Some(".myharness")).unwrap();
assert_eq!(lobe.path, base_dir.join(".myharness"));
assert_eq!(lobe.kinds.as_deref(), Some([ItemKind::Skill].as_slice()));
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn resolve_lobe_bare_path_add() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let lobe_dir =
std::env::temp_dir().join(format!("mind-rl-bare-{}-{n}", std::process::id()));
std::fs::create_dir_all(&lobe_dir).unwrap();
let (lobe, preset) = resolve_lobe(Some(lobe_dir.to_str().unwrap()), None, None).unwrap();
assert_eq!(
lobe.path, lobe_dir,
"bare path add must use base as the lobe path"
);
assert_eq!(lobe.kinds, None, "bare path add must be all-kinds");
assert!(preset.is_none());
let _ = std::fs::remove_dir_all(&lobe_dir);
}
#[test]
fn resolve_lobe_no_args_returns_lobe_target_required() {
let err = resolve_lobe(None, None, None).unwrap_err();
assert!(
matches!(err, MindError::LobeTargetRequired),
"no base/preset/subdir must be LobeTargetRequired: {err:?}"
);
}
#[test]
fn resolve_lobe_missing_base_returns_lobe_base_missing() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let missing =
std::env::temp_dir().join(format!("mind-rl-missing-{}-{n}", std::process::id()));
let err =
resolve_lobe(Some(missing.to_str().unwrap()), Some("windsurf"), None).unwrap_err();
assert!(
matches!(err, MindError::LobeBaseMissing { .. }),
"nonexistent explicit base must be LobeBaseMissing: {err:?}"
);
let err2 = resolve_lobe(Some(missing.to_str().unwrap()), None, Some(".ws")).unwrap_err();
assert!(
matches!(err2, MindError::LobeBaseMissing { .. }),
"--subdir with nonexistent base must be LobeBaseMissing: {err2:?}"
);
}
#[test]
fn lobe_reachable_true_when_parent_exists() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-reach-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let lobe = Lobe {
path: base.join(".windsurf"),
kinds: Some(vec![ItemKind::Skill]),
};
assert!(
lobe.reachable(),
"lobe is reachable when its parent dir exists (STO-56)"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn lobe_reachable_false_when_parent_missing() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let vanished = std::env::temp_dir().join(format!("mind-gone-{}-{n}", std::process::id()));
let lobe = Lobe {
path: vanished.join(".windsurf"),
kinds: Some(vec![ItemKind::Skill]),
};
assert!(
!lobe.reachable(),
"lobe is unreachable when its parent dir is missing (STO-56)"
);
}
#[test]
fn link_into_new_lobes_links_into_a_lobe_with_a_missing_parent() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-link-new-lobes-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
mkdir_p(&mind_home).unwrap();
let store_rel = "store/skill/greet";
let store_abs = mind_home.join(store_rel);
std::fs::create_dir_all(&store_abs).unwrap();
std::fs::write(store_abs.join("SKILL.md"), "---\ndescription: greet\n---\n").unwrap();
let paths = Paths {
mind_home: mind_home.clone(),
claude_home: base.join("claude-unused"),
};
let item = crate::manifest::InstalledItem {
kind: ItemKind::Skill,
name: "greet".to_string(),
bare_name: "greet".to_string(),
source: "test-source".to_string(),
commit: "deadbeef".to_string(),
hash: "abc123".to_string(),
store: store_rel.to_string(),
links: vec![], description: None,
install_hooks: Vec::new(),
dropped_requires: Vec::new(),
};
let missing_parent = base.join("not-yet-created");
let lobe = Lobe::all_kinds(missing_parent.join(".windsurf"));
assert!(
!lobe.reachable(),
"sanity: the lobe's parent must genuinely be missing for this test"
);
let (created, failed) = crate::install::link_into_new_lobes(&paths, &item, &[lobe], false);
assert!(
failed.is_empty(),
"link_into_new_lobes must not fail when the lobe's parent is missing \
(STO-56 explicitly excludes this call site from the reachability gate): {failed:?}"
);
assert_eq!(
created.len(),
1,
"exactly one link must be created: {created:?}"
);
let expected = missing_parent.join(".windsurf/skills/greet");
assert_eq!(
created[0], expected,
"created link must be at the expected path"
);
assert!(
expected.symlink_metadata().is_ok(),
"the symlink must actually exist on disk at {expected:?} -- a regression that \
added the STO-56 gate to link_into_new_lobes would silently create nothing here \
while this whole test suite otherwise stays green (HARN-7 preset backfill)"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn lobe_reachable_global_home_always_reachable() {
let home = dirs::home_dir().expect("home dir must exist in test env");
let claude = home.join(".claude");
let lobe = Lobe::all_kinds(claude);
assert!(
lobe.reachable(),
"global home lobe parent (~) always exists (STO-56)"
);
}
#[test]
fn preset_scope_assignments() {
assert_eq!(lookup_preset("gemini").unwrap().scope, Scope::Global);
assert_eq!(lookup_preset("codex").unwrap().scope, Scope::Global);
assert_eq!(lookup_preset("universal").unwrap().scope, Scope::Global);
assert_eq!(lookup_preset("windsurf").unwrap().scope, Scope::Project);
}
}