use crate::commands;
use crate::error::Result;
use crate::lock;
use crate::paths::Paths;
use crate::tui::app::{ActionKind, PendingAction};
use crate::tui::data::{self, Snapshot};
static CAPTURE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub fn execute(paths: &Paths, action: PendingAction) -> Result<(Snapshot, String)> {
execute_inner(paths, action, true)
}
pub fn execute_interactive(paths: &Paths, action: PendingAction) -> Result<(Snapshot, String)> {
execute_inner(paths, action, false)
}
fn execute_inner(
paths: &Paths,
action: PendingAction,
capture: bool,
) -> Result<(Snapshot, String)> {
let mut lock = lock::open(paths)?;
let _guard = lock.write()?;
let (result, captured) = if capture {
with_captured_stdout(|| dispatch(paths, action.kind))
} else {
(dispatch(paths, action.kind), String::new())
};
result?;
drop(_guard);
let snapshot = data::load(paths)?;
Ok((snapshot, summary_line(&captured)))
}
fn dispatch(paths: &Paths, kind: ActionKind) -> Result<()> {
match kind {
ActionKind::Learn { item_key, source } => {
let item_ref = crate::tui::app::learn_ref(&item_key, &source);
commands::learn(
paths,
&item_ref,
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Error,
dangerously_skip: false,
dangerously_skip_build: false,
},
)?;
}
ActionKind::Forget { item_key } => {
commands::forget(paths, Some(&item_key), false, true, true, false)?
}
ActionKind::Meld { spec } => {
commands::meld(
paths,
&spec,
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)?;
}
ActionKind::Unmeld { name, forget } => {
commands::unmeld(paths, &name, !forget, true, false, None)?
}
ActionKind::Sync => commands::sync(paths, false, false, false, false)?,
ActionKind::Upgrade => commands::upgrade(paths, true, None, false, false)?,
ActionKind::LobeAdd { path } => commands::lobe_add(paths, &path, true)?,
ActionKind::LobeRemove { path } => commands::lobe_remove(paths, &path, false)?,
ActionKind::SetNamespace { .. } => {
}
}
Ok(())
}
fn summary_line(captured: &str) -> String {
let line = captured
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or_default();
crate::sanitize::strip_ansi(line)
}
#[cfg(unix)]
fn open_capture_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt as _;
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
}
#[cfg(unix)]
fn create_capture_file() -> Option<std::fs::File> {
let dir = crate::selfupdate::mktemp_dir_prefixed("mind-tui-capture").ok()?;
let path = dir.join("capture");
let file = open_capture_file(&path).ok();
if file.is_some() {
let _ = std::fs::remove_file(&path); }
let _ = std::fs::remove_dir(&dir); file
}
#[cfg(unix)]
fn with_captured_stdout<R>(f: impl FnOnce() -> R) -> (R, String) {
use std::io::{Read, Seek, Write};
use std::os::unix::io::AsRawFd;
struct FdRestore(libc::c_int);
impl Drop for FdRestore {
fn drop(&mut self) {
let _ = std::io::stdout().flush();
unsafe {
libc::dup2(self.0, libc::STDOUT_FILENO);
libc::close(self.0);
}
}
}
let _serialize = CAPTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut file) = create_capture_file() else {
return (f(), String::new()); };
let _ = std::io::stdout().flush();
let saved = unsafe { libc::dup(libc::STDOUT_FILENO) };
if saved < 0 {
return (f(), String::new());
}
let result = {
unsafe {
libc::dup2(file.as_raw_fd(), libc::STDOUT_FILENO);
}
let _restore = FdRestore(saved); f()
};
let mut buf = String::new();
let _ = file.rewind();
let _ = file.read_to_string(&mut buf);
(result, buf)
}
#[cfg(not(unix))]
fn with_captured_stdout<R>(f: impl FnOnce() -> R) -> (R, String) {
(f(), String::new())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::Paths;
use crate::tui::app::{ActionKind, PendingAction};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[test]
fn with_captured_stdout_cannot_observe_println_content_under_cargo_test() {
let marker = "TUI-62-diagnostic-canary-line-that-must-not-be-observed";
let (_, captured) = with_captured_stdout(|| println!("{marker}"));
assert!(
captured.is_empty(),
"expected with_captured_stdout to see nothing (libtest intercepts \
println! first); got {captured:?} instead -- if this now \
contains {marker:?}, in-process prose assertions on captured \
TUI output are sound again and the comment above is stale"
);
}
#[test]
fn create_capture_file_is_owner_only_0600() {
use std::os::unix::fs::PermissionsExt;
let file = create_capture_file().expect("must create a capture file");
let meta = file.metadata().expect("fstat the still-open capture file");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"stdout-capture file must be mode 0600, got {mode:o}"
);
}
#[test]
fn open_capture_file_refuses_a_preexisting_symlink() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!(
"mind-tui-capture-symlink-test-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&base).expect("create scratch dir");
let victim = base.join("victim-target");
let victim_content = b"attacker must not see this truncated";
std::fs::write(&victim, victim_content).expect("seed victim file");
let capture_path = base.join("capture");
#[cfg(unix)]
std::os::unix::fs::symlink(&victim, &capture_path).expect("plant symlink");
let result = open_capture_file(&capture_path);
assert!(
result.is_err(),
"open_capture_file must refuse to open through a pre-existing symlink, \
not silently follow it: {result:?}"
);
let victim_after = std::fs::read(&victim).expect("victim file must still exist");
assert_eq!(
victim_after, victim_content,
"the symlink target must be untouched -- a create+truncate open would have \
truncated it to empty"
);
let _ = std::fs::remove_dir_all(&base);
}
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl std::ops::Deref for TempDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
fn temp_paths() -> (Paths, TempDir) {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-tui-action-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
(paths, TempDir(base))
}
#[test]
fn summary_line_is_the_last_nonempty_line() {
use super::summary_line;
assert_eq!(
summary_line("everything is up to date\n"),
"everything is up to date"
);
assert_eq!(
summary_line("upgraded skill:review\n"),
"upgraded skill:review"
);
assert_eq!(summary_line("first\nlast\n\n"), "last");
assert_eq!(summary_line(" \n \n"), "");
assert_eq!(summary_line(""), "");
}
#[test]
fn summary_line_strips_ansi_from_captured_output() {
use super::summary_line;
assert_eq!(
summary_line("\x1b[32m+ installed skill:review\x1b[0m\n"),
"+ installed skill:review"
);
assert!(
!summary_line("\x1b[33mup to date\x1b[0m\n").contains('\x1b'),
"no raw ESC byte must survive summary_line"
);
}
#[test]
fn execute_forget_on_unknown_item_returns_error() {
let (paths, _base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let action = PendingAction {
kind: ActionKind::Forget {
item_key: "skill:nonexistent".to_string(),
},
description: "test".to_string(),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(
result.is_err(),
"forget on unknown item should return an error"
);
}
#[test]
fn execute_sync_on_empty_registry_succeeds() {
let (paths, _base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let action = PendingAction {
kind: ActionKind::Sync,
description: "sync?".to_string(),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"sync on empty registry should succeed: {:?}",
result.err()
);
let (snap, _msg) = result.unwrap();
assert!(snap.installed.is_empty());
}
#[test]
fn execute_takes_exclusive_lock() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let paths_thread = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
let handle = std::thread::spawn(move || {
let action = PendingAction {
kind: ActionKind::Sync,
description: "sync".to_string(),
dep_tree: None,
};
execute(&paths_thread, action)
});
let result = handle.join().unwrap();
assert!(result.is_ok(), "execute should succeed: {:?}", result.err());
}
#[test]
fn execute_lock_is_exclusive_not_shared() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
let (paths, _base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let paths = Arc::new(paths);
let reader_acquired = Arc::new(AtomicBool::new(false));
let reader_released = Arc::new(AtomicBool::new(false));
let hold = Duration::from_millis(300);
let p_reader = Arc::clone(&paths);
let acq = Arc::clone(&reader_acquired);
let rel = Arc::clone(&reader_released);
let reader = std::thread::spawn(move || {
let lock = lock::open(&p_reader).expect("open reader lock");
let guard = lock.read().expect("acquire shared lock");
acq.store(true, Ordering::SeqCst);
std::thread::sleep(hold);
rel.store(true, Ordering::SeqCst);
drop(guard);
});
while !reader_acquired.load(Ordering::SeqCst) {
std::thread::yield_now();
}
let p_exec = Arc::clone(&paths);
let rel_check = Arc::clone(&reader_released);
let start = Instant::now();
let result = execute(
&p_exec,
PendingAction {
kind: ActionKind::Sync,
description: "sync".to_string(),
dep_tree: None,
},
);
let waited = start.elapsed();
assert!(
rel_check.load(Ordering::SeqCst),
"execute acquired its lock before the shared reader released it: \
it is NOT taking an exclusive lock"
);
assert!(
waited >= Duration::from_millis(200),
"execute should have blocked behind the shared reader (exclusive lock); \
only waited {waited:?} - lock is not exclusive"
);
assert!(
result.is_ok(),
"execute should still succeed: {:?}",
result.err()
);
reader.join().unwrap();
}
#[test]
fn execute_upgrade_with_no_pending_succeeds() {
let (paths, _base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let action = PendingAction {
kind: ActionKind::Upgrade,
description: "upgrade?".to_string(),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"upgrade with nothing to do should succeed: {:?}",
result.err()
);
}
fn init_git_repo(dir: &std::path::Path) {
use std::process::Command;
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("git");
};
run(&["-c", "init.defaultBranch=main", "init", "-q"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
}
fn make_source_repo(base: &std::path::Path) -> std::path::PathBuf {
use std::process::Command;
let src = base.join("source-repo-action");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(src.join("skills/build")).unwrap();
std::fs::write(
src.join("skills/build/SKILL.md"),
"---\ndescription: build skill\n---\n# build\n",
)
.unwrap();
init_git_repo(&src);
Command::new("git")
.args(["add", "-A"])
.current_dir(&src)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-qm", "initial"])
.current_dir(&src)
.output()
.unwrap();
src
}
#[test]
fn execute_meld_promotes_preview_and_registers_source() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_source_repo(&base);
let spec = src.to_str().unwrap().to_string();
let action = PendingAction {
kind: ActionKind::Meld { spec: spec.clone() },
description: format!("Meld {spec}?"),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(result.is_ok(), "meld should succeed: {:?}", result.err());
let (snap, _msg) = result.unwrap();
assert!(
snap.source_names
.iter()
.any(|n| n.contains("source-repo-action")),
"newly melded source should appear in snapshot: {:?}",
snap.source_names
);
}
#[test]
fn execute_interactive_melds_without_capturing_stdout() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_source_repo(&base);
let spec = src.to_str().unwrap().to_string();
let action = PendingAction {
kind: ActionKind::Meld { spec: spec.clone() },
description: format!("Meld {spec}?"),
dep_tree: None,
};
let result = execute_interactive(&paths, action);
assert!(
result.is_ok(),
"interactive meld should succeed: {:?}",
result.err()
);
let (snap, msg) = result.unwrap();
assert!(
snap.source_names
.iter()
.any(|n| n.contains("source-repo-action")),
"interactively melded source must appear in the reloaded snapshot: {:?}",
snap.source_names
);
assert_eq!(msg, "", "interactive execute captures no stdout summary");
}
#[test]
fn execute_interactive_unmelds_without_capturing_stdout() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_source_repo(&base);
let spec = src.to_str().unwrap().to_string();
commands::meld(
&paths,
&spec,
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld prerequisite");
let source_name = crate::source::Registry::load(&paths).unwrap().sources[0]
.name
.clone();
let action = PendingAction {
kind: ActionKind::Unmeld {
name: source_name.clone(),
forget: false,
},
description: format!("Unmeld {source_name}?"),
dep_tree: None,
};
let result = execute_interactive(&paths, action);
assert!(
result.is_ok(),
"interactive unmeld should succeed: {:?}",
result.err()
);
let (snap, msg) = result.unwrap();
assert!(
snap.source_names.is_empty(),
"source must be absent from snapshot after unmeld: {:?}",
snap.source_names
);
assert_eq!(msg, "", "interactive execute captures no stdout summary");
}
fn seed_source_with_installed_item(paths: &Paths, base: &std::path::Path) -> String {
use crate::manifest::{InstalledItem, Manifest};
let src = make_source_repo(base);
let spec = src.to_str().unwrap().to_string();
commands::meld(
paths,
&spec,
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld");
let source_name = crate::source::Registry::load(paths).unwrap().sources[0]
.name
.clone();
let mut manifest = Manifest::load(paths).unwrap();
manifest.insert(InstalledItem {
kind: crate::error::ItemKind::Skill,
name: "build".to_string(),
bare_name: "build".to_string(),
source: source_name.clone(),
commit: "abc".to_string(),
hash: "deadbeef".to_string(),
store: String::new(), links: vec![],
description: None,
install_hooks: Vec::new(),
});
manifest.save(paths).unwrap();
source_name
}
#[test]
fn execute_unmeld_with_forget_purges_source_and_installed_items() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let source_name = seed_source_with_installed_item(&paths, &base);
let action = PendingAction {
kind: ActionKind::Unmeld {
name: source_name.clone(),
forget: true,
},
description: format!("Unmeld {source_name} --forget?"),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"destructive unmeld should succeed: {:?}",
result.err()
);
let registry2 = crate::source::Registry::load(&paths).unwrap();
assert!(
registry2.sources.is_empty(),
"source must be removed after unmeld: {:?}",
registry2.sources
);
let manifest2 = crate::manifest::Manifest::load(&paths).unwrap();
assert!(
!manifest2.items.values().any(|i| i.key() == "skill:build"),
"skill:build must be purged by unmeld --forget: {:?}",
manifest2.items.keys().collect::<Vec<_>>()
);
}
#[test]
fn execute_unmeld_without_forget_keeps_installed_items() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let source_name = seed_source_with_installed_item(&paths, &base);
let action = PendingAction {
kind: ActionKind::Unmeld {
name: source_name.clone(),
forget: false,
},
description: format!("Unmeld {source_name}?"),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(result.is_ok(), "unmeld should succeed: {:?}", result.err());
let registry2 = crate::source::Registry::load(&paths).unwrap();
assert!(registry2.sources.is_empty(), "source removed");
let manifest2 = crate::manifest::Manifest::load(&paths).unwrap();
assert!(
manifest2.items.values().any(|i| i.key() == "skill:build"),
"skill:build must survive a non-forget unmeld"
);
}
#[test]
fn decline_preview_leaves_nothing_registered_and_no_temp_dir() {
use crate::tui::preview;
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_source_repo(&base);
let spec = src.to_str().unwrap().to_string();
let prev = preview::preview(&paths, &spec).expect("preview should succeed");
let temp_dir = prev.temp_dir.clone();
assert!(
temp_dir.exists(),
"temp dir should exist while preview is live"
);
drop(prev);
assert!(
!temp_dir.exists(),
"temp dir must be removed when preview is dropped (decline)"
);
let registry = crate::source::Registry::load(&paths).unwrap();
assert!(
registry.sources.is_empty(),
"registry must remain empty after declining a preview: {:?}",
registry.sources
);
}
#[test]
fn execute_lobe_add_appends_lobe_to_config() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let lobe_path = base.join("custom-ai").to_str().unwrap().to_string();
let action = PendingAction {
kind: ActionKind::LobeAdd {
path: lobe_path.clone(),
},
description: format!("Add lobe {lobe_path}?"),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(result.is_ok(), "LobeAdd should succeed: {:?}", result.err());
let cfg = crate::config::Config::load(&paths).unwrap();
assert!(
cfg.lobes.iter().any(|e| e.path() == lobe_path),
"lobe must appear in config after LobeAdd: {:?}",
cfg.lobes
);
}
#[test]
fn execute_lobe_remove_drops_lobe_from_config() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let lobe_path = base.join("custom-ai").to_str().unwrap().to_string();
let add_action = PendingAction {
kind: ActionKind::LobeAdd {
path: lobe_path.clone(),
},
description: format!("Add {lobe_path}?"),
dep_tree: None,
};
execute(&paths, add_action).expect("LobeAdd prerequisite");
let remove_action = PendingAction {
kind: ActionKind::LobeRemove {
path: lobe_path.clone(),
},
description: format!("Remove lobe {lobe_path}?"),
dep_tree: None,
};
let result = execute(&paths, remove_action);
assert!(
result.is_ok(),
"LobeRemove should succeed: {:?}",
result.err()
);
let cfg = crate::config::Config::load(&paths).unwrap();
assert!(
!cfg.lobes.iter().any(|e| e.path() == lobe_path),
"lobe must be absent from config after LobeRemove: {:?}",
cfg.lobes
);
}
#[test]
fn execute_lobe_remove_nonexistent_returns_error() {
let (paths, _base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let action = PendingAction {
kind: ActionKind::LobeRemove {
path: "/does/not/exist".to_string(),
},
description: "Remove nonexistent lobe?".to_string(),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(
result.is_err(),
"LobeRemove of unknown path must return an error"
);
assert!(
matches!(
result.unwrap_err(),
crate::error::MindError::UnknownLobe { .. }
),
"error must be MindError::UnknownLobe"
);
}
#[test]
fn execute_lobe_add_duplicate_is_idempotent() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let lobe_path = base.join("custom-ai").to_str().unwrap().to_string();
for _ in 0..2 {
let action = PendingAction {
kind: ActionKind::LobeAdd {
path: lobe_path.clone(),
},
description: format!("Add {lobe_path}?"),
dep_tree: None,
};
execute(&paths, action).expect("LobeAdd must succeed");
}
let cfg = crate::config::Config::load(&paths).unwrap();
let count = cfg.lobes.iter().filter(|e| e.path() == lobe_path).count();
assert_eq!(
count, 1,
"duplicate lobe add must not produce duplicate entries"
);
}
#[test]
fn execute_lobe_add_creates_lobe_dir_and_backfills_installed_item() {
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 = make_source_repo(&base);
commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld");
commands::learn(
&paths,
"skill:build",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Error,
dangerously_skip: false,
dangerously_skip_build: false,
},
)
.expect("learn skill:build");
let new_lobe = base.join("tui-new-lobe");
assert!(
!new_lobe.exists(),
"the lobe directory must not exist before the add"
);
let action = PendingAction {
kind: ActionKind::LobeAdd {
path: new_lobe.to_str().unwrap().to_string(),
},
description: "Add lobe?".to_string(),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(result.is_ok(), "LobeAdd should succeed: {:?}", result.err());
assert!(
new_lobe.is_dir(),
"HARN-15: the resolved lobe directory must be created by the TUI's \
lobe-add action, before it is written to config"
);
let backfilled = new_lobe.join("skills/build");
let meta = std::fs::symlink_metadata(&backfilled).unwrap_or_else(|e| {
panic!(
"HARN-7/HARN-17: the installed skill:build must be backfilled \
into the newly added lobe by the TUI's own lobe-add action: {e}"
)
});
assert!(
meta.file_type().is_symlink(),
"the backfilled item must be linked as a symlink into the store, \
matching the per-item link operation `learn` uses"
);
}
#[test]
fn execute_lobe_add_reports_foreign_target_without_clobbering() {
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 = make_source_repo(&base);
commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld");
commands::learn(
&paths,
"skill:build",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Error,
dangerously_skip: false,
dangerously_skip_build: false,
},
)
.expect("learn skill:build");
let new_lobe = base.join("tui-foreign-lobe");
let skills_dir = new_lobe.join("skills");
std::fs::create_dir_all(&skills_dir).unwrap();
let foreign_target = skills_dir.join("build");
let foreign_content = b"not managed by mind, do not clobber";
std::fs::write(&foreign_target, foreign_content).unwrap();
let action = PendingAction {
kind: ActionKind::LobeAdd {
path: new_lobe.to_str().unwrap().to_string(),
},
description: "Add lobe?".to_string(),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"the lobe add itself must still succeed even when a backfill \
target is blocked (a blocked item is reported, not fatal): {:?}",
result.as_ref().err()
);
let foreign_target_str = foreign_target.to_string_lossy().into_owned();
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
let item = manifest
.items
.get("skill:build")
.expect("skill:build must still be in the manifest");
assert!(
!item.links.contains(&foreign_target_str),
"a blocked backfill target must NOT be recorded as a link: {:?}",
item.links
);
let meta = std::fs::symlink_metadata(&foreign_target).unwrap();
assert!(
!meta.file_type().is_symlink(),
"TUI-62: a foreign file at a backfill target reached through the \
TUI must NOT be clobbered into a symlink"
);
assert_eq!(
std::fs::read(&foreign_target).unwrap(),
foreign_content,
"TUI-62: the foreign file's content must be untouched"
);
}
#[test]
fn force_true_would_have_clobbered_the_foreign_target_proving_the_guard_is_load_bearing() {
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 = make_source_repo(&base);
commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld");
commands::learn(
&paths,
"skill:build",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Error,
dangerously_skip: false,
dangerously_skip_build: false,
},
)
.expect("learn skill:build");
let new_lobe = base.join("tui-foreign-lobe-forced");
let skills_dir = new_lobe.join("skills");
std::fs::create_dir_all(&skills_dir).unwrap();
let foreign_target = skills_dir.join("build");
std::fs::write(&foreign_target, b"not managed by mind, do not clobber").unwrap();
let result = commands::lobe_add_resolved(
&paths,
Some(new_lobe.to_str().unwrap()),
None,
None,
false, true, true, );
assert!(
result.is_ok(),
"a forced backfill must still succeed: {:?}",
result.err()
);
let meta = std::fs::symlink_metadata(&foreign_target).unwrap();
assert!(
meta.file_type().is_symlink(),
"with force = true the foreign target MUST be clobbered into a \
symlink -- proving the force parameter (hardcoded to false at \
the TUI's call site) is what the non-clobber guarantee depends on"
);
}
#[test]
fn execute_lobe_add_snapshot_includes_new_lobe() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let lobe_path = base.join("custom-ai").to_str().unwrap().to_string();
let action = PendingAction {
kind: ActionKind::LobeAdd {
path: lobe_path.clone(),
},
description: format!("Add {lobe_path}?"),
dep_tree: None,
};
let (snap, _msg) = execute(&paths, action).expect("LobeAdd must succeed");
assert!(
snap.lobes.contains(&lobe_path),
"snapshot after LobeAdd must include the new lobe: {:?}",
snap.lobes
);
}
fn make_named_source_repo(
base: &std::path::Path,
dir_name: &str,
skill_name: &str,
) -> std::path::PathBuf {
use std::process::Command;
let src = base.join(dir_name);
std::fs::create_dir_all(src.join("skills").join(skill_name)).unwrap();
std::fs::write(
src.join("skills").join(skill_name).join("SKILL.md"),
format!("---\ndescription: {skill_name} skill from {dir_name}\n---\n# {skill_name}\n"),
)
.unwrap();
init_git_repo(&src);
Command::new("git")
.args(["add", "-A"])
.current_dir(&src)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-qm", "initial"])
.current_dir(&src)
.output()
.unwrap();
src
}
fn make_dep_source_repo(base: &std::path::Path) -> std::path::PathBuf {
use std::process::Command;
let src = base.join("dep-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\nHand off to {{ns:dev}}.\n",
)
.unwrap();
std::fs::create_dir_all(src.join("agents")).unwrap();
std::fs::write(
src.join("agents/dev.md"),
"---\nname: dev\ndescription: dev agent\n---\n# dev\n",
)
.unwrap();
init_git_repo(&src);
Command::new("git")
.args(["add", "-A"])
.current_dir(&src)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-qm", "initial"])
.current_dir(&src)
.output()
.unwrap();
src
}
#[test]
fn execute_learn_installs_whole_dependency_closure() {
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 = make_dep_source_repo(&base);
commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld dep-source");
let source_name = crate::source::Registry::load(&paths).unwrap().sources[0]
.name
.clone();
let _declined = PendingAction {
kind: ActionKind::Learn {
item_key: "skill:review".to_string(),
source: source_name.clone(),
},
description: "Install skill:review?".to_string(),
dep_tree: Some("review (selected)\n dev (dependency)".to_string()),
};
let pre = crate::manifest::Manifest::load(&paths).unwrap();
assert!(
pre.items.is_empty(),
"declining (not executing) must install nothing: {:?}",
pre.items.keys().collect::<Vec<_>>()
);
let action = PendingAction {
kind: ActionKind::Learn {
item_key: "skill:review".to_string(),
source: source_name.clone(),
},
description: "Install skill:review?".to_string(),
dep_tree: Some("review (selected)\n dev (dependency)".to_string()),
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"learn of the closure must succeed: {:?}",
result.err()
);
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
assert!(
manifest.items.contains_key("skill:review"),
"the explicitly selected skill must be installed: {:?}",
manifest.items.keys().collect::<Vec<_>>()
);
assert!(
manifest.items.contains_key("agent:dev"),
"the referenced agent (the dependency) must be pulled in: {:?}",
manifest.items.keys().collect::<Vec<_>>()
);
}
fn make_chain_source_repo(base: &std::path::Path) -> std::path::PathBuf {
use std::process::Command;
let src = base.join("chain-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\nHand off to {{ns:dev}}.\n",
)
.unwrap();
std::fs::create_dir_all(src.join("agents")).unwrap();
std::fs::write(
src.join("agents/dev.md"),
"---\nname: dev\ndescription: dev agent\n---\n# dev\nUse {{ns:build}} to compile.\n",
)
.unwrap();
std::fs::create_dir_all(src.join("skills/build")).unwrap();
std::fs::write(
src.join("skills/build/SKILL.md"),
"---\ndescription: build skill\n---\n# build\n",
)
.unwrap();
init_git_repo(&src);
Command::new("git")
.args(["add", "-A"])
.current_dir(&src)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-qm", "initial"])
.current_dir(&src)
.output()
.unwrap();
src
}
#[test]
fn execute_learn_installs_transitive_closure_dependency_first() {
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 = make_chain_source_repo(&base);
commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld chain-source");
let source_name = crate::source::Registry::load(&paths).unwrap().sources[0]
.name
.clone();
let action = PendingAction {
kind: ActionKind::Learn {
item_key: "skill:review".to_string(),
source: source_name.clone(),
},
description: "Install skill:review?".to_string(),
dep_tree: Some(
"- skill:review [selected]\n - agent:dev [dep]\n - skill:build [dep]"
.to_string(),
),
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"learn of the transitive closure must succeed: {:?}",
result.err()
);
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
for key in ["skill:review", "agent:dev", "skill:build"] {
assert!(
manifest.items.contains_key(key),
"transitive closure member {key} must be installed: {:?}",
manifest.items.keys().collect::<Vec<_>>()
);
}
for key in ["skill:review", "agent:dev", "skill:build"] {
let item = manifest.items.get(key).unwrap();
assert_eq!(
item.source, source_name,
"closure member {key} must be attributed to the selected source"
);
}
}
#[test]
fn execute_learn_does_not_reinstall_already_installed_dependency() {
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 = make_dep_source_repo(&base); commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld dep-source");
let source_name = crate::source::Registry::load(&paths).unwrap().sources[0]
.name
.clone();
let pre = PendingAction {
kind: ActionKind::Learn {
item_key: "agent:dev".to_string(),
source: source_name.clone(),
},
description: "Install agent:dev?".to_string(),
dep_tree: None,
};
execute(&paths, pre).expect("pre-install of agent:dev must succeed");
let before = crate::manifest::Manifest::load(&paths).unwrap();
assert!(
before.items.contains_key("agent:dev"),
"agent:dev must be installed before the second learn"
);
let dev_commit_before = before.items.get("agent:dev").unwrap().commit.clone();
let dev_hash_before = before.items.get("agent:dev").unwrap().hash.clone();
assert!(
!before.items.contains_key("skill:review"),
"skill:review must NOT be installed yet"
);
let plan = crate::commands::learn_preview(
&paths,
&crate::tui::app::learn_ref("skill:review", &source_name),
)
.expect("learn_preview must succeed");
assert!(
plan.adds_dependencies,
"the closure still pulls in the (already-installed) dep, so a tree is shown"
);
assert_eq!(
plan.install_count, 1,
"DEP-23: only the not-yet-installed `review` installs; the already-installed \
dep is excluded from the install order"
);
let action = PendingAction {
kind: ActionKind::Learn {
item_key: "skill:review".to_string(),
source: source_name.clone(),
},
description: "Install skill:review?".to_string(),
dep_tree: Some("- skill:review [selected]\n - agent:dev [installed]".to_string()),
};
execute(&paths, action).expect("learn of review must succeed");
let after = crate::manifest::Manifest::load(&paths).unwrap();
assert!(
after.items.contains_key("skill:review"),
"skill:review must now be installed"
);
let dev_count = after.items.keys().filter(|k| *k == "agent:dev").count();
assert_eq!(
dev_count,
1,
"the already-installed dependency must appear exactly once, not duplicated: {:?}",
after.items.keys().collect::<Vec<_>>()
);
let dev_after = after.items.get("agent:dev").unwrap();
assert_eq!(
dev_after.commit, dev_commit_before,
"already-installed dependency must keep its original commit (not re-installed)"
);
assert_eq!(
dev_after.hash, dev_hash_before,
"already-installed dependency must keep its original hash (not re-installed)"
);
}
#[test]
fn execute_learn_with_source_resolves_when_two_sources_have_same_skill() {
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_a = make_named_source_repo(&base, "source-alpha", "review");
let src_b = make_named_source_repo(&base, "source-beta", "review");
commands::meld(
&paths,
src_a.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld source-alpha");
commands::meld(
&paths,
src_b.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
false,
)
.expect("meld source-beta");
let registry = crate::source::Registry::load(&paths).unwrap();
assert_eq!(registry.sources.len(), 2, "two sources must be registered");
let source_name = registry
.sources
.iter()
.find(|s| s.name.ends_with("source-alpha"))
.map(|s| s.name.clone())
.expect("source-alpha must be registered");
let action = PendingAction {
kind: ActionKind::Learn {
item_key: "skill:review".to_string(),
source: source_name.clone(),
},
description: "Learn skill:review from source-alpha?".to_string(),
dep_tree: None,
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"learn with source qualifier must succeed (not AmbiguousItem): {:?}",
result.err()
);
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
let item = manifest
.items
.get("skill:review")
.expect("skill:review must be in manifest after learn");
assert!(
item.source.ends_with("source-alpha"),
"installed item must come from source-alpha, got: {}",
item.source
);
}
}