use std::process::{Command, Stdio};
use tempfile::TempDir;
mod common;
const MX: &str = env!("CARGO_BIN_EXE_mx");
const HOOK_PASTE: &str = include_str!("fixtures/doors/hook-paste.json");
const WALDEN: &str = "tests/fixtures/doors/pd/walden.txt";
const SCHEMA_BASE: &str = r#"
[keys.facts]
type = "list"
[keys.doors_fired]
type = "history"
max_entries = 5000
[keys.doors_fired.data]
session = { type = "string", required = true }
key = { type = "string", required = true }
entry = { type = "string", required = true }
trigger = { type = "string" }
"#;
const ORIGIN_FIELD: &str = "origin = { type = \"string\" }\n";
fn setup(with_origin: bool) -> TempDir {
setup_schema(if with_origin { ORIGIN_FIELD } else { "" })
}
fn setup_schema(extra_fields: &str) -> TempDir {
let dir = TempDir::new().unwrap();
let schema_dir = dir.path().join("kv").join("schema");
std::fs::create_dir_all(&schema_dir).unwrap();
std::fs::write(
schema_dir.join("test.toml"),
format!("{SCHEMA_BASE}{extra_fields}"),
)
.unwrap();
dir
}
fn mx_stdin(dir: &TempDir, args: &[&str], stdin: &str) -> std::process::Output {
let mut cmd = Command::new(MX);
common::isolate(&mut cmd, dir.path());
cmd.args(args)
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env("MX_CURRENT_AGENT", "test")
.env_remove("MX_KV_SCHEMA")
.env_remove("MX_KV_DATA")
.env_remove("MX_DOORS_BUDGET")
.env_remove("MX_DOORS_NAMES")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("failed to spawn mx");
{
use std::io::Write;
child
.stdin
.as_mut()
.unwrap()
.write_all(stdin.as_bytes())
.unwrap();
}
drop(child.stdin.take());
child.wait_with_output().expect("failed to run mx")
}
fn mx(dir: &TempDir, args: &[&str]) -> std::process::Output {
mx_stdin(dir, args, "")
}
fn ok(out: &std::process::Output, what: &str) -> String {
assert!(
out.status.success(),
"{what} failed ({:?}): {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
}
fn push_door(dir: &TempDir, value: &str, trigger: &str) -> String {
let out = ok(
&mx(dir, &["kv", "push", "facts", value, "--trigger", trigger]),
"kv push",
);
out.split_whitespace()
.next()
.unwrap()
.trim_start_matches("kv-")
.to_string()
}
fn fire_rows(dir: &TempDir) -> Vec<serde_json::Value> {
let out = mx(
dir,
&["kv", "last", "doors_fired", "--count", "100", "--json"],
);
if !out.status.success() {
return Vec::new();
}
serde_json::from_slice(&out.stdout).unwrap_or_default()
}
fn hook_session() -> String {
let v: serde_json::Value = serde_json::from_str(HOOK_PASTE).unwrap();
v["session_id"].as_str().unwrap().to_string()
}
#[test]
fn hook_fires_the_typed_trigger_and_holds_the_paste() {
let dir = setup(true);
push_door(&dir, "Berries picked on the hill.", "huckleberries");
push_door(&dir, "A pond south of town.", "fair haven");
let stdout = ok(
&mx_stdin(&dir, &["doors", "hook"], HOOK_PASTE),
"doors hook",
);
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 1, "{stdout}");
assert!(lines[0].contains("huckleberries"), "{stdout}");
let rows = fire_rows(&dir);
assert_eq!(
rows.len(),
1,
"the held paste door is never recorded: {rows:?}"
);
let d = &rows[0]["data"];
assert_eq!(d["trigger"], "huckleberries");
assert_eq!(d["origin"], "typed");
assert_eq!(d["session"], hook_session().as_str());
}
#[test]
fn schema_without_origin_still_fires_and_records() {
let dir = setup(false);
push_door(&dir, "Berries picked on the hill.", "huckleberries");
let stdout = ok(
&mx_stdin(&dir, &["doors", "hook"], HOOK_PASTE),
"doors hook",
);
assert!(stdout.contains("huckleberries"), "{stdout}");
let rows = fire_rows(&dir);
assert_eq!(rows.len(), 1);
assert!(rows[0]["data"].get("origin").is_none(), "{rows:?}");
}
#[test]
fn origin_declared_as_a_non_string_is_not_written() {
let dir = setup_schema("origin = { type = \"number\" }\n");
push_door(&dir, "Berries picked on the hill.", "huckleberries");
let stdout = ok(
&mx_stdin(&dir, &["doors", "hook"], HOOK_PASTE),
"doors hook",
);
assert!(stdout.contains("huckleberries"), "{stdout}");
let rows = fire_rows(&dir);
assert_eq!(rows.len(), 1);
assert!(rows[0]["data"].get("origin").is_none(), "{rows:?}");
}
#[test]
fn legacy_rows_without_origin_still_dedup_and_count() {
let dir = setup(true);
let id = push_door(&dir, "Berries picked on the hill.", "huckleberries");
let legacy = serde_json::json!({
"session": hook_session(),
"key": "facts",
"entry": id,
"trigger": "huckleberries",
})
.to_string();
ok(
&mx(
&dir,
&[
"kv",
"push",
"doors_fired",
"huckleberries",
"--data",
&legacy,
],
),
"push legacy row",
);
let out = ok(
&mx_stdin(&dir, &["doors", "hook"], HOOK_PASTE),
"doors hook",
);
assert_eq!(out, "");
ok(
&mx(
&dir,
&["doors", "check", "huckleberries", "--session", "other"],
),
"check",
);
let rows = fire_rows(&dir);
assert_eq!(rows.len(), 2);
let stats: serde_json::Value =
serde_json::from_str(&ok(&mx(&dir, &["doors", "stats", "--json"]), "stats")).unwrap();
assert_eq!(stats["total_fires"], 2);
assert_eq!(stats["entries"][0]["fires"], 2);
}
#[test]
fn explain_shows_segments_verdicts_and_held_doors_without_recording() {
let dir = setup(true);
let berries = push_door(&dir, "Berries picked on the hill.", "huckleberries");
let haven = push_door(&dir, "A pond south of town.", "fair haven");
let out = ok(
&mx(
&dir,
&[
"doors",
"check",
"about huckleberries",
"--paste",
WALDEN,
"--explain",
],
),
"check --explain",
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 5, "{out}");
assert_eq!(lines[0], "mode house (default)");
assert_eq!(
lines[1],
"segment 1 typed 21 ch not classified (only pastes are classified) places {house}"
);
assert!(lines[2].starts_with("segment 2 paste "), "{out}");
assert!(
lines[2].contains("verdict ForeignProse F=5 O=0 [continuous-prose"),
"{out}"
);
assert!(lines[2].ends_with("places \u{2205} (quarantined)"), "{out}");
assert_eq!(
lines[3],
format!("huckleberries facts/kv-{berries} FIRED seg 1 place house pool house")
);
assert_eq!(
lines[4],
format!("fair haven facts/kv-{haven} HELD seg 2 place - pool -")
);
assert!(fire_rows(&dir).is_empty(), "--explain implies --dry-run");
}
#[test]
fn explain_json_has_the_same_structure() {
let dir = setup(true);
push_door(&dir, "A pond south of town.", "fair haven");
let out = ok(
&mx(
&dir,
&[
"doors",
"check",
"hi",
"--paste",
WALDEN,
"--explain",
"--json",
],
),
"check --explain --json",
);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
let segs = v["segments"].as_array().unwrap();
assert_eq!(segs.len(), 2);
assert_eq!(segs[0]["origin"], "typed");
assert_eq!(segs[0]["verdict"], serde_json::Value::Null);
assert_eq!(segs[0]["reason"], "only pastes are classified");
assert_eq!(segs[0]["gated"], serde_json::Value::Null);
assert_eq!(segs[0]["places"], serde_json::json!(["house"]));
assert_eq!(segs[1]["origin"], "paste");
assert_eq!(segs[1]["verdict"], "ForeignProse");
assert_eq!(segs[1]["reason"], serde_json::Value::Null);
assert_eq!(segs[1]["gated"], false);
assert_eq!(segs[1]["quarantined"], true);
assert_eq!(segs[1]["places"], serde_json::json!([]));
assert_eq!(segs[1]["signals"][0]["name"], "continuous-prose");
assert_eq!(v["doors"][0]["status"], "HELD");
assert_eq!(v["doors"][0]["origin"], "paste");
assert_eq!(v["doors"][0]["segment"], 2);
assert_eq!(v["doors"][0]["pool"], serde_json::Value::Null);
assert_eq!(v["fired"], serde_json::json!([]));
}
#[test]
fn only_a_tagged_paste_is_ever_held() {
let dir = setup(true);
push_door(&dir, "A pond south of town.", "fair haven");
let walden = std::fs::read_to_string(WALDEN).unwrap();
assert!(walden.chars().count() >= 600);
let explain = |extra: &[&str], message: &str| -> serde_json::Value {
let mut args = vec!["doors", "check", message, "--explain", "--json"];
args.extend_from_slice(extra);
serde_json::from_str(&ok(&mx(&dir, &args), "check --explain")).unwrap()
};
for (extra, origin) in [(&[][..], "typed"), (&["--from", "@j:s"][..], "channel")] {
let v = explain(extra, &walden);
assert_eq!(v["segments"][0]["origin"], origin);
assert_eq!(v["segments"][0]["verdict"], serde_json::Value::Null);
assert_eq!(v["segments"][0]["quarantined"], false);
assert_eq!(v["doors"][0]["status"], "FIRED", "{v}");
assert_eq!(v["fired"][0]["origin"], origin);
}
let v = explain(&["--paste", WALDEN], "");
assert_eq!(v["segments"][0]["verdict"], "ForeignProse");
assert_eq!(v["doors"][0]["status"], "HELD");
assert_eq!(v["fired"], serde_json::json!([]));
}
#[test]
fn from_wraps_the_message_in_a_channel_block() {
let dir = setup(true);
push_door(&dir, "A sample widget.", "widget");
let out = ok(
&mx(
&dir,
&[
"doors",
"check",
"good morning widget",
"--from",
"@j:s",
"--explain",
"--json",
],
),
"check --from --explain",
);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["segments"][0]["origin"], "channel");
assert_eq!(v["segments"][0]["user_id"], "@j:s");
assert_eq!(v["fired"][0]["origin"], "channel");
ok(
&mx(
&dir,
&[
"doors",
"check",
"good morning widget",
"--from",
"@j:s",
"--json",
],
),
"check --from",
);
let rows = fire_rows(&dir);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["data"]["origin"], "channel");
}
#[test]
fn names_from_env_reach_the_classifier() {
let dir = setup(true);
let mut cmd = Command::new(MX);
common::isolate(&mut cmd, dir.path());
let out = cmd
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env("MX_CURRENT_AGENT", "test")
.env_remove("MX_KV_SCHEMA")
.env_remove("MX_KV_DATA")
.env_remove("MX_DOORS_BUDGET")
.env("MX_DOORS_NAMES", "robin, sam")
.args([
"doors",
"check",
"",
"--paste",
"tests/fixtures/doors/syn/syn-chatlog.txt",
"--explain",
"--json",
])
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let names = v["segments"][0]["signals"]
.as_array()
.unwrap()
.iter()
.find(|s| s["name"] == "names-us")
.cloned()
.unwrap();
assert_eq!(names["value"], 21.0);
}
#[test]
fn missing_paste_file_is_an_error() {
let dir = setup(true);
let out = mx(
&dir,
&[
"doors",
"check",
"hi",
"--paste",
"tests/fixtures/doors/nope.txt",
],
);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("nope.txt"));
}