use std::process::Command;
use std::sync::{Mutex, MutexGuard, OnceLock};
use tempfile::TempDir;
const MX: &str = env!("CARGO_BIN_EXE_mx");
fn test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct Env {
dir: TempDir,
_guard: MutexGuard<'static, ()>,
}
fn setup() -> Env {
let guard = test_lock().lock().unwrap_or_else(|e| e.into_inner());
Env {
dir: TempDir::new().unwrap(),
_guard: guard,
}
}
fn mx(env: &Env, args: &[&str]) -> std::process::Output {
Command::new(MX)
.args(args)
.env("MX_HOME", env.dir.path())
.env("MX_CURRENT_AGENT", "test")
.output()
.expect("failed to run mx")
}
fn add_with_triggers(dir: &Env, triggers: &str) -> (String, Vec<String>) {
let out = mx(
dir,
&[
"memory",
"add",
"--category",
"insight",
"--title",
"Trig Test",
"--content",
"body",
"--triggers",
triggers,
"--json",
],
);
assert!(
out.status.success(),
"add failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("add --json output should parse");
let id = v["id"].as_str().expect("id present").to_string();
let triggers = json_triggers(&v);
(id, triggers)
}
fn add_plain(dir: &Env) -> String {
let out = mx(
dir,
&[
"memory",
"add",
"--category",
"insight",
"--title",
"Plain",
"--content",
"body",
"--json",
],
);
assert!(
out.status.success(),
"add failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
v["id"].as_str().unwrap().to_string()
}
fn show_triggers(dir: &Env, id: &str) -> Vec<String> {
let out = mx(dir, &["memory", "show", id, "--json"]);
assert!(
out.status.success(),
"show failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("show --json output should parse");
json_triggers(&v)
}
fn json_triggers(v: &serde_json::Value) -> Vec<String> {
v["triggers"]
.as_array()
.expect("triggers array present")
.iter()
.map(|t| t.as_str().unwrap().to_string())
.collect()
}
#[test]
fn add_stores_triggers_normalized_and_deduped() {
let dir = setup();
let (id, triggers) = add_with_triggers(&dir, "Blood Sugar, brad, BRAD");
assert_eq!(
triggers,
vec!["blood sugar".to_string(), "brad".to_string()],
"triggers should be lowercased, whitespace-collapsed, and deduped"
);
assert_eq!(show_triggers(&dir, &id), triggers);
}
#[test]
fn add_without_triggers_is_empty() {
let dir = setup();
let id = add_plain(&dir);
assert!(show_triggers(&dir, &id).is_empty());
}
#[test]
fn show_renders_triggers_line() {
let dir = setup();
let (id, _) = add_with_triggers(&dir, "Alpha, Beta");
let out = mx(&dir, &["memory", "show", &id]);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("Triggers: alpha, beta"),
"show output should render a Triggers line: {stdout}"
);
}
#[test]
fn update_triggers_replaces_full_set() {
let dir = setup();
let (id, _) = add_with_triggers(&dir, "old one, old two");
let out = mx(
&dir,
&[
"memory",
"update",
&id,
"--triggers",
"X One, x one, Foo Bar",
],
);
assert!(
out.status.success(),
"update failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
show_triggers(&dir, &id),
vec!["x one".to_string(), "foo bar".to_string()]
);
}
#[test]
fn add_trigger_appends_normalized() {
let dir = setup();
let (id, _) = add_with_triggers(&dir, "alpha");
let out = mx(
&dir,
&["memory", "update", &id, "--add-trigger", "Beta Gamma"],
);
assert!(out.status.success());
assert_eq!(
show_triggers(&dir, &id),
vec!["alpha".to_string(), "beta gamma".to_string()]
);
}
#[test]
fn add_trigger_existing_is_noop() {
let dir = setup();
let (id, _) = add_with_triggers(&dir, "alpha, beta");
let out = mx(&dir, &["memory", "update", &id, "--add-trigger", "ALPHA"]);
assert!(out.status.success());
assert_eq!(
show_triggers(&dir, &id),
vec!["alpha".to_string(), "beta".to_string()],
"adding an existing trigger should be a no-op (no dupes)"
);
}
#[test]
fn remove_trigger_removes_by_normalized_form() {
let dir = setup();
let (id, _) = add_with_triggers(&dir, "alpha, beta, gamma");
let out = mx(
&dir,
&["memory", "update", &id, "--remove-trigger", "Beta "],
);
assert!(out.status.success());
assert_eq!(
show_triggers(&dir, &id),
vec!["alpha".to_string(), "gamma".to_string()]
);
}
#[test]
fn remove_trigger_absent_is_clean_noop() {
let dir = setup();
let (id, _) = add_with_triggers(&dir, "alpha, beta");
let out = mx(&dir, &["memory", "update", &id, "--remove-trigger", "nope"]);
assert!(
out.status.success(),
"removing an absent trigger should be a clean success: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
show_triggers(&dir, &id),
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn triggers_conflicts_with_add_trigger() {
let dir = setup();
let id = add_plain(&dir);
let out = mx(
&dir,
&[
"memory",
"update",
&id,
"--triggers",
"a",
"--add-trigger",
"b",
],
);
assert!(
!out.status.success(),
"--triggers + --add-trigger must be rejected by clap"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("cannot be used with") || stderr.to_lowercase().contains("usage"),
"expected a clap conflict error: {stderr}"
);
}
#[test]
fn triggers_conflicts_with_remove_trigger() {
let dir = setup();
let id = add_plain(&dir);
let out = mx(
&dir,
&[
"memory",
"update",
&id,
"--triggers",
"a",
"--remove-trigger",
"b",
],
);
assert!(
!out.status.success(),
"--triggers + --remove-trigger must be rejected by clap"
);
}