use std::path::{Path, PathBuf};
use std::process::Command;
use chrono::{TimeZone, Utc};
use cortex_core::{AuditRecordId, Event, EventSource, EventType, SCHEMA_VERSION};
use cortex_store::migrate::apply_pending;
use cortex_store::repo::memories::accept_candidate_policy_decision_test_allow;
use cortex_store::repo::{EventRepo, MemoryAcceptanceAudit, MemoryCandidate, MemoryRepo};
use rusqlite::Connection;
use serde_json::json;
fn cortex_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_cortex"))
}
fn run_in(cwd: &Path, args: &[&str]) -> std::process::Output {
Command::new(cortex_bin())
.current_dir(cwd)
.env("XDG_DATA_HOME", cwd.join("xdg"))
.env("HOME", cwd)
.args(args)
.output()
.expect("spawn cortex")
}
fn assert_exit(out: &std::process::Output, expected: i32) {
let code = out.status.code().expect("process exited via signal");
assert_eq!(
code,
expected,
"expected exit {expected}, got {code}\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
fn init(tmp: &Path) -> PathBuf {
let out = run_in(tmp, &["init"]);
assert_exit(&out, 0);
let stdout = String::from_utf8_lossy(&out.stdout);
let db_line = stdout
.lines()
.find(|line| line.starts_with("cortex init: db"))
.expect("init stdout includes db path");
let path = db_line
.split_once('=')
.expect("db line has equals sign")
.1
.trim()
.split_once(" (")
.expect("db line has status suffix in parens")
.0;
PathBuf::from(path)
}
fn at(second: u32) -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, second).unwrap()
}
fn ensure_source_event(pool: &Connection, second: u32) {
let event_id = "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap();
let repo = EventRepo::new(pool);
if repo
.get_by_id(&event_id)
.expect("query source event")
.is_some()
{
return;
}
repo.append(&Event {
id: event_id,
schema_version: SCHEMA_VERSION,
observed_at: at(second),
recorded_at: at(second),
source: EventSource::Tool {
name: "tag-mutation-test".into(),
},
event_type: EventType::ToolResult,
trace_id: None,
session_id: Some("tag-mutation-test".into()),
domain_tags: vec!["test".into()],
payload: json!({"source": "tag-mutation-test", "second": second}),
payload_hash: format!("payload-tag-mut-{second}"),
prev_event_hash: None,
event_hash: format!("event-tag-mut-{second}"),
})
.expect("append source event");
}
fn insert_active_memory(
db_path: &Path,
memory_id: &str,
claim: &str,
domains: &[&str],
second: u32,
) -> String {
let pool = Connection::open(db_path).expect("open sqlite db");
apply_pending(&pool).expect("apply pending migrations");
ensure_source_event(&pool, second);
let repo = MemoryRepo::new(&pool);
let candidate = MemoryCandidate {
id: memory_id.parse().unwrap(),
memory_type: "semantic".into(),
claim: claim.into(),
source_episodes_json: json!([]),
source_events_json: json!(["evt_01ARZ3NDEKTSV4RRFFQ69G5FAV"]),
domains_json: json!(domains),
salience_json: json!({"score": 0.7}),
confidence: 0.85,
authority: "user".into(),
applies_when_json: json!([]),
does_not_apply_when_json: json!([]),
created_at: at(second),
updated_at: at(second),
};
let id = candidate.id.to_string();
repo.insert_candidate(&candidate).expect("insert candidate");
let audit = MemoryAcceptanceAudit {
id: AuditRecordId::new(),
actor_json: json!({"kind": "test"}),
reason: "tag-mutation integration test".into(),
source_refs_json: json!([id]),
created_at: at(second + 1),
};
repo.accept_candidate(
&memory_id.parse().unwrap(),
at(second + 2),
&audit,
&accept_candidate_policy_decision_test_allow(),
)
.expect("accept candidate");
id
}
#[test]
fn tag_add_creates_tag_on_existing_memory() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let db_path = init(tmp.path());
let memory_id = insert_active_memory(
&db_path,
"mem_01ARZ3NDEKTSV4RRFFQ69G5FC1",
"tag mutation round-trip claim one",
&["seed"],
1,
);
let add_out = run_in(tmp.path(), &["memory", "tag", "add", &memory_id, "roundtrip"]);
assert_exit(&add_out, 0);
let add_stdout = String::from_utf8_lossy(&add_out.stdout);
assert!(
add_stdout.contains("added"),
"stdout must confirm the tag was added: {add_stdout}"
);
let list_out = run_in(tmp.path(), &["memory", "list", "--tag", "roundtrip"]);
assert_exit(&list_out, 0);
let list_stdout = String::from_utf8_lossy(&list_out.stdout);
assert!(
list_stdout.contains(&memory_id),
"memory must appear under --tag roundtrip after tag add: {list_stdout}"
);
}
#[test]
fn tag_add_is_idempotent() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let db_path = init(tmp.path());
let memory_id = insert_active_memory(
&db_path,
"mem_01ARZ3NDEKTSV4RRFFQ69G5FC2",
"tag mutation idempotent claim",
&["seed"],
1,
);
let first = run_in(tmp.path(), &["memory", "tag", "add", &memory_id, "ops"]);
assert_exit(&first, 0);
let second = run_in(tmp.path(), &["memory", "tag", "add", &memory_id, "ops"]);
assert_exit(&second, 0);
let second_stdout = String::from_utf8_lossy(&second.stdout);
assert!(
second_stdout.contains("already present"),
"second add must report tag already present: {second_stdout}"
);
let pool = Connection::open(&db_path).expect("open db");
let domains_raw: String = pool
.query_row(
"SELECT domains_json FROM memories WHERE id = ?1;",
[&memory_id],
|row| row.get(0),
)
.expect("read domains_json");
let tags: Vec<String> =
serde_json::from_str(&domains_raw).expect("domains_json is a JSON array");
let ops_count = tags.iter().filter(|t| t.as_str() == "ops").count();
assert_eq!(
ops_count, 1,
"ops tag must appear exactly once after two adds: {domains_raw}"
);
}
#[test]
fn tag_remove_removes_existing_tag() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let db_path = init(tmp.path());
let memory_id = insert_active_memory(
&db_path,
"mem_01ARZ3NDEKTSV4RRFFQ69G5FC3",
"tag remove round-trip claim",
&["seed"],
1,
);
let add_out = run_in(tmp.path(), &["memory", "tag", "add", &memory_id, "transient"]);
assert_exit(&add_out, 0);
let remove_out = run_in(
tmp.path(),
&["memory", "tag", "remove", &memory_id, "transient"],
);
assert_exit(&remove_out, 0);
let remove_stdout = String::from_utf8_lossy(&remove_out.stdout);
assert!(
remove_stdout.contains("removed"),
"stdout must confirm the tag was removed: {remove_stdout}"
);
let pool = Connection::open(&db_path).expect("open db");
let domains_raw: String = pool
.query_row(
"SELECT domains_json FROM memories WHERE id = ?1;",
[&memory_id],
|row| row.get(0),
)
.expect("read domains_json");
let tags: Vec<String> =
serde_json::from_str(&domains_raw).expect("domains_json is a JSON array");
assert!(
!tags.iter().any(|t| t == "transient"),
"transient tag must be absent from domains_json after remove: {domains_raw}"
);
assert!(
tags.iter().any(|t| t == "seed"),
"seed tag must still be present after transient remove: {domains_raw}"
);
let list_out = run_in(tmp.path(), &["memory", "list", "--tag", "transient"]);
assert_exit(&list_out, 0);
let list_stdout = String::from_utf8_lossy(&list_out.stdout);
assert!(
!list_stdout.contains(&memory_id),
"memory must be absent under --tag transient after remove: {list_stdout}"
);
}
#[test]
fn tag_remove_nonexistent_tag_returns_not_found() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let db_path = init(tmp.path());
let memory_id = insert_active_memory(
&db_path,
"mem_01ARZ3NDEKTSV4RRFFQ69G5FC4",
"tag remove absent tag claim",
&["seed"],
1,
);
let out = run_in(
tmp.path(),
&["memory", "tag", "remove", &memory_id, "missing-tag"],
);
assert_exit(&out, 7);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("not found") || stderr.contains("not present"),
"stderr must indicate the tag was not found: {stderr}"
);
}
#[test]
fn tag_add_invalid_memory_id_exits_usage() {
let tmp = tempfile::TempDir::new().expect("tempdir");
init(tmp.path());
let out = run_in(tmp.path(), &["memory", "tag", "add", "garbage123", "ops"]);
assert_exit(&out, 2);
}
#[test]
fn tagged_memory_excluded_from_results_after_tag_remove() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let db_path = init(tmp.path());
let memory_id = insert_active_memory(
&db_path,
"mem_01ARZ3NDEKTSV4RRFFQ69G5FC5",
"tagged memory search exclusion claim",
&["seed"],
1,
);
let add_out = run_in(tmp.path(), &["memory", "tag", "add", &memory_id, "visible"]);
assert_exit(&add_out, 0);
let before_list = run_in(tmp.path(), &["memory", "list", "--tag", "visible"]);
assert_exit(&before_list, 0);
let before_stdout = String::from_utf8_lossy(&before_list.stdout);
assert!(
before_stdout.contains(&memory_id),
"memory must appear under --tag visible before remove: {before_stdout}"
);
let remove_out = run_in(
tmp.path(),
&["memory", "tag", "remove", &memory_id, "visible"],
);
assert_exit(&remove_out, 0);
let after_list = run_in(tmp.path(), &["memory", "list", "--tag", "visible"]);
assert_exit(&after_list, 0);
let after_stdout = String::from_utf8_lossy(&after_list.stdout);
assert!(
!after_stdout.contains(&memory_id),
"memory must not appear under --tag visible after remove: {after_stdout}"
);
}