use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Run {
stdout: String,
stderr: String,
success: bool,
}
struct Env {
base: PathBuf,
mind_home: PathBuf,
claude_home: PathBuf,
}
impl Env {
fn new(label: &str) -> Env {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!(
"mind-clone-confine-{label}-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&base);
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
Env {
base,
mind_home,
claude_home,
}
}
fn write_sources_json(&self, json: &str) {
std::fs::write(self.mind_home.join("sources.json"), json).unwrap();
}
fn mind(&self, args: &[&str]) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(args)
.env("MIND_HOME", &self.mind_home)
.env("CLAUDE_HOME", &self.claude_home)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run mind");
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
}
}
impl Drop for Env {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn make_victim(dir: &Path) -> (PathBuf, &'static str) {
let marker = dir.join("marker.txt");
let contents = "do not delete me";
std::fs::create_dir_all(dir).unwrap();
std::fs::write(&marker, contents).unwrap();
(marker, contents)
}
fn assert_victim_intact(marker: &Path, expected: &str, label: &str) {
assert!(
marker.exists(),
"{label}: the out-of-tree victim file must survive at {}",
marker.display()
);
let got = std::fs::read_to_string(marker).unwrap();
assert_eq!(
got, expected,
"{label}: the out-of-tree victim file's contents must be untouched"
);
}
#[test]
fn unmeld_leaves_traversal_victim_intact_shape_parent_dir_parts() {
let env = Env::new("unmeld-shape1");
let (marker, contents) = make_victim(&env.base.join("victim"));
env.write_sources_json(
r#"{
"version": 1,
"sources": [
{
"name": "../../victim",
"url": "https://example.com/victim",
"host": "..",
"owner": "..",
"repo": "victim"
}
]
}"#,
);
let r = env.mind(&["unmeld", "../../victim"]);
assert!(
!r.success,
"unmeld of a traversing source must not report success: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
!r.stdout.contains("unmelded"),
"no success line naming the traversing source may be printed: stdout={}",
r.stdout
);
assert_victim_intact(&marker, contents, "unmeld shape1");
}
#[test]
fn sync_leaves_traversal_victim_intact_shape_parent_dir_parts() {
let env = Env::new("sync-shape1");
let (marker, contents) = make_victim(&env.base.join("victim"));
env.write_sources_json(
r#"{
"version": 1,
"sources": [
{
"name": "../../victim",
"url": "https://example.com/victim",
"host": "..",
"owner": "..",
"repo": "victim"
}
]
}"#,
);
let _ = env.mind(&["sync"]);
assert_victim_intact(&marker, contents, "sync shape1");
}
#[test]
fn unmeld_leaves_traversal_victim_intact_shape_dotted_owner_repo() {
let env = Env::new("unmeld-shape2");
let (marker, contents) = make_victim(&env.base.join("victim2"));
env.write_sources_json(
r#"{
"version": 1,
"sources": [
{
"name": "traversal2",
"url": "https://example.com/victim2",
"host": "../../victim2",
"owner": ".",
"repo": "."
}
]
}"#,
);
let r = env.mind(&["unmeld", "traversal2"]);
assert!(
!r.success,
"unmeld of a traversing source must not report success: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
!r.stdout.contains("unmelded"),
"no success line naming the traversing source may be printed: stdout={}",
r.stdout
);
assert_victim_intact(&marker, contents, "unmeld shape2");
}
#[test]
fn sync_leaves_traversal_victim_intact_shape_dotted_owner_repo() {
let env = Env::new("sync-shape2");
let (marker, contents) = make_victim(&env.base.join("victim2"));
env.write_sources_json(
r#"{
"version": 1,
"sources": [
{
"name": "traversal2",
"url": "https://example.com/victim2",
"host": "../../victim2",
"owner": ".",
"repo": "."
}
]
}"#,
);
let _ = env.mind(&["sync"]);
assert_victim_intact(&marker, contents, "sync shape2");
}
const STALE_DOTDOT_REPO_REGISTRY: &str = r#"{
"version": 1,
"sources": [
{
"name": "example.com/acme/..",
"url": "https://example.com/acme/x",
"host": "example.com",
"owner": "acme",
"repo": ".."
},
{
"name": "github.com/acme/agents",
"url": "https://github.com/acme/agents",
"host": "github.com",
"owner": "acme",
"repo": "agents"
}
]
}"#;
#[test]
fn a_stale_dotdot_repo_entry_is_dropped_with_a_warning_and_does_not_brick_the_cli() {
let env = Env::new("stale-repo");
env.write_sources_json(STALE_DOTDOT_REPO_REGISTRY);
let r = env.mind(&["recall", "--sources"]);
assert!(
r.success,
"a stale entry must not brick a read-only verb: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("sources.json") && r.stderr.contains("repo"),
"the warning must name sources.json and the offending part: {}",
r.stderr
);
assert!(
r.stderr.contains("example.com/acme/.."),
"the warning must name the dropped entry: {}",
r.stderr
);
assert!(
!r.stdout.contains("example.com"),
"the dropped entry must not be listed as a melded source: {}",
r.stdout
);
assert!(
r.stdout.contains("github.com/acme/agents"),
"the healthy entry must survive: {}",
r.stdout
);
}
#[test]
fn the_drop_is_not_written_back_until_a_verb_saves_the_registry() {
let env = Env::new("stale-persist");
env.write_sources_json(STALE_DOTDOT_REPO_REGISTRY);
let file = env.mind_home.join("sources.json");
assert!(env.mind(&["recall", "--sources"]).success);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
STALE_DOTDOT_REPO_REGISTRY,
"a read-only verb must not rewrite sources.json"
);
let u = env.mind(&["unmeld", "github.com/acme/agents", "--unlink-only"]);
assert!(u.success, "unmeld failed: {} {}", u.stdout, u.stderr);
let after = std::fs::read_to_string(&file).unwrap();
assert!(
!after.contains("\"repo\": \"..\"") && !after.contains("\"repo\":\"..\""),
"the drop must be persisted by the next registry save: {after}"
);
}