use std::fs;
use std::process::Command;
use tempfile::TempDir;
const MX: &str = env!("CARGO_BIN_EXE_mx");
fn setup(schema_toml: &str) -> TempDir {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("schema.toml"), schema_toml).unwrap();
dir
}
fn mx(dir: &TempDir, args: &[&str]) -> std::process::Output {
Command::new(MX)
.args(args)
.env("MX_CURRENT_AGENT", "test")
.env("MX_KV_SCHEMA", dir.path().join("schema.toml"))
.env("MX_KV_DATA", dir.path().join("data.json"))
.output()
.expect("failed to run mx")
}
fn push(dir: &TempDir, key: &str, value: &str) -> String {
let out = mx(dir, &["kv", "push", key, value]);
assert!(
out.status.success(),
"push failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn push_with_data(dir: &TempDir, key: &str, value: &str, data: &str) -> String {
let out = mx(dir, &["kv", "push", key, value, "--data", data]);
assert!(
out.status.success(),
"push --data failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn index_from_push(push_out: &str) -> String {
push_out
.trim_end_matches(')')
.rsplit('(')
.next()
.unwrap()
.trim()
.to_string()
}
fn id_from_push(push_out: &str) -> String {
push_out.split_whitespace().next().unwrap().to_string()
}
const BASIC_SCHEMA: &str = r#"
[keys.log]
type = "history"
[keys.items]
type = "list"
[keys.counter]
type = "counter"
default = "0"
"#;
const VALIDATED_SCHEMA: &str = r#"
[keys.tasks]
type = "history"
[keys.tasks.data]
status = { type = "string", required = true }
priority = { type = "number" }
"#;
#[test]
fn empty_patch_is_accepted_but_changes_nothing() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "hello");
let idx = index_from_push(&pushed);
let out = mx(&dir, &["kv", "update", "log", "--id", &idx, "--data", "{}"]);
assert!(
!out.status.success(),
"empty --data {{}} with no value must be rejected, got exit {:?}",
out.status.code()
);
assert_eq!(
out.status.code(),
Some(4),
"expected EXIT_INVALID_INPUT (4) for empty patch with no value"
);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("empty object") || stderr.contains("nothing to update"),
"stderr must explain the rejection: {}",
stderr
);
}
#[test]
fn patch_all_nulls_on_entry_without_data_yields_none() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "world");
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&[
"kv",
"update",
"log",
"--id",
&idx,
"--data",
r#"{"foo": null, "bar": null}"#,
],
);
assert!(
out.status.success(),
"null-only patch on entry with no data should succeed, got: {}",
String::from_utf8_lossy(&out.stderr)
);
let get = mx(&dir, &["kv", "get", "log", "--json"]);
let out_str = String::from_utf8_lossy(&get.stdout).to_string();
assert!(
!out_str.contains("\"foo\"") && !out_str.contains("\"bar\""),
"null-deleted fields must not appear in output: {}",
out_str
);
}
#[test]
fn update_value_to_empty_string_is_accepted() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "non-empty");
let idx = index_from_push(&pushed);
let out = mx(&dir, &["kv", "update", "log", "--id", &idx, ""]);
assert!(
out.status.success(),
"empty string value update should succeed, got: {}",
String::from_utf8_lossy(&out.stderr)
);
let get = mx(&dir, &["kv", "get", "log", "--json"]);
let get_str = String::from_utf8_lossy(&get.stdout).to_string();
assert!(
get_str.contains("\"value\":\"\"") || get_str.contains("\"value\": \"\""),
"value should be empty string after update, got: {}",
get_str
);
}
#[test]
fn update_nonexistent_key_returns_key_not_found_exit_code() {
let dir = setup(BASIC_SCHEMA);
let out = mx(
&dir,
&["kv", "update", "does_not_exist", "--id", "0", "newval"],
);
assert!(!out.status.success(), "update on nonexistent key must fail");
assert_eq!(
out.status.code(),
Some(1),
"expected exit code 1 (KEY_NOT_FOUND), got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn update_on_empty_history_returns_entry_not_found() {
let dir = setup(BASIC_SCHEMA);
let out = mx(&dir, &["kv", "update", "log", "--id", "0", "newval"]);
assert!(!out.status.success(), "update on empty history must fail");
assert_eq!(
out.status.code(),
Some(4),
"expected exit code 4 (INVALID_INPUT/EntryNotFound), got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn update_by_full_id_string_succeeds() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "exact");
let full_id = id_from_push(&pushed);
let out = mx(
&dir,
&["kv", "update", "log", "--id", &full_id, "exact-updated"],
);
assert!(
out.status.success(),
"update by full ID must succeed, got: {}",
String::from_utf8_lossy(&out.stderr)
);
let get = mx(&dir, &["kv", "get", "log"]);
assert!(
String::from_utf8_lossy(&get.stdout).contains("exact-updated"),
"entry value must be updated"
);
}
#[test]
fn multiple_sequential_updates_preserve_ts_and_id() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "v1");
let idx = index_from_push(&pushed);
let out1 = mx(&dir, &["kv", "update", "log", "--id", &idx, "v2"]);
assert!(out1.status.success(), "update 1 must succeed");
let out2 = mx(&dir, &["kv", "update", "log", "--id", &idx, "v3"]);
assert!(out2.status.success(), "update 2 must succeed");
let out3 = mx(&dir, &["kv", "update", "log", "--id", &idx, "v4"]);
assert!(out3.status.success(), "update 3 must succeed");
let get = mx(&dir, &["kv", "get", "log", "--json"]);
let json_str = String::from_utf8_lossy(&get.stdout).to_string();
assert!(
json_str.contains("v4"),
"final value should be v4, got: {}",
json_str
);
let value_count = json_str.matches("\"value\"").count();
assert_eq!(
value_count, 1,
"should have exactly one entry after 3 updates, got {} value fields in: {}",
value_count, json_str
);
}
#[test]
fn update_counter_key_returns_type_mismatch_exit_code() {
let dir = setup(BASIC_SCHEMA);
let out = mx(&dir, &["kv", "update", "counter", "--id", "0", "99"]);
assert!(!out.status.success(), "update on counter must fail");
assert_eq!(
out.status.code(),
Some(2),
"expected exit code 2 (TYPE_MISMATCH), got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn data_as_string_is_rejected() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "entry");
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&["kv", "update", "log", "--id", &idx, "--data", "\"hello\""],
);
assert!(!out.status.success(), "string --data must be rejected");
assert_eq!(
out.status.code(),
Some(4),
"expected exit 4 for non-object data"
);
}
#[test]
fn data_as_array_is_rejected() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "entry");
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&["kv", "update", "log", "--id", &idx, "--data", "[1,2,3]"],
);
assert!(!out.status.success(), "array --data must be rejected");
assert_eq!(out.status.code(), Some(4), "expected exit 4 for array data");
}
#[test]
fn data_as_null_is_rejected() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "entry");
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&["kv", "update", "log", "--id", &idx, "--data", "null"],
);
assert!(!out.status.success(), "null --data must be rejected");
assert_eq!(out.status.code(), Some(4), "expected exit 4 for null data");
}
#[test]
fn data_empty_object_bypasses_noop_guard() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "noop-test");
let idx = index_from_push(&pushed);
let out = mx(&dir, &["kv", "update", "log", "--id", &idx, "--data", "{}"]);
assert!(
!out.status.success(),
"empty --data {{}} with no value must be rejected"
);
assert_eq!(
out.status.code(),
Some(4),
"expected EXIT_INVALID_INPUT (4), got {:?}",
out.status.code()
);
}
#[test]
fn null_as_delete_removes_field_from_json_output() {
let dir = setup(BASIC_SCHEMA);
let pushed = push_with_data(&dir, "log", "entry", r#"{"a": 1, "b": 2}"#);
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&[
"kv",
"update",
"log",
"--id",
&idx,
"--data",
r#"{"b": null}"#,
],
);
assert!(
out.status.success(),
"null-delete update must succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
let get = mx(&dir, &["kv", "get", "log", "--json"]);
let json_str = String::from_utf8_lossy(&get.stdout).to_string();
assert!(
json_str.contains("\"a\""),
"field a must remain after null-delete of b: {}",
json_str
);
assert!(
!json_str.contains("\"b\""),
"field b must be gone after null-delete, got: {}",
json_str
);
}
#[test]
fn null_delete_required_field_fails_validation() {
let dir = setup(VALIDATED_SCHEMA);
let pushed = push_with_data(&dir, "tasks", "task-x", r#"{"status": "open"}"#);
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&[
"kv",
"update",
"tasks",
"--id",
&idx,
"--data",
r#"{"status": null}"#,
],
);
assert!(
!out.status.success(),
"deleting a required field via null must fail"
);
assert_eq!(
out.status.code(),
Some(4),
"expected EXIT_INVALID_INPUT (4) for validation failure, got {:?}",
out.status.code()
);
let get = mx(&dir, &["kv", "get", "tasks", "--json"]);
let json_str = String::from_utf8_lossy(&get.stdout).to_string();
assert!(
json_str.contains("\"status\""),
"status field must still exist after rejected update: {}",
json_str
);
}
#[test]
fn update_with_bare_kv_prefix_is_rejected() {
let dir = setup(BASIC_SCHEMA);
push(&dir, "log", "entry");
let out = mx(&dir, &["kv", "update", "log", "--id", "kv-", "newval"]);
assert!(
!out.status.success(),
"bare 'kv-' ID must be rejected, got exit {:?}",
out.status.code()
);
assert_eq!(
out.status.code(),
Some(4),
"expected EXIT_INVALID_INPUT (4) for empty kv- ID"
);
}
#[test]
fn update_output_format_matches_spec() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "check-format");
let idx = index_from_push(&pushed);
let kv_id = id_from_push(&pushed);
let out = mx(&dir, &["kv", "update", "log", "--id", &idx, "updated"]);
assert!(out.status.success(), "update must succeed");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
stdout.contains("Updated entry"),
"output must start with 'Updated entry': {}",
stdout
);
assert!(
stdout.contains(&kv_id),
"output must contain the kv-<id>: {}",
stdout
);
}
#[test]
fn update_with_no_value_and_no_data_is_rejected() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "entry");
let idx = index_from_push(&pushed);
let out = mx(&dir, &["kv", "update", "log", "--id", &idx]);
assert!(
!out.status.success(),
"no-op update must be rejected, got exit {:?}",
out.status.code()
);
assert_eq!(
out.status.code(),
Some(4),
"expected EXIT_INVALID_INPUT (4) for no-op update"
);
}
#[test]
fn update_list_entry_by_index_works() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "items", "list-item-1");
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&["kv", "update", "items", "--id", &idx, "list-item-1-updated"],
);
assert!(
out.status.success(),
"update on list must succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
let get = mx(&dir, &["kv", "get", "items"]);
assert!(
String::from_utf8_lossy(&get.stdout).contains("list-item-1-updated"),
"list entry value must be updated"
);
}
#[test]
fn patch_wrong_type_for_validated_field_fails_entry_unchanged() {
let dir = setup(VALIDATED_SCHEMA);
let pushed = push_with_data(
&dir,
"tasks",
"work",
r#"{"status": "open", "priority": 3}"#,
);
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&[
"kv",
"update",
"tasks",
"--id",
&idx,
"--data",
r#"{"priority": "high"}"#,
],
);
assert!(
!out.status.success(),
"wrong type for validated field must fail"
);
assert_eq!(out.status.code(), Some(4));
let get = mx(&dir, &["kv", "get", "tasks", "--json"]);
let json_str = String::from_utf8_lossy(&get.stdout).to_string();
assert!(
json_str.contains("\"priority\":3") || json_str.contains("\"priority\": 3"),
"priority must remain 3 after rejected update: {}",
json_str
);
}
#[test]
fn merge_resulting_in_empty_object_stored_as_none() {
let dir = setup(BASIC_SCHEMA);
let pushed = push_with_data(&dir, "log", "alone", r#"{"x": 1}"#);
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&[
"kv",
"update",
"log",
"--id",
&idx,
"--data",
r#"{"x": null}"#,
],
);
assert!(
out.status.success(),
"null-delete of sole field must succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
let get = mx(&dir, &["kv", "get", "log", "--json"]);
let json_str = String::from_utf8_lossy(&get.stdout).to_string();
assert!(
!json_str.contains("\"data\":{}") && !json_str.contains("\"data\": {}"),
"empty merged object must be stored as None, not {{}}: {}",
json_str
);
}
#[test]
fn invalid_json_for_data_returns_error() {
let dir = setup(BASIC_SCHEMA);
let pushed = push(&dir, "log", "entry");
let idx = index_from_push(&pushed);
let out = mx(
&dir,
&[
"kv",
"update",
"log",
"--id",
&idx,
"--data",
"{not valid json",
],
);
assert!(!out.status.success(), "invalid JSON must be rejected");
assert_eq!(out.status.code(), Some(4));
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("invalid JSON"),
"error message must mention invalid JSON: {}",
stderr
);
}
#[test]
fn ambiguous_prefix_returns_exit_code_4() {
let dir = setup(BASIC_SCHEMA);
let mut ids = Vec::new();
for i in 0..20 {
let pushed = push(&dir, "log", &format!("entry-{}", i));
ids.push(id_from_push(&pushed));
}
let hashes: Vec<&str> = ids.iter().map(|id| &id[3..]).collect(); let mut ambiguous_prefix = None;
'outer: for prefix_len in 1..6 {
let mut seen = std::collections::HashMap::new();
for hash in &hashes {
if hash.len() < prefix_len {
continue;
}
let pfx = &hash[..prefix_len];
let cnt = seen.entry(pfx).or_insert(0usize);
*cnt += 1;
if *cnt >= 2 {
ambiguous_prefix = Some(format!("kv-{}", pfx));
break 'outer;
}
}
}
if let Some(prefix) = ambiguous_prefix {
let out = mx(&dir, &["kv", "update", "log", "--id", &prefix, "newval"]);
assert!(
!out.status.success(),
"ambiguous prefix must fail, got exit {:?}",
out.status.code()
);
assert_eq!(
out.status.code(),
Some(4),
"AmbiguousId must return exit 4, got {:?}",
out.status.code()
);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.to_lowercase().contains("ambiguous") || stderr.contains("matches"),
"stderr must mention ambiguity: {}",
stderr
);
}
}