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 {
crate::prompt::set_captured(true);
let r = with_captured_stdout(|| dispatch(paths, action.kind, &action.upgrade_keys, false));
crate::prompt::set_captured(false);
r
} else {
(
dispatch(paths, action.kind, &action.upgrade_keys, false),
String::new(),
)
};
result?;
drop(_guard);
let snapshot = data::load(paths)?;
Ok((snapshot, summary_line(&captured)))
}
fn dispatch(paths: &Paths, kind: ActionKind, upgrade_keys: &[String], force: bool) -> Result<()> {
let clobber = if force {
commands::Clobber::Force
} else {
commands::Clobber::Error
};
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,
accept: crate::hook::Accept::default(),
},
)?;
commands::mark_learned_explicit(paths, &item_ref)?;
}
ActionKind::Forget { item_key } => commands::forget(
paths,
Some(&item_key),
false,
false,
true,
true,
crate::hook::Accept::default(),
)?,
ActionKind::Meld { args } => commands::meld_verb(paths, *args, false, false)?,
ActionKind::EnableProfile { source, profile } => {
learn_profiles(paths, &source, vec![profile], clobber)?
}
ActionKind::InstallProfiles { source, profiles } => {
learn_profiles(paths, &source, profiles, clobber)?
}
ActionKind::Forced(inner) => dispatch(paths, *inner, upgrade_keys, true)?,
ActionKind::DisableProfile { source, profile } => commands::forget_source_profile(
paths,
&source,
&profile,
true,
crate::hook::Accept::default(),
)?,
ActionKind::Unmeld { name, forget } => commands::unmeld(
paths,
&name,
!forget,
true,
crate::hook::Accept::default(),
None,
)?,
ActionKind::Sync => commands::sync(paths, false, false, crate::hook::Accept::default())?,
ActionKind::Upgrade if upgrade_keys.is_empty() => {
commands::upgrade_no_sync(paths, true, None, crate::hook::Accept::default())?
}
ActionKind::Upgrade => commands::upgrade_no_sync_keys(
paths,
true,
upgrade_keys,
crate::hook::Accept::default(),
)?,
ActionKind::LobeAdd { path } => commands::lobe_add(paths, &path, true)?,
ActionKind::LobeRemove { path } => commands::lobe_remove(paths, &path, false)?,
ActionKind::SetNamespace { .. } => {
}
}
Ok(())
}
fn learn_profiles(
paths: &Paths,
source: &str,
profiles: Vec<String>,
clobber: commands::Clobber,
) -> Result<()> {
let selection = crate::profile::Selection {
include_default: false,
named: profiles,
all: false,
};
commands::learn_source_profiles(
paths,
source,
&selection,
false,
commands::InstallFlow {
yes: true,
clobber,
accept: crate::hook::Accept::default(),
},
)
}
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, libc::c_int);
impl Drop for FdRestore {
fn drop(&mut self) {
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
unsafe {
libc::dup2(self.0, libc::STDOUT_FILENO);
libc::close(self.0);
libc::dup2(self.1, libc::STDERR_FILENO);
libc::close(self.1);
}
}
}
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 _ = std::io::stderr().flush();
let saved_out = unsafe { libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
if saved_out < 0 {
return (f(), String::new());
}
let saved_err = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
if saved_err < 0 {
unsafe { libc::close(saved_out) };
return (f(), String::new());
}
let result = {
unsafe {
libc::dup2(file.as_raw_fd(), libc::STDOUT_FILENO);
libc::dup2(file.as_raw_fd(), libc::STDERR_FILENO);
}
let _restore = FdRestore(saved_out, saved_err); 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.contains(marker),
"expected with_captured_stdout NOT to observe the closure's own \
`println!` (libtest intercepts it first); got {captured:?}, which \
contains {marker:?} -- in-process prose assertions on captured \
TUI output are sound again and the comment above is stale"
);
}
#[test]
fn with_captured_stdout_also_captures_stderr() {
let out_marker = "TUI-H3-stdout-must-land-in-the-capture-buffer";
let err_marker = "TUI-H3-stderr-must-land-in-the-capture-buffer";
let (status, captured) = with_captured_stdout(|| {
std::process::Command::new("sh")
.args(["-c", &format!("echo {out_marker}; echo {err_marker} 1>&2")])
.status()
.expect("run sh")
});
assert!(status.success(), "the child process must run to completion");
assert!(
captured.contains(out_marker),
"with_captured_stdout must still capture stdout: {captured:?}"
);
assert!(
captured.contains(err_marker),
"with_captured_stdout must also capture stderr, not just stdout: \
{captured:?}"
);
}
#[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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
},
);
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,
upgrade_keys: Vec::new(),
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"upgrade with nothing to do should succeed: {:?}",
result.err()
);
}
#[test]
fn action_upgrade_with_empty_keys_still_applies_real_drift_not_a_guaranteed_noop() {
use std::process::Command;
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![crate::config::LobeEntry::bare(
paths.claude_home.to_str().unwrap(),
)],
..Default::default()
}
.save(&paths)
.unwrap();
let src = base.join("empty-keys-anyway-source");
std::fs::create_dir_all(src.join("skills/review")).unwrap();
std::fs::write(
src.join("skills/review/SKILL.md"),
"---\ndescription: review skill\n---\n# review\noriginal\n",
)
.unwrap();
init_git_repo(&src);
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.expect("git");
};
git(&["add", "-A"]);
git(&["commit", "-qm", "initial"]);
commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
crate::hook::Accept::default(),
None,
)
.expect("meld");
commands::learn(
&paths,
"skill:review",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Force,
accept: crate::hook::Accept::ALL,
},
)
.expect("learn");
let hash_before = crate::manifest::Manifest::load(&paths)
.unwrap()
.items
.get("skill:review")
.unwrap()
.hash
.clone();
std::fs::write(
src.join("skills/review/SKILL.md"),
"---\ndescription: review skill\n---\n# review\nedited after the empty confirm\n",
)
.unwrap();
let action = PendingAction {
kind: ActionKind::Upgrade,
description: "Upgrade: nothing is out of date since the last sync. Press `s` to \
sync and check for updates. Proceed with upgrade anyway?"
.to_string(),
dep_tree: None,
upgrade_keys: Vec::new(),
};
let result = execute(&paths, action);
assert!(
result.is_ok(),
"the empty-keys 'anyway' apply must succeed: {:?}",
result.err()
);
let hash_after = crate::manifest::Manifest::load(&paths)
.unwrap()
.items
.get("skill:review")
.unwrap()
.hash
.clone();
assert_ne!(
hash_before, hash_after,
"an empty-keys 'anyway' apply must re-derive staleness and actually \
upgrade the drifted item, not silently apply nothing"
);
}
#[test]
fn action_upgrade_applies_only_the_confirmed_keys_not_a_newly_stale_item() {
use std::process::Command;
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![crate::config::LobeEntry::bare(
paths.claude_home.to_str().unwrap(),
)],
..Default::default()
}
.save(&paths)
.unwrap();
let src = base.join("two-item-source");
std::fs::create_dir_all(src.join("skills/alpha")).unwrap();
std::fs::write(
src.join("skills/alpha/SKILL.md"),
"---\ndescription: alpha skill\n---\n# alpha\noriginal\n",
)
.unwrap();
std::fs::create_dir_all(src.join("skills/beta")).unwrap();
std::fs::write(
src.join("skills/beta/SKILL.md"),
"---\ndescription: beta skill\n---\n# beta\noriginal\n",
)
.unwrap();
init_git_repo(&src);
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.expect("git");
};
git(&["add", "-A"]);
git(&["commit", "-qm", "initial"]);
commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
crate::hook::Accept::default(),
None,
)
.expect("meld");
commands::learn(
&paths,
"skill:alpha",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Force,
accept: crate::hook::Accept::ALL,
},
)
.expect("learn alpha");
commands::learn(
&paths,
"skill:beta",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Force,
accept: crate::hook::Accept::ALL,
},
)
.expect("learn beta");
let manifest_before = crate::manifest::Manifest::load(&paths).unwrap();
let alpha_hash_before = manifest_before
.items
.get("skill:alpha")
.unwrap()
.hash
.clone();
let beta_hash_before = manifest_before
.items
.get("skill:beta")
.unwrap()
.hash
.clone();
std::fs::write(
src.join("skills/alpha/SKILL.md"),
"---\ndescription: alpha skill\n---\n# alpha\nedited before confirm\n",
)
.unwrap();
let action = PendingAction {
kind: ActionKind::Upgrade,
description: "Upgrade 1 pending item(s)?\n\nskill:alpha (...)".to_string(),
dep_tree: None,
upgrade_keys: vec!["skill:alpha".to_string()],
};
std::fs::write(
src.join("skills/beta/SKILL.md"),
"---\ndescription: beta skill\n---\n# beta\nedited after confirm\n",
)
.unwrap();
let result = execute(&paths, action);
assert!(
result.is_ok(),
"scoped upgrade must succeed: {:?}",
result.err()
);
let manifest_after = crate::manifest::Manifest::load(&paths).unwrap();
let alpha_hash_after = manifest_after
.items
.get("skill:alpha")
.unwrap()
.hash
.clone();
let beta_hash_after = manifest_after.items.get("skill:beta").unwrap().hash.clone();
assert_ne!(
alpha_hash_after, alpha_hash_before,
"the confirmed item (skill:alpha) must be upgraded"
);
assert_eq!(
beta_hash_after, beta_hash_before,
"an item the modal never named (skill:beta) must NOT be upgraded, \
even though it independently became stale before the apply ran"
);
}
#[test]
fn action_upgrade_routes_through_no_sync_so_recorded_commit_stays_stale() {
use std::process::Command;
fn make_stale_fixture(paths: &Paths, base: &std::path::Path) {
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![crate::config::LobeEntry::bare(
paths.claude_home.to_str().unwrap(),
)],
..Default::default()
}
.save(paths)
.unwrap();
let src = base.join("source");
std::fs::create_dir_all(src.join("skills/review")).unwrap();
std::fs::write(
src.join("skills/review/SKILL.md"),
"---\ndescription: review skill\n---\n# review\noriginal\n",
)
.unwrap();
init_git_repo(&src);
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.expect("git");
};
git(&["add", "-A"]);
git(&["commit", "-qm", "initial"]);
commands::meld(
paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
crate::hook::Accept::default(),
None,
)
.expect("meld");
commands::learn(
paths,
"skill:review",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Force,
accept: crate::hook::Accept::ALL,
},
)
.expect("learn");
std::fs::write(
src.join("skills/review/SKILL.md"),
"---\ndescription: review skill\n---\n# review\nedited\n",
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-qm", "edit"]);
}
let recorded_commit = |paths: &Paths| {
crate::manifest::Manifest::load(paths)
.unwrap()
.items
.get("skill:review")
.expect("skill:review must be installed")
.commit
.clone()
};
let (no_sync_paths, _td1) = temp_paths();
make_stale_fixture(&no_sync_paths, &_td1);
let old_commit = recorded_commit(&no_sync_paths);
let action = PendingAction {
kind: ActionKind::Upgrade,
description: "upgrade?".to_string(),
dep_tree: None,
upgrade_keys: vec!["skill:review".to_string()],
};
execute(&no_sync_paths, action).expect("no-sync upgrade should succeed");
let no_sync_commit = recorded_commit(&no_sync_paths);
assert_eq!(
no_sync_commit, old_commit,
"a no-sync upgrade (the TUI's `u` action) must record the STALE \
commit: it never re-reads the source's HEAD, only `sync` does. \
got {no_sync_commit:?}, expected the pre-edit commit {old_commit:?}"
);
let (sync_paths, _td2) = temp_paths();
make_stale_fixture(&sync_paths, &_td2);
let old_commit_2 = recorded_commit(&sync_paths);
commands::upgrade(&sync_paths, true, None, crate::hook::Accept::default())
.expect("sync-first upgrade should succeed");
let sync_commit = recorded_commit(&sync_paths);
assert_ne!(
sync_commit, old_commit_2,
"a sync-first upgrade must record the NEW commit: `sync` re-reads \
the linked source's live HEAD before upgrade computes what to \
record. got {sync_commit:?}, expected it to differ from the \
pre-edit commit {old_commit_2:?}"
);
}
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
}
fn make_profile_repo(base: &std::path::Path) -> std::path::PathBuf {
use std::process::Command;
let src = base.join("profile-repo");
for name in ["a", "b", "c"] {
std::fs::create_dir_all(src.join(format!("skills/{name}"))).unwrap();
std::fs::write(
src.join(format!("skills/{name}/SKILL.md")),
format!("---\ndescription: {name}\n---\n# {name}\n"),
)
.unwrap();
}
std::fs::write(
src.join("mind.toml"),
"[profiles]\ndefault = [\"skill:a\"]\nfull = [\"skill:b\", \"skill:c\"]\n",
)
.unwrap();
init_git_repo(&src);
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.unwrap();
};
git(&["add", "-A"]);
git(&["commit", "-qm", "initial"]);
git(&["branch", "dev"]);
src
}
fn pending(kind: ActionKind) -> PendingAction {
PendingAction::new(kind, String::new())
}
fn pin_lobe(paths: &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();
}
fn register(paths: &Paths, src: &std::path::Path) -> String {
pin_lobe(paths);
commands::meld(
paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
crate::hook::Accept::default(),
None,
)
.expect("meld");
crate::source::Registry::load(paths).unwrap().sources[0]
.name
.clone()
}
#[test]
fn meld_input_flags_reach_the_meld_verb_pin() {
let (paths, base) = temp_paths();
pin_lobe(&paths);
let src = make_profile_repo(&base);
let line = format!("{} --pin branch=dev --register-only", src.display());
let args = crate::tui::app::parse_meld_line(&line).expect("parse");
execute(
&paths,
pending(ActionKind::Meld {
args: Box::new(args),
}),
)
.expect("meld");
let reg = crate::source::Registry::load(&paths).unwrap();
assert_eq!(
reg.sources[0].pin,
crate::source::Pin::FollowBranch("dev".to_string())
);
}
#[test]
fn meld_input_profile_flag_reaches_the_meld_verb() {
let (paths, base) = temp_paths();
pin_lobe(&paths);
let src = make_profile_repo(&base);
let line = format!("{} --profile nope", src.display());
let args = crate::tui::app::parse_meld_line(&line).expect("parse");
let err = execute(
&paths,
pending(ActionKind::Meld {
args: Box::new(args),
}),
)
.expect_err("an undeclared profile must fail");
assert!(
matches!(err, crate::error::MindError::ProfileNotFound { .. }),
"got {err:?}"
);
}
#[test]
fn enable_and_disable_profile_actions_run_learn_and_forget_profile() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_profile_repo(&base);
let name = register(&paths, &src);
execute(
&paths,
pending(ActionKind::EnableProfile {
source: name.clone(),
profile: "full".to_string(),
}),
)
.expect("enable");
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
assert!(manifest.items.contains_key("skill:b"));
assert!(manifest.items.contains_key("skill:c"));
assert!(!manifest.items.contains_key("skill:a"));
assert_eq!(manifest.items["skill:b"].profiles, vec!["full".to_string()]);
let reg = crate::source::Registry::load(&paths).unwrap();
assert_eq!(reg.sources[0].profiles, Some(vec!["full".to_string()]));
execute(
&paths,
pending(ActionKind::DisableProfile {
source: name,
profile: "full".to_string(),
}),
)
.expect("disable");
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
assert!(manifest.items.is_empty(), "{:?}", manifest.items.keys());
let reg = crate::source::Registry::load(&paths).unwrap();
assert_eq!(reg.sources[0].profiles, None);
}
#[test]
fn install_profiles_installs_every_enabled_profiles_missing_members() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_profile_repo(&base);
let name = register(&paths, &src);
let mut reg = crate::source::Registry::load(&paths).unwrap();
reg.sources[0].profiles = Some(vec!["default".to_string(), "full".to_string()]);
reg.save(&paths).unwrap();
execute(
&paths,
pending(ActionKind::InstallProfiles {
source: name,
profiles: vec!["default".to_string(), "full".to_string()],
}),
)
.expect("install enabled profiles");
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
for key in ["skill:a", "skill:b", "skill:c"] {
assert!(manifest.items.contains_key(key), "{key} missing");
}
}
#[test]
fn a_forced_learn_replaces_a_foreign_target_a_plain_one_refuses() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_profile_repo(&base);
let name = register(&paths, &src);
let target = paths.claude_home.join("skills/a");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("SKILL.md"), "the user's own\n").unwrap();
let learn = ActionKind::Learn {
item_key: "skill:a".to_string(),
source: name,
};
let err = execute(&paths, pending(learn.clone())).expect_err("plain learn must stop");
assert!(
matches!(err, crate::error::MindError::LinkOccupied { .. }),
"got {err:?}"
);
assert!(!std::fs::symlink_metadata(&target).unwrap().is_symlink());
execute(&paths, pending(ActionKind::Forced(Box::new(learn)))).expect("forced learn");
assert!(std::fs::symlink_metadata(&target).unwrap().is_symlink());
assert!(
crate::manifest::Manifest::load(&paths)
.unwrap()
.items
.contains_key("skill:a")
);
}
#[test]
fn single_item_install_marks_explicit_and_survives_disable() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_profile_repo(&base);
let name = register(&paths, &src);
execute(
&paths,
pending(ActionKind::EnableProfile {
source: name.clone(),
profile: "full".to_string(),
}),
)
.expect("enable");
execute(
&paths,
pending(ActionKind::Learn {
item_key: "skill:b".to_string(),
source: name.clone(),
}),
)
.expect("learn");
assert!(crate::manifest::Manifest::load(&paths).unwrap().items["skill:b"].explicit);
execute(
&paths,
pending(ActionKind::DisableProfile {
source: name,
profile: "full".to_string(),
}),
)
.expect("disable");
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
assert!(manifest.items.contains_key("skill:b"));
assert!(!manifest.items.contains_key("skill:c"));
}
#[test]
fn bulk_install_does_not_mark_explicit() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_profile_repo(&base);
let name = register(&paths, &src);
execute(
&paths,
pending(ActionKind::EnableProfile {
source: name.clone(),
profile: "full".to_string(),
}),
)
.expect("enable");
execute(
&paths,
pending(ActionKind::Learn {
item_key: "skill:*".to_string(),
source: name,
}),
)
.expect("bulk learn");
let manifest = crate::manifest::Manifest::load(&paths).unwrap();
assert!(manifest.items.contains_key("skill:a"));
assert!(!manifest.items["skill:b"].explicit);
}
#[test]
fn profile_change_preview_lists_installs_and_removals() {
let (paths, base) = temp_paths();
crate::paths::mkdir_p(&paths.mind_home).unwrap();
let src = make_profile_repo(&base);
let name = register(&paths, &src);
let p = commands::profile_change_preview(&paths, &name, "full", true).unwrap();
assert_eq!(
p.install,
vec!["skill:b".to_string(), "skill:c".to_string()]
);
assert!(
crate::manifest::Manifest::load(&paths)
.unwrap()
.items
.is_empty(),
"a preview installs nothing"
);
let p = commands::profile_change_preview(&paths, &name, "full", false).unwrap();
assert_eq!(p, commands::ProfileChangePreview::default());
execute(
&paths,
pending(ActionKind::EnableProfile {
source: name.clone(),
profile: "full".to_string(),
}),
)
.expect("enable");
let p = commands::profile_change_preview(&paths, &name, "full", false).unwrap();
assert_eq!(p.remove, vec!["skill:b".to_string(), "skill:c".to_string()]);
assert!(p.kept.is_empty());
assert_eq!(
crate::manifest::Manifest::load(&paths).unwrap().items.len(),
2,
"a preview forgets nothing"
);
assert!(commands::profile_change_preview(&paths, &name, "nope", true).is_err());
}
#[test]
fn execute_meld_promotes_preview_and_registers_source() {
let (paths, base) = temp_paths();
pin_lobe(&paths);
let src = make_source_repo(&base);
let spec = src.to_str().unwrap().to_string();
let action = PendingAction {
kind: ActionKind::meld(spec.clone()),
description: format!("Meld {spec}?"),
dep_tree: None,
upgrade_keys: Vec::new(),
};
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();
pin_lobe(&paths);
let src = make_source_repo(&base);
let spec = src.to_str().unwrap().to_string();
let action = PendingAction {
kind: ActionKind::meld(spec.clone()),
description: format!("Meld {spec}?"),
dep_tree: None,
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.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,
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.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(),
dropped_requires: Vec::new(),
profiles: Vec::new(),
explicit: false,
version: None,
});
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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
};
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,
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.expect("meld");
commands::learn(
&paths,
"skill:build",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Error,
accept: crate::hook::Accept::default(),
},
)
.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,
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.expect("meld");
commands::learn(
&paths,
"skill:build",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Error,
accept: crate::hook::Accept::default(),
},
)
.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,
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.expect("meld");
commands::learn(
&paths,
"skill:build",
false,
commands::InstallFlow {
yes: true,
clobber: commands::Clobber::Error,
accept: crate::hook::Accept::default(),
},
)
.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,
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.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()),
upgrade_keys: Vec::new(),
};
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()),
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.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(),
),
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.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,
upgrade_keys: Vec::new(),
};
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()),
upgrade_keys: Vec::new(),
};
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,
crate::hook::Accept::default(),
None,
)
.expect("meld source-alpha");
commands::meld(
&paths,
src_b.to_str().unwrap(),
None,
vec![],
vec![],
false,
commands::PinRequest::None,
None,
crate::hook::Accept::default(),
None,
)
.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,
upgrade_keys: Vec::new(),
};
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
);
}
}