use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Sandbox {
base: PathBuf,
mind_home: PathBuf,
claude_home: PathBuf,
}
struct Run {
success: bool,
stdout: String,
stderr: String,
}
impl Sandbox {
fn new() -> Sandbox {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-help-force-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
Sandbox {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
base,
}
}
fn help(&self, args: &[&str]) -> String {
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())
.stdin(Stdio::null())
.output()
.expect("run mind");
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr)
}
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())
.stdin(Stdio::null())
.output()
.expect("run mind");
Run {
success: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
}
fn looks_like_a_clap_parse_error(text: &str) -> bool {
text.contains("unexpected argument")
|| text.contains("unrecognized subcommand")
|| text.contains("error: unrecognized")
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
#[test]
fn link_project_help_names_both_force_effects() {
let sb = Sandbox::new();
let out = sb.help(&["link-project", "--help"]);
assert!(
out.contains("snapshot"),
"link-project --help must still mention snapshot mode for --force: {out}"
);
assert!(
out.contains("backfill"),
"link-project --help must mention the backfill guard override for --force: {out}"
);
}
#[test]
fn config_lobes_add_help_names_both_force_effects() {
let sb = Sandbox::new();
let out = sb.help(&["config", "lobes", "add", "--help"]);
assert!(
out.contains("snapshot"),
"config lobes add --help must still mention snapshot mode for --force: {out}"
);
assert!(
out.contains("backfill"),
"config lobes add --help must mention the backfill guard override for --force: {out}"
);
}
#[test]
fn unmeld_dangerously_skip_hook_check_help_shows_new_name() {
let sb = Sandbox::new();
let out = sb.help(&["unmeld", "--help"]);
assert!(
out.contains("--dangerously-skip-hook-check"),
"unmeld --help must show the renamed flag: {out}"
);
}
#[test]
fn unmeld_accepts_new_flag_and_legacy_alias_both() {
let sb = Sandbox::new();
let new_flag = sb.mind(&["unmeld", "no-such-source", "--dangerously-skip-hook-check"]);
assert!(!new_flag.success, "{}", new_flag.stderr);
assert!(
!looks_like_a_clap_parse_error(&new_flag.stderr),
"the new flag name must parse: {}",
new_flag.stderr
);
assert!(
new_flag.stderr.contains("no melded source matches"),
"must fail with SourceNotFound, not a parse error: {}",
new_flag.stderr
);
let old_flag = sb.mind(&[
"unmeld",
"no-such-source",
"--dangerously-skip-install-hook-check",
]);
assert!(!old_flag.success, "{}", old_flag.stderr);
assert!(
!looks_like_a_clap_parse_error(&old_flag.stderr),
"the legacy alias must still parse: {}",
old_flag.stderr
);
assert!(
old_flag.stderr.contains("no melded source matches"),
"legacy alias must fail with SourceNotFound, not a parse error: {}",
old_flag.stderr
);
}
#[test]
fn forget_dangerously_skip_hook_check_help_shows_new_name() {
let sb = Sandbox::new();
let out = sb.help(&["forget", "--help"]);
assert!(
out.contains("--dangerously-skip-hook-check"),
"forget --help must show the renamed flag: {out}"
);
}
#[test]
fn forget_accepts_new_flag_and_legacy_alias_both() {
let sb = Sandbox::new();
let new_flag = sb.mind(&[
"forget",
"skill:no-such-item",
"--dangerously-skip-hook-check",
]);
assert!(!new_flag.success, "{}", new_flag.stderr);
assert!(
!looks_like_a_clap_parse_error(&new_flag.stderr),
"the new flag name must parse: {}",
new_flag.stderr
);
assert!(
new_flag.stderr.contains("is not installed"),
"must fail with NotInstalled, not a parse error: {}",
new_flag.stderr
);
let old_flag = sb.mind(&[
"forget",
"skill:no-such-item",
"--dangerously-skip-install-hook-check",
]);
assert!(!old_flag.success, "{}", old_flag.stderr);
assert!(
!looks_like_a_clap_parse_error(&old_flag.stderr),
"the legacy alias must still parse: {}",
old_flag.stderr
);
assert!(
old_flag.stderr.contains("is not installed"),
"legacy alias must fail with NotInstalled, not a parse error: {}",
old_flag.stderr
);
}
#[test]
fn unmeld_remove_and_rm_aliases_route_to_unmeld() {
let sb = Sandbox::new();
for verb in ["remove", "rm", "unmeld"] {
let r = sb.mind(&[verb, "no-such-source"]);
assert!(!r.success, "[{verb}] {}", r.stderr);
assert!(
!looks_like_a_clap_parse_error(&r.stderr),
"[{verb}] must be a recognized subcommand: {}",
r.stderr
);
assert!(
r.stderr.contains("no melded source matches"),
"[{verb}] must route to unmeld's SourceNotFound: {}",
r.stderr
);
}
}
#[test]
fn hooks_run_rerun_is_an_alias_for_force() {
let sb = Sandbox::new();
let force = sb.mind(&["hooks", "run", "no-such-target", "--force"]);
let rerun = sb.mind(&["hooks", "run", "no-such-target", "--rerun"]);
assert!(
!looks_like_a_clap_parse_error(&force.stderr),
"--force must parse: {}",
force.stderr
);
assert!(
!looks_like_a_clap_parse_error(&rerun.stderr),
"--rerun must parse: {}",
rerun.stderr
);
assert_eq!(
force.success, rerun.success,
"--force and --rerun must behave identically: force={:?} rerun={:?}",
force.stderr, rerun.stderr
);
assert_eq!(
force.stderr, rerun.stderr,
"--force and --rerun must produce byte-identical output (same field)"
);
}
#[test]
fn hooks_run_help_mentions_rerun_alias() {
let sb = Sandbox::new();
let out = sb.help(&["hooks", "run", "--help"]);
assert!(
out.contains("--rerun"),
"hooks run --help must mention the --rerun alias: {out}"
);
}
#[test]
fn evolve_help_shows_to_flag() {
let sb = Sandbox::new();
let out = sb.help(&["evolve", "--help"]);
assert!(
out.contains("--to"),
"evolve --help must show the --to flag: {out}"
);
}
#[test]
fn evolve_check_accepts_to_and_legacy_version_alias_both() {
let sb = Sandbox::new();
let to_flag = sb.mind(&["evolve", "--check", "--to", "9.9.9"]);
assert!(
to_flag.success,
"evolve --check --to 9.9.9 should succeed: {} {}",
to_flag.stdout, to_flag.stderr
);
assert!(
to_flag.stdout.contains("9.9.9") && to_flag.stdout.contains("available"),
"expected the pinned version to be reported as available: {}",
to_flag.stdout
);
let version_flag = sb.mind(&["evolve", "--check", "--version", "9.9.9"]);
assert!(
version_flag.success,
"the legacy --version alias must still work: {} {}",
version_flag.stdout, version_flag.stderr
);
assert!(
version_flag.stdout.contains("9.9.9") && version_flag.stdout.contains("available"),
"legacy alias must report the same outcome: {}",
version_flag.stdout
);
}
fn looks_like_a_spec_id(token: &str) -> bool {
let Some(dash) = token.find('-') else {
return false;
};
if token[dash + 1..].contains('-') {
return false;
}
let (letters, rest) = token.split_at(dash);
let digits = &rest[1..];
(2..=4).contains(&letters.len())
&& !letters.is_empty()
&& letters.chars().all(|c| c.is_ascii_uppercase())
&& !digits.is_empty()
&& digits.chars().all(|c| c.is_ascii_digit())
}
const KNOWN_NON_SPEC_ID_TOKENS: &[&str] = &[];
fn leaked_spec_id_tokens(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
for c in text.chars() {
if c.is_ascii_alphanumeric() || c == '-' {
current.push(c);
} else if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
.into_iter()
.filter(|t| looks_like_a_spec_id(t) && !KNOWN_NON_SPEC_ID_TOKENS.contains(&t.as_str()))
.collect()
}
fn direct_subcommand_names(help_text: &str) -> Vec<String> {
let mut names = Vec::new();
let mut in_commands = false;
for line in help_text.lines() {
if line.trim_end() == "Commands:" {
in_commands = true;
continue;
}
if !in_commands {
continue;
}
if line.trim().is_empty() {
break;
}
if let Some(name) = line.split_whitespace().next()
&& name != "help"
{
names.push(name.to_string());
}
}
names
}
fn collect_all_help_pages(sb: &Sandbox) -> Vec<(Vec<String>, String)> {
let mut pages = Vec::new();
let mut queue: Vec<Vec<String>> = vec![Vec::new()];
while let Some(path) = queue.pop() {
let mut args: Vec<&str> = path.iter().map(String::as_str).collect();
args.push("--help");
let text = sb.help(&args);
for name in direct_subcommand_names(&text) {
let mut child = path.clone();
child.push(name);
queue.push(child);
}
pages.push((path, text));
}
pages
}
#[test]
fn help_text_does_not_leak_spec_ids_or_enum_names() {
let sb = Sandbox::new();
let pages = collect_all_help_pages(&sb);
assert!(
pages.len() > 20,
"expected to discover the full command tree (>20 pages), found {}: {:?}",
pages.len(),
pages.iter().map(|(p, _)| p.join(" ")).collect::<Vec<_>>()
);
for (path, text) in &pages {
let label = if path.is_empty() {
"mind".to_string()
} else {
format!("mind {}", path.join(" "))
};
let leaked = leaked_spec_id_tokens(text);
assert!(
leaked.is_empty(),
"[{label}] --help text must not leak a bare spec ID, found {leaked:?}: {text}"
);
assert!(
!text.contains("LobeTargetRequired"),
"[{label}] --help text must not leak a Rust enum name: {text}"
);
}
}
#[test]
fn looks_like_a_spec_id_matches_real_ids_and_rejects_lookalikes() {
for real in ["CLI-209", "CLI-206", "DEP-61", "HARN-20", "TUI-1", "NS-40"] {
assert!(looks_like_a_spec_id(real), "{real} should match");
}
for not_id in [
"self-update",
"dry-run",
"owner-repo",
"9-9",
"TOOLONGPREFIX-1",
"A-1",
"CLI-",
"CLI",
"CLI-20-30",
] {
assert!(!looks_like_a_spec_id(not_id), "{not_id} should not match");
}
}
#[test]
fn meld_force_help_mentions_hook_rerun() {
let sb = Sandbox::new();
let out = sb.help(&["meld", "--help"]);
assert!(
out.contains("install hook"),
"meld --help's --force doc must mention re-running install hooks: {out}"
);
}
#[test]
fn meld_learn_forget_help_include_examples_block() {
let sb = Sandbox::new();
for verb in ["meld", "learn", "forget"] {
let out = sb.help(&[verb, "--help"]);
assert!(
out.contains("EXAMPLES"),
"[{verb}] --help must include an EXAMPLES block: {out}"
);
}
}
#[test]
fn sync_about_line_mentions_the_source_selector() {
let sb = Sandbox::new();
let top = sb.help(&["--help"]);
let sync_line = top
.lines()
.find(|l| l.trim_start().starts_with("sync "))
.unwrap_or_else(|| panic!("mind --help must list sync: {top}"));
assert!(
sync_line.contains("source") || sync_line.contains("[source]"),
"sync's about-line in `mind --help` must mention the selector it takes: {sync_line}"
);
let sync_help = sb.help(&["sync", "--help"]);
let about = sync_help
.lines()
.next()
.expect("sync --help must have a first line");
assert!(
about.contains("source"),
"`mind sync --help`'s header must not contradict the [source] selector \
declared beneath it: {about}"
);
}
#[test]
fn sync_source_selector_help_does_not_claim_ambiguity_is_rejected() {
let sb = Sandbox::new();
let out = sb.help(&["sync", "--help"]);
assert!(
!out.contains("unambiguous"),
"sync --help must not claim its selector requires an unambiguous match \
(sync has no AmbiguousSource check, unlike unmeld/upgrade): {out}"
);
assert!(
out.contains("trailing suffix"),
"sync --help should still describe the trailing-suffix selector form: {out}"
);
}
#[test]
fn sync_source_selector_help_notes_the_skipped_whole_set_steps() {
let sb = Sandbox::new();
let out = sb.help(&["sync", "--help"]);
assert!(
out.contains("skip") && out.contains("provisioning"),
"sync --help's [source] selector doc must note that a selector skips \
managed-policy auto-meld provisioning: {out}"
);
}
#[test]
fn sync_upgrade_help_notes_the_pass_is_scoped_to_the_selector() {
let sb = Sandbox::new();
let out = sb.help(&["sync", "--help"]);
let upgrade_block = out
.split("--upgrade")
.nth(1)
.expect("sync --help must document --upgrade");
assert!(
upgrade_block.contains("scoped") || upgrade_block.contains("selector matched"),
"sync --help's --upgrade doc must say the pass is scoped to the \
selected source(s): {out}"
);
assert!(
out.contains("deprecated") || out.contains("prefer"),
"sync --help's --upgrade doc must keep the existing deprecation note \
(CLI-169): {out}"
);
}
#[test]
fn upgrade_help_documents_source_filter() {
let sb = Sandbox::new();
let out = sb.help(&["upgrade", "--help"]);
let block = out
.split("--source")
.nth(1)
.expect("upgrade --help must document --source");
assert!(
block.contains("sync and upgrade") && block.contains("filter"),
"upgrade --help's --source doc must describe the scoped sync: {out}"
);
let sync = sb.help(&["sync", "--help"]);
assert!(
sync.contains("upgrade --source"),
"sync --help's --upgrade deprecation must point at `upgrade --source`: {sync}"
);
}
#[test]
fn evolve_help_mentions_prerelease_offered_its_matching_release() {
let sb = Sandbox::new();
let out = sb.help(&["evolve", "--help"]);
assert!(
out.to_lowercase().contains("prerelease") || out.contains("-dev"),
"evolve --help must mention that a prerelease/dev build is offered \
its matching release: {out}"
);
}
#[test]
fn upgrade_help_does_not_claim_the_qualifier_resolves_to_one_source() {
let sb = Sandbox::new();
let out = sb.help(&["upgrade", "--help"]);
assert!(
!out.contains("must resolve to exactly one source"),
"upgrade --help must not claim the owner/repo# qualifier resolves to \
exactly one source (it has no ambiguity check): {out}"
);
assert!(
out.contains("filter"),
"upgrade --help must describe the owner/repo# qualifier as a filter: {out}"
);
}
#[test]
fn evolve_help_mentions_the_to_flag_can_pin_a_prerelease() {
let sb = Sandbox::new();
let out = sb.help(&["evolve", "--help"]);
assert!(
out.to_lowercase().contains("prerelease"),
"evolve --help must mention the --to flag's prerelease form: {out}"
);
assert!(
out.contains("leading `v`") || out.contains("leading v"),
"evolve --help should note that a single leading v is accepted/stripped: {out}"
);
}
#[test]
fn hooks_run_help_calls_target_a_filter_not_a_ref() {
let sb = Sandbox::new();
let out = sb.help(&["hooks", "run", "--help"]);
assert!(
!out.contains("A ref that matches several"),
"hooks run --help must not call its multi-match target a ref: {out}"
);
assert!(
out.to_lowercase().contains("filter"),
"hooks run --help must call its source target a filter: {out}"
);
}
#[test]
fn sync_upgrade_help_notes_disclosure_not_suppressed_by_yes() {
let sb = Sandbox::new();
let out = sb.help(&["sync", "--help"]);
let upgrade_block = out
.split("--upgrade")
.nth(1)
.expect("sync --help must document --upgrade");
assert!(
upgrade_block.contains("--yes"),
"sync --help's --upgrade doc must carry the --yes caveat: {out}"
);
}