use std::path::Path;
use crate::catalog::CatalogItem;
use crate::error::{ItemKind, MindError, Result};
use crate::hash::hash_path;
use crate::manifest::InstalledItem;
use crate::namespace;
use crate::paths::{Paths, mkdir_p};
pub fn install(
paths: &Paths,
item: &CatalogItem,
commit: &str,
siblings: &[CatalogItem],
force: bool,
) -> Result<InstalledItem> {
let kind = item.kind;
let name = item.effective_name();
let store = paths.store_item(kind, &name);
let staging = paths.staging_path(kind, &name);
let backup = paths.backup_path(kind, &name);
let link_name = if kind == ItemKind::Agent {
item.agent_harness_name()
.unwrap_or_else(|| item.name.clone())
} else {
name.clone()
};
let link_rel = item
.link_rel
.clone()
.or_else(|| paths.default_link_rel(kind, &link_name));
let store_root = paths.store_dir();
let planned_links: Vec<std::path::PathBuf> = match &link_rel {
Some(rel) => paths
.agent_homes()?
.iter()
.filter(|home| home.admits(kind))
.map(|home| home.path.join(rel))
.collect(),
None => Vec::new(),
};
if !force {
for link in &planned_links {
ensure_unoccupied(&store_root, link)?;
}
}
remove_path(&staging)?;
if let Some(parent) = staging.parent() {
mkdir_p(parent)?;
}
copy_recursive(&item.path, &staging)?;
if let Err(e) = expand_references(&staging, item, siblings, &store_root) {
let _ = remove_path(&staging);
return Err(e);
}
if let Some(build) = &item.build
&& let Err(e) = run_build_hook(item, build, &staging, commit)
{
let _ = remove_path(&staging);
return Err(e);
}
if let Some(parent) = store.parent() {
mkdir_p(parent)?;
}
let had_backup = store.exists();
if had_backup {
remove_path(&backup)?;
if let Some(parent) = backup.parent() {
mkdir_p(parent)?;
}
rename(&store, &backup)?;
}
if let Err(e) = rename(&staging, &store) {
if had_backup {
let _ = rename(&backup, &store); }
return Err(e);
}
let mut links: Vec<std::path::PathBuf> = Vec::new();
let mut stashes: Vec<Option<std::path::PathBuf>> = Vec::new();
for (i, link) in planned_links.into_iter().enumerate() {
let stash = if force {
let sp = paths.tmp_dir().join("foreign-stash").join(i.to_string());
match maybe_stash_foreign(&store_root, &link, &sp) {
Ok(true) => Some(sp),
Ok(false) => None,
Err(e) => {
for (made, s) in links.iter().zip(stashes.iter()) {
let _ = remove_path(made);
if let Some(s) = s {
let _ = rename(s, made);
}
}
let _ = remove_path(&store);
if had_backup {
let _ = rename(&backup, &store);
}
return Err(e);
}
}
} else {
None
};
if let Err(e) = ensure_link(&store, &link) {
for (made, s) in links.iter().zip(stashes.iter()) {
let _ = remove_path(made);
if let Some(s) = s {
let _ = rename(s, made);
}
}
if let Some(s) = &stash {
let _ = rename(s, &link);
}
let _ = remove_path(&store);
if had_backup {
let _ = rename(&backup, &store);
}
return Err(e);
}
links.push(link);
stashes.push(stash);
}
if had_backup {
let _ = remove_path(&backup);
}
for s in stashes.iter().flatten() {
let _ = remove_path(s);
}
Ok(InstalledItem {
kind,
name,
bare_name: item.name.clone(),
source: item.source.clone(),
commit: commit.to_string(),
hash: hash_path(&item.path)?,
store: paths.store_rel(kind, &item.effective_name()),
links: links
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect(),
description: item.description.clone(),
})
}
pub fn uninstall(paths: &Paths, item: &InstalledItem) -> Result<()> {
for link in &item.links {
remove_path(Path::new(link))?;
}
remove_path(&paths.mind_home.join(&item.store))?;
Ok(())
}
pub fn relink(paths: &Paths, item: &InstalledItem) -> Result<usize> {
let store = paths.mind_home.join(&item.store);
if !store.exists() {
return Ok(0);
}
let mut fixed = 0;
for link in &item.links {
let link = Path::new(link);
if std::fs::symlink_metadata(link).is_err() {
ensure_link(&store, link)?;
fixed += 1;
}
}
Ok(fixed)
}
fn maybe_stash_foreign(store_root: &Path, link: &Path, stash: &Path) -> Result<bool> {
let meta = match std::fs::symlink_metadata(link) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(MindError::io(link, e)),
Ok(m) => m,
};
if meta.file_type().is_symlink()
&& std::fs::read_link(link).is_ok_and(|t| t.starts_with(store_root))
{
return Ok(false);
}
if let Some(parent) = stash.parent() {
mkdir_p(parent)?;
}
rename(link, stash)?;
Ok(true)
}
fn ensure_unoccupied(store_root: &Path, link: &Path) -> Result<()> {
match std::fs::symlink_metadata(link) {
Ok(meta) => {
let ours = meta.file_type().is_symlink()
&& std::fs::read_link(link).is_ok_and(|t| t.starts_with(store_root));
if ours {
Ok(())
} else {
Err(MindError::LinkOccupied {
path: link.display().to_string(),
})
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(MindError::io(link, e)),
}
}
fn ensure_link(store: &Path, link: &Path) -> Result<()> {
if let Some(parent) = link.parent() {
mkdir_p(parent)?;
}
remove_path(link)?;
symlink(store, link)
}
fn expand_references(
root: &Path,
item: &CatalogItem,
siblings: &[CatalogItem],
store_root: &Path,
) -> Result<()> {
let names: std::collections::HashSet<String> =
siblings.iter().map(|s| s.name.clone()).collect();
let agent_names: std::collections::HashSet<String> = siblings
.iter()
.filter(|s| s.kind == crate::error::ItemKind::Agent)
.map(|s| s.name.clone())
.collect();
let non_agent_names: std::collections::HashSet<String> = siblings
.iter()
.filter(|s| s.kind != crate::error::ItemKind::Agent)
.map(|s| s.name.clone())
.collect();
let bare_names: std::collections::HashSet<String> =
agent_names.difference(&non_agent_names).cloned().collect();
let path_siblings: Vec<namespace::PathSibling> =
siblings.iter().map(CatalogItem::as_path_sibling).collect();
let home = dirs::home_dir();
let ctx = namespace::PathCtx {
store_root,
home: home.as_deref(),
prefix: &item.prefix,
self_kind: item.kind,
self_name: &item.name,
siblings: &path_siblings,
};
let bad_ref = |referent: String| MindError::BadReference {
item: item.key(),
referent,
in_source: item.source.clone(),
};
for entry in &item.requires {
let r = crate::resolve::parse_item_ref(entry)
.map_err(|_| bad_ref(format!("requires: {entry}")))?;
if r.source.is_some() {
return Err(bad_ref(format!("requires: {entry}")));
}
let matches: Vec<&CatalogItem> = siblings
.iter()
.filter(|s| s.name == r.name && r.kind.is_none_or(|k| s.kind == k))
.collect();
if matches.is_empty() || matches.len() > 1 && r.kind.is_none() {
return Err(bad_ref(format!("requires: {entry}")));
}
}
let mut files = Vec::new();
if root.is_dir() {
collect_files(root, &mut files)?;
} else {
files.push(root.to_path_buf());
}
for file in files {
let Ok(content) = std::fs::read_to_string(&file) else {
continue;
};
if !content.contains("{{") {
continue;
}
let expanded = namespace::expand(&content, &item.prefix, &names, &bare_names)
.map_err(|name| bad_ref(format!("{{{{ns:{name}}}}}")))?;
let expanded = namespace::expand_paths(&expanded, &ctx).map_err(&bad_ref)?;
std::fs::write(&file, expanded).map_err(|e| MindError::io(&file, e))?;
}
Ok(())
}
fn run_build_hook(item: &CatalogItem, build: &str, staging: &Path, commit: &str) -> Result<()> {
let run = if !crate::hook::is_tty() {
println!(
"note: skipped build hook for {} in a non-interactive context; its tooling is not built",
item.key()
);
false
} else {
let disclosure = crate::hook::disclosure_text(
&item.source,
"(per-item build)",
commit,
&staging.to_string_lossy(),
build,
None,
);
matches!(
crate::hook::prompt_choice_optional(&disclosure)?,
crate::hook::OptionalChoice::Run
)
};
if run {
println!("running build hook for {}", item.key());
crate::hook::run_hook(build, staging, &item.source, "build")?;
} else if crate::hook::is_tty() {
println!(
"note: skipped build hook for {}; its tooling is not built",
item.key()
);
}
Ok(())
}
fn hook_cwd(store: &Path) -> std::path::PathBuf {
if store.is_dir() {
store.to_path_buf()
} else {
store
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| store.to_path_buf())
}
}
fn run_item_hook(
event: &str,
key: &str,
source: &str,
cmd: &str,
store: &Path,
commit: &str,
dangerously_skip: bool,
) -> Result<()> {
let effect = if event == "install" {
"its side effect is not applied"
} else {
"its cleanup is not run"
};
let cwd = hook_cwd(store);
let run = if dangerously_skip {
true
} else if !crate::hook::is_tty() {
println!("note: skipped {event} hook for {key} in a non-interactive context; {effect}");
false
} else {
let disclosure = crate::hook::disclosure_text(
source,
&format!("(per-item {event})"),
commit,
&cwd.to_string_lossy(),
cmd,
None,
);
matches!(
crate::hook::prompt_choice_optional(&disclosure)?,
crate::hook::OptionalChoice::Run
)
};
if run {
println!("running {event} hook for {key}");
crate::hook::run_hook(cmd, &cwd, source, event)?;
} else if crate::hook::is_tty() && !dangerously_skip {
println!("note: skipped {event} hook for {key}; {effect}");
}
Ok(())
}
pub fn run_item_install_hooks(
item: &CatalogItem,
hooks: &[&crate::mindfile::ResolvedHook],
store: &Path,
commit: &str,
dangerously_skip: bool,
) -> Result<()> {
for hook in hooks {
run_item_hook(
"install",
&item.key(),
&item.source,
&hook.run,
store,
commit,
dangerously_skip,
)?;
}
Ok(())
}
pub fn run_item_uninstall_hooks(
item: &InstalledItem,
hooks: &[&crate::mindfile::ResolvedHook],
store: &Path,
commit: &str,
dangerously_skip: bool,
) -> Result<()> {
for hook in hooks {
run_item_hook(
"uninstall",
&item.key(),
&item.source,
&hook.run,
store,
commit,
dangerously_skip,
)?;
}
Ok(())
}
fn collect_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
let rd = std::fs::read_dir(dir).map_err(|e| MindError::io(dir, e))?;
for entry in rd {
let entry = entry.map_err(|e| MindError::io(dir, e))?;
let path = entry.path();
if path.is_dir() {
collect_files(&path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
fn rename(from: &Path, to: &Path) -> Result<()> {
std::fs::rename(from, to).map_err(|e| MindError::io(to, e))
}
pub(crate) fn remove_path(path: &Path) -> Result<()> {
let meta = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(MindError::io(path, e)),
};
let res = if meta.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
res.map_err(|e| MindError::io(path, e))
}
fn copy_recursive(src: &Path, dst: &Path) -> Result<()> {
let meta = std::fs::symlink_metadata(src).map_err(|e| MindError::io(src, e))?;
if meta.file_type().is_symlink() {
return Err(MindError::io(
src,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"source item trees must not contain symlinks",
),
));
}
if meta.is_dir() {
mkdir_p(dst)?;
let rd = std::fs::read_dir(src).map_err(|e| MindError::io(src, e))?;
for entry in rd {
let entry = entry.map_err(|e| MindError::io(src, e))?;
let from = entry.path();
let to = dst.join(entry.file_name());
copy_recursive(&from, &to)?;
}
} else {
if let Some(parent) = dst.parent() {
mkdir_p(parent)?;
}
std::fs::copy(src, dst).map_err(|e| MindError::io(dst, e))?;
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, link).map_err(|e| MindError::io(link, e))
}
#[cfg(not(unix))]
fn symlink(target: &Path, link: &Path) -> Result<()> {
copy_recursive(target, link)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
fn tool_item(build: &str, path: std::path::PathBuf) -> CatalogItem {
CatalogItem {
kind: ItemKind::Tool,
name: "t".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path,
description: None,
link_rel: None,
bin: None,
build: Some(build.to_string()),
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
}
}
#[test]
fn build_hook_is_skipped_without_a_tty() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-build-skip-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
let marker = staging.join("built");
let item = tool_item(
&format!("touch {}", marker.display()),
std::path::PathBuf::from("/src/tools/t"),
);
run_build_hook(&item, item.build.as_deref().unwrap(), &staging, "abc123").unwrap();
assert!(
!marker.exists(),
"a non-TTY context must skip the build hook (HOOK-72)"
);
let _ = std::fs::remove_dir_all(&staging);
}
fn skill_item_at(name: &str, path: std::path::PathBuf, requires: Vec<String>) -> CatalogItem {
CatalogItem {
kind: ItemKind::Skill,
name: name.to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path,
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires,
hooks: Vec::new(),
}
}
fn agent_item_at(name: &str, path: std::path::PathBuf) -> CatalogItem {
CatalogItem {
kind: ItemKind::Agent,
name: name.to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path,
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
}
}
#[test]
fn requires_valid_entry_passes_validation() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging = std::env::temp_dir().join(format!("mind-req-ok-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["agent:test".to_string()],
);
let test_agent = agent_item_at("test", std::path::PathBuf::from("/src/agents/test.md"));
let siblings = vec![item.clone(), test_agent];
let result = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"));
assert!(
result.is_ok(),
"valid requires entry must not error: {result:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_typo_entry_is_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-typo-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["agent:nonexistent".to_string()],
);
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { ref referent, .. }
if referent.contains("agent:nonexistent")),
"typo requires entry must be BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_source_qualified_entry_is_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-cross-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["owner/repo#agent:test".to_string()],
);
let test_agent = agent_item_at("test", std::path::PathBuf::from("/src/agents/test.md"));
let siblings = vec![item.clone(), test_agent];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { .. }),
"source-qualified requires entry must be BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_ambiguous_bare_name_is_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-ambig-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["shared".to_string()], );
let agent = CatalogItem {
kind: ItemKind::Agent,
name: "shared".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path: std::path::PathBuf::from("/src/agents/shared.md"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
};
let rule = CatalogItem {
kind: ItemKind::Rule,
name: "shared".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path: std::path::PathBuf::from("/src/rules/shared.md"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
};
let siblings = vec![item.clone(), agent, rule];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { ref referent, .. }
if referent.contains("shared")),
"ambiguous bare-name requires entry must be BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_kind_qualified_among_two_same_name_siblings_is_accepted() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-kindok-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["agent:shared".to_string()],
);
let agent = CatalogItem {
kind: ItemKind::Agent,
name: "shared".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path: std::path::PathBuf::from("/src/agents/shared.md"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
};
let rule = CatalogItem {
kind: ItemKind::Rule,
name: "shared".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path: std::path::PathBuf::from("/src/rules/shared.md"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
};
let siblings = vec![item.clone(), agent, rule];
let result = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"));
assert!(
result.is_ok(),
"a kind-qualified ref that uniquely matches one of two same-name \
siblings must pass validation, not be flagged ambiguous: {result:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_self_reference_resolves_and_is_accepted() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-self-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"solo",
std::path::PathBuf::from("/src/skills/solo"),
vec!["skill:solo".to_string()],
);
let siblings = vec![item.clone()];
let result = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"));
assert!(
result.is_ok(),
"a self-requires must resolve to the item itself and not error: {result:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[cfg(unix)]
#[test]
fn maybe_stash_foreign_moves_foreign_file_and_restores_correctly() {
let n = N.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-stash-helper-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let store_root = base.join("store");
let link = base.join("link-target.md");
let stash0 = base.join("stash").join("0");
let got = maybe_stash_foreign(&store_root, &link, &stash0).unwrap();
assert!(!got, "absent link must not be stashed (LIFE-43)");
assert!(
!stash0.exists(),
"no stash must be created for an absent link"
);
let foreign_content = b"original foreign content";
std::fs::write(&link, foreign_content).unwrap();
let got = maybe_stash_foreign(&store_root, &link, &stash0).unwrap();
assert!(got, "foreign file must be stashed (LIFE-43)");
assert!(!link.exists(), "original path must be vacated after stash");
assert!(stash0.exists(), "stash file must exist");
assert_eq!(
std::fs::read(&stash0).unwrap(),
foreign_content,
"stash must preserve original content"
);
rename(&stash0, &link).unwrap();
assert!(link.exists(), "restored path must exist");
assert_eq!(
std::fs::read(&link).unwrap(),
foreign_content,
"restored file must have original content (LIFE-43)"
);
std::fs::create_dir_all(&store_root).unwrap();
let store_file = store_root.join("agent").join("myagent");
std::fs::create_dir_all(store_file.parent().unwrap()).unwrap();
std::fs::write(&store_file, b"mind managed").unwrap();
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&store_file, &link).unwrap();
let stash1 = base.join("stash").join("1");
let got = maybe_stash_foreign(&store_root, &link, &stash1).unwrap();
assert!(!got, "mind's own symlink must not be stashed (LIFE-43)");
assert!(!stash1.exists(), "no stash for mind's own symlink");
assert!(std::fs::symlink_metadata(&link).is_ok());
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn force_install_rollback_restores_stashed_foreign_target() {
let n = N.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-life43-e2e-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
let lobe1 = base.join("lobe1");
let blocker = base.join("not-a-dir");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&lobe1).unwrap();
std::fs::write(&blocker, b"i-am-a-regular-file").unwrap();
let lobe2 = blocker.join("lobe2");
let cfg = format!(
"lobes = [\"{}\", \"{}\"]\n",
lobe1.to_str().unwrap(),
lobe2.to_str().unwrap(),
);
std::fs::write(mind_home.join("config.toml"), cfg.as_bytes()).unwrap();
let paths = Paths {
mind_home: mind_home.clone(),
claude_home: lobe1.clone(),
};
let agents_dir = lobe1.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let foreign_path = agents_dir.join("myagent.md");
let foreign_content = b"foreign content - must survive the rollback";
std::fs::write(&foreign_path, foreign_content).unwrap();
let src_file = base.join("myagent.md");
std::fs::write(&src_file, b"# My Agent\n").unwrap();
let item = CatalogItem {
kind: ItemKind::Agent,
name: "myagent".to_string(),
source: "local/test".to_string(),
prefix: None,
path: src_file,
description: None,
link_rel: None, bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
};
let result = install(&paths, &item, "abc", std::slice::from_ref(&item), true);
assert!(
result.is_err(),
"install must fail when a later link cannot be created: {result:?}"
);
let meta = std::fs::symlink_metadata(&foreign_path)
.expect("foreign file must exist at original path after rollback (LIFE-43)");
assert!(
!meta.file_type().is_symlink(),
"restored path must be a regular file, not a symlink (LIFE-43)"
);
assert_eq!(
std::fs::read(&foreign_path).unwrap(),
foreign_content,
"restored file must have original content (LIFE-43)"
);
let store_path = mind_home.join("store").join("agent").join("myagent");
assert!(
!store_path.exists(),
"store copy must be absent after rollback: {store_path:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn copy_recursive_rejects_symlink_in_source_tree() {
let n = N.fetch_add(1, Ordering::SeqCst);
let src = std::env::temp_dir().join(format!("mind-cplink-src-{}-{n}", std::process::id()));
let dst = std::env::temp_dir().join(format!("mind-cplink-dst-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("ok.txt"), b"normal").unwrap();
std::os::unix::fs::symlink("/etc/passwd", src.join("evil")).unwrap();
let result = copy_recursive(&src, &dst);
assert!(
result.is_err(),
"copy_recursive must reject a source tree containing a symlink"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("symlink"),
"error message must mention 'symlink': {msg}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
}
}