use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
const CLEAR: &str = "\r\x1b[2K";
struct Run {
stdout: String,
stderr: String,
success: bool,
}
struct Sandbox {
base: PathBuf,
mind_home: PathBuf,
claude_home: PathBuf,
}
impl Sandbox {
fn new() -> Sandbox {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-progress-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("mind")).unwrap();
std::fs::create_dir_all(base.join("claude")).unwrap();
Sandbox {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
base,
}
}
fn cmd(&self, progress: Option<&str>, args: &[&str]) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_mind"));
cmd.args(args)
.env("MIND_HOME", &self.mind_home)
.env("CLAUDE_HOME", &self.claude_home)
.env("LC_ALL", "C.UTF-8")
.env("COLUMNS", "400")
.env_remove("MIND_PROGRESS")
.env_remove("MIND_AGENT_HOMES")
.env_remove("MIND_POLICY_FILE")
.env_remove("MIND_MAX_METADATA_SIZE")
.env_remove("MIND_MAX_PARALLEL_FETCHES")
.stdin(Stdio::null());
if let Some(p) = progress {
cmd.env("MIND_PROGRESS", p);
}
cmd
}
fn run(&self, progress: Option<&str>, args: &[&str]) -> Run {
let out = self
.cmd(progress, args)
.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(),
}
}
fn run_combined(&self, progress: Option<&str>, args: &[&str]) -> (String, bool) {
let path = self.base.join("combined.out");
let file = std::fs::File::create(&path).unwrap();
let status = self
.cmd(progress, args)
.stdout(file.try_clone().unwrap())
.stderr(file)
.status()
.expect("run mind");
(std::fs::read_to_string(&path).unwrap(), status.success())
}
fn repo(&self, name: &str, mind_toml: Option<&str>) -> String {
let dir = self.base.join(name);
write(
&dir.join("skills/demo/SKILL.md"),
"---\nname: demo\ndescription: Demo skill\n---\nbody\n",
);
if let Some(toml) = mind_toml {
write(&dir.join("mind.toml"), toml);
}
git(&dir, &["init", "-q", "-b", "main"]);
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "init"]);
dir.to_string_lossy().into_owned()
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn write(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z")
.status()
.unwrap()
.success();
assert!(ok, "git {args:?}");
}
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
fn has_spinner(s: &str) -> bool {
s.contains(CLEAR)
}
fn draws(s: &str) -> Vec<&str> {
s.split(CLEAR)
.filter(|seg| seg.starts_with(FRAMES))
.collect()
}
fn labels(s: &str) -> Vec<&str> {
draws(s)
.into_iter()
.filter_map(|d| d.split_once(' ').map(|(_, label)| label))
.collect()
}
#[test]
fn always_draws_labeled_steps_on_stderr_and_leaves_stdout_alone() {
let sb = Sandbox::new();
let repo = sb.repo("src", None);
let r = sb.run(Some("always"), &["meld", &repo, "--pin", "HEAD", "--yes"]);
assert!(r.success, "{}{}", r.stdout, r.stderr);
let shown = labels(&r.stderr);
assert!(
shown.contains(&format!("cloning {repo}").as_str()),
"the clone shows a labeled step: {shown:?}"
);
assert!(
shown
.iter()
.any(|l| l.starts_with("installing ") && l.contains("demo")),
"the install shows a labeled step: {shown:?}"
);
let tail = r.stderr.rsplit(CLEAR).next().unwrap();
assert!(
!tail.starts_with(FRAMES),
"the last step is cleared: {:?}",
r.stderr
);
assert!(
!has_spinner(&r.stdout) && !r.stdout.contains(FRAMES),
"stdout carries no spinner: {:?}",
r.stdout
);
let off = Sandbox::new();
let repo_off = off.repo("src", None);
let r_off = off.run(
Some("never"),
&["meld", &repo_off, "--pin", "HEAD", "--yes"],
);
assert!(r_off.success, "{}{}", r_off.stdout, r_off.stderr);
let norm = |s: &str, sb: &Sandbox| {
let name = sb.base.file_name().unwrap().to_string_lossy().into_owned();
s.replace(&*sb.base.to_string_lossy(), "<base>")
.replace(&name, "<base>")
};
assert_eq!(norm(&r.stdout, &sb), norm(&r_off.stdout, &off));
}
#[test]
fn progress_is_off_by_flag_json_never_and_off_a_terminal() {
let sb = Sandbox::new();
let repo = sb.repo("src", None);
let cases: [(Option<&str>, &[&str]); 4] = [
(Some("always"), &["--no-progress"]),
(Some("always"), &["--json"]),
(Some("never"), &[]),
(None, &[]),
];
for (i, (setting, extra)) in cases.iter().enumerate() {
let home = Sandbox::new();
let mut args = vec!["meld", repo.as_str(), "--pin", "HEAD", "--yes"];
args.extend_from_slice(extra);
let r = home.run(*setting, &args);
assert!(r.success, "case {i}: {}{}", r.stdout, r.stderr);
assert!(
!has_spinner(&r.stderr) && !has_spinner(&r.stdout),
"case {i} ({setting:?} {extra:?}) drew a spinner: {:?}",
r.stderr
);
}
}
#[test]
fn parallel_sync_counts_finished_sources() {
let sb = Sandbox::new();
for name in ["one", "two"] {
let repo = sb.repo(name, None);
let r = sb.run(
Some("never"),
&["meld", &repo, "--pin", "HEAD", "--register-only"],
);
assert!(r.success, "{}{}", r.stdout, r.stderr);
}
let r = sb.run(Some("always"), &["sync"]);
assert!(r.success, "{}{}", r.stdout, r.stderr);
let shown = labels(&r.stderr);
assert!(
shown.contains(&"syncing sources (0/2)") && shown.contains(&"syncing sources (2/2)"),
"{shown:?}"
);
assert!(
!shown.iter().any(|l| l.starts_with("fetching ")),
"a worker's git call does not replace the counted label: {shown:?}"
);
}
#[cfg(unix)]
fn real_git() -> PathBuf {
std::env::split_paths(&std::env::var_os("PATH").unwrap())
.map(|d| d.join("git"))
.find(|p| p.is_file())
.expect("git on PATH")
}
#[cfg(unix)]
#[test]
fn ctrl_c_erases_a_drawn_spinner_line() {
erases_the_line_on(libc::SIGINT);
}
#[cfg(unix)]
#[test]
fn sigterm_erases_a_drawn_spinner_line() {
erases_the_line_on(libc::SIGTERM);
}
#[cfg(unix)]
fn erases_the_line_on(sig: libc::c_int) {
use std::os::unix::fs::PermissionsExt;
use std::os::unix::process::ExitStatusExt;
let sb = Sandbox::new();
let repo = sb.repo("src", None);
let bin = sb.base.join("bin");
let stub = bin.join("git");
write(
&stub,
&format!(
"#!/bin/sh\nif [ \"$1\" = clone ]; then exec sleep 5; fi\nexec '{}' \"$@\"\n",
real_git().display()
),
);
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = std::env::join_paths(
std::iter::once(bin).chain(std::env::split_paths(&std::env::var_os("PATH").unwrap())),
)
.unwrap();
let err_path = sb.base.join("stderr.out");
let mut child = sb
.cmd(Some("always"), &["meld", &repo, "--pin", "HEAD", "--yes"])
.env("PATH", path)
.stdout(Stdio::null())
.stderr(std::fs::File::create(&err_path).unwrap())
.spawn()
.expect("run mind");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !labels(&std::fs::read_to_string(&err_path).unwrap())
.iter()
.any(|l| l.starts_with("cloning "))
{
assert!(
std::time::Instant::now() < deadline,
"the clone step never drew"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
unsafe {
libc::kill(child.id() as libc::pid_t, sig);
}
let status = child.wait().unwrap();
assert_eq!(status.signal(), Some(sig), "mind dies of the signal");
let err = std::fs::read_to_string(&err_path).unwrap();
assert!(err.ends_with(CLEAR), "the spinner line is erased: {err:?}");
}
#[test]
fn a_command_blocked_on_the_lock_says_it_is_waiting() {
const WAITING: &str = "waiting for another mind command to finish";
let sb = Sandbox::new();
let mut held = fd_lock::RwLock::new(
std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(sb.mind_home.join(".lock"))
.unwrap(),
);
let guard = held.write().unwrap();
let err_path = sb.base.join("stderr.out");
let mut child = sb
.cmd(Some("always"), &["recall"])
.stdout(Stdio::null())
.stderr(std::fs::File::create(&err_path).unwrap())
.spawn()
.expect("run mind");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !labels(&std::fs::read_to_string(&err_path).unwrap()).contains(&WAITING) {
assert!(
std::time::Instant::now() < deadline,
"the wait step never drew"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
drop(guard);
assert!(child.wait().unwrap().success());
let r = sb.run(Some("always"), &["recall"]);
assert!(r.success, "{}{}", r.stdout, r.stderr);
assert!(!labels(&r.stderr).contains(&WAITING), "{:?}", r.stderr);
}
#[test]
fn nothing_else_lands_on_a_drawn_spinner_line() {
let sb = Sandbox::new();
let repo = sb.repo(
"src",
Some("[[hooks]]\nname = \"setup\"\nrun = \"echo hook-ran\"\nevent = \"install\"\n"),
);
let (out, ok) = sb.run_combined(
Some("always"),
&["meld", &repo, "--pin", "HEAD", "--yes", "--accept-hooks"],
);
assert!(ok, "{out}");
assert!(out.contains("hook-ran"), "the hook ran: {out:?}");
assert!(has_spinner(&out), "progress drew: {out:?}");
for seg in draws(&out) {
assert!(
!seg.contains('\n'),
"output was written onto a spinner line: {seg:?}\nfull: {out:?}"
);
}
}