crtx 0.1.1

CLI for the Cortex supervisory memory substrate.
//! Integration tests for the tag mutation round-trip.
//!
//! Covers:
//!
//! - `tag_add_creates_tag_on_existing_memory` — init store, accept a memory,
//!   add a tag, verify `cortex memory list --tag <name>` returns it
//! - `tag_add_is_idempotent` — add same tag twice, verify it appears only once
//! - `tag_remove_removes_existing_tag` — add then remove a tag, verify it's gone
//! - `tag_remove_nonexistent_tag_returns_not_found` — remove a tag that was
//!   never added; the CLI exits `PreconditionUnmet` (7) and writes "not found"
//!   to stderr
//! - `tag_add_invalid_memory_id_exits_usage` — pass a garbage memory id; clap
//!   fails to parse it and exits 2 (`Usage`)
//! - `tagged_memory_excluded_from_results_after_tag_remove` — tag a memory,
//!   search by tag (appears), remove the tag, search again (absent)

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;

// ── Shared helpers ────────────────────────────────────────────────────────────

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()
}

/// Inserts a single source event if it is not already present.
///
/// All test memories share the same source event id. The guard avoids a
/// duplicate-key error when two helper calls target the same database.
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");
}

/// Inserts a candidate memory and promotes it to active status.
///
/// Returns the string-form memory id (`mem_…`).
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 1 ────────────────────────────────────────────────────────────────────

/// Adding a tag to an existing active memory makes it visible under
/// `cortex memory list --tag <name>`.
#[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 2 ────────────────────────────────────────────────────────────────────

/// Adding the same tag twice is idempotent: the second call exits 0 and the
/// tag appears exactly once in `domains_json`.
#[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 3 ────────────────────────────────────────────────────────────────────

/// After adding and then removing a tag the memory no longer appears under
/// `cortex memory list --tag <name>` and the tag is absent from `domains_json`.
#[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}"
    );

    // The memory must no longer surface under --tag transient.
    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 4 ────────────────────────────────────────────────────────────────────

/// Removing a tag that was never added to an existing memory exits
/// `PreconditionUnmet` (7) — the CLI treats a missing-tag as a not-found
/// condition rather than a silent no-op.  The stderr line includes "not found".
///
/// Note: there is no `Exit::NotFound` variant; `PreconditionUnmet` (7) is the
/// documented exit code for all operator-visible not-found conditions (see
/// `crates/cortex-cli/src/exit.rs`).
#[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,
    );

    // "missing-tag" was never added to this memory.
    let out = run_in(
        tmp.path(),
        &["memory", "tag", "remove", &memory_id, "missing-tag"],
    );
    // PreconditionUnmet = 7
    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 5 ────────────────────────────────────────────────────────────────────

/// Passing a garbage string that cannot parse as a `MemoryId` causes clap to
/// fail at argument-parsing time and exit 2 (`Usage`).
#[test]
fn tag_add_invalid_memory_id_exits_usage() {
    let tmp = tempfile::TempDir::new().expect("tempdir");
    init(tmp.path());

    // "garbage123" has no `mem_` prefix and is not a valid ULID, so `MemoryId`
    // `FromStr` rejects it inside clap's value parser.
    let out = run_in(tmp.path(), &["memory", "tag", "add", "garbage123", "ops"]);
    // Usage = 2
    assert_exit(&out, 2);
}

// ── Test 6 ────────────────────────────────────────────────────────────────────

/// Full search round-trip: after adding a tag the memory appears in
/// `cortex memory list --tag <name>`; after removing the tag it no longer
/// appears.
#[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,
    );

    // Phase 1: add the tag and confirm it surfaces via --tag filter.
    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}"
    );

    // Phase 2: remove the tag and verify the memory is absent from the same
    // filtered list.
    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}"
    );
}