crtx 0.1.1

CLI for the Cortex supervisory memory substrate.
//! Integration tests: `cortex context build --include-doctrine`.

use std::path::{Path, PathBuf};
use std::process::Command;

use chrono::{TimeZone, Utc};
use cortex_core::{
    Attestor, Event, EventSource, EventType, InMemoryAttestor, KeyLifecycleState, PrincipleId,
    TrustTier, SCHEMA_VERSION,
};
use cortex_store::migrate::apply_pending;
use cortex_store::repo::memories::MemoryAcceptanceAudit;
use cortex_store::repo::{
    AuthorityRepo, EventRepo, KeyTimelineRecord, MemoryCandidate, MemoryRepo,
    PrincipalTimelineRecord, PrincipleCandidateRow, PrincipleRepo,
};
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")
        .1
        .trim()
        .split_once(" (")
        .expect("db line has status suffix")
        .0;
    PathBuf::from(path)
}

fn at(second: u32) -> chrono::DateTime<Utc> {
    Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, second).unwrap()
}

fn attestation_key_fixture() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("attested-user-event-key.bin")
}

fn signed_run_attestor(path: &Path) -> InMemoryAttestor {
    let raw = std::fs::read(path).expect("read attestation key");
    let seed: [u8; 32] = raw.as_slice().try_into().expect("key must be 32 bytes");
    InMemoryAttestor::from_seed(&seed)
}

/// Insert a single source event used by all fixture memories.
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: "doctrine-cli-test".into(),
        },
        event_type: EventType::ToolResult,
        trace_id: None,
        session_id: Some("doctrine-cli-test".into()),
        domain_tags: vec!["doctrine".into(), "test".into()],
        payload: json!({"source": "doctrine-cli-test", "second": second}),
        payload_hash: format!("payload-source-{second}"),
        prev_event_hash: None,
        event_hash: format!("event-source-{second}"),
    })
    .expect("append source event");
}

/// Insert a memory candidate and accept it to make it active.
fn insert_active_memory(
    db_path: &Path,
    memory_id: &str,
    claim: &str,
    domains: &[&str],
    second: u32,
) {
    let pool = Connection::open(db_path).expect("open sqlite db");
    apply_pending(&pool).expect("apply 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.8, "validation": 0.6}),
        confidence: 0.9,
        authority: "user".into(),
        applies_when_json: json!(["querying stored memories"]),
        does_not_apply_when_json: json!([]),
        created_at: at(second),
        updated_at: at(second),
    };
    repo.insert_candidate(&candidate).expect("insert candidate");
    let audit = MemoryAcceptanceAudit {
        id: format!("aud_01ARZ3NDEKTSV4RRFFQ69G5F{second:02}")
            .parse()
            .unwrap(),
        actor_json: json!({"kind": "test"}),
        reason: "doctrine test active memory".into(),
        source_refs_json: json!([memory_id]),
        created_at: at(second + 1),
    };
    repo.accept_candidate(
        &memory_id.parse().unwrap(),
        at(second + 2),
        &audit,
        &cortex_store::repo::memories::accept_candidate_policy_decision_test_allow(),
    )
    .expect("accept candidate");
}

/// Insert three active memories needed by a principle candidate.
fn insert_fixture_memories(db_path: &Path) -> Vec<String> {
    let specs: &[(&str, &str, &[&str], u32)] = &[
        (
            "mem_01ARZ3NDEKTSV4RRFFQ69G5FAW",
            "Evidence must be inspected before claiming state.",
            &["agents"],
            4,
        ),
        (
            "mem_01ARZ3NDEKTSV4RRFFQ69G5FAX",
            "Audit reports need direct observed anchors.",
            &["audit"],
            6,
        ),
        (
            "mem_01ARZ3NDEKTSV4RRFFQ69G5FAY",
            "Candidate output must stay separate from promotion.",
            &["agents"],
            8,
        ),
    ];
    specs
        .iter()
        .map(|&(id, claim, domains, second)| {
            insert_active_memory(db_path, id, claim, domains, second);
            id.to_string()
        })
        .collect()
}

fn insert_principle_candidate(db_path: &Path, memory_ids: &[String]) -> PrincipleId {
    let pool = Connection::open(db_path).expect("open sqlite db");
    apply_pending(&pool).expect("apply migrations");
    let repo = PrincipleRepo::new(&pool);
    let principle_id: PrincipleId = "prn_01ARZ3NDEKTSV4RRFFQ69G5FAV"
        .parse()
        .expect("valid principle id");
    repo.insert_candidate(
        &PrincipleCandidateRow {
            id: principle_id,
            statement: "Preserve evidence-bound guidance before doctrine promotion.".into(),
            status: "candidate".into(),
            supporting_memories_json: json!(memory_ids),
            contradicting_memories_json: json!([]),
            domains_observed_json: json!(["agents", "audit"]),
            applies_when_json: json!(["the pattern recurs across domains"]),
            does_not_apply_when_json: json!(["support is below threshold"]),
            confidence: 0.72,
            validation: 0.6,
            brightness: 0.7,
            created_by_json: json!({
                "kind": "test",
                "falsification": {
                    "status": "attempted",
                    "failed_attempts": [
                        "counterexample search found no high-risk blocker in the fixture window"
                    ],
                    "unresolved_high_risk_counterexamples": []
                }
            }),
            created_at: at(10),
            updated_at: at(10),
        },
        &cortex_store::repo::principles::insert_candidate_policy_decision_test_allow(),
    )
    .expect("insert principle candidate");
    principle_id
}

fn seed_operator_authority(db_path: &Path, attestation_key: &Path) {
    let attestor = signed_run_attestor(attestation_key);
    let pool = Connection::open(db_path).expect("open sqlite db");
    apply_pending(&pool).expect("apply migrations");
    let repo = AuthorityRepo::new(&pool);
    repo.append_principal_state(
        &PrincipalTimelineRecord {
            principal_id: "operator-principal".into(),
            trust_tier: TrustTier::Operator,
            effective_at: at(1),
            trust_review_due_at: None,
            removed_at: None,
            audit_ref: None,
        },
        &cortex_store::repo::authority::principal_state_policy_decision_test_allow(),
    )
    .expect("append operator trust state");
    repo.append_key_state(
        &KeyTimelineRecord {
            key_id: attestor.key_id().to_string(),
            principal_id: "operator-principal".into(),
            state: KeyLifecycleState::Active,
            effective_at: at(1),
            reason: None,
            audit_ref: None,
        },
        &cortex_store::repo::authority::key_state_policy_decision_test_allow(),
    )
    .expect("append active operator key state");
}

/// Promote a principle via the CLI and return its id string.
fn promote_principle(tmp: &Path, db_path: &Path) -> String {
    let memory_ids = insert_fixture_memories(db_path);
    let principle_id = insert_principle_candidate(db_path, &memory_ids);
    let attestation_key = attestation_key_fixture();
    seed_operator_authority(db_path, &attestation_key);
    let out = run_in(
        tmp,
        &[
            "principle",
            "promote",
            &principle_id.to_string(),
            "--force",
            "advisory",
            "--reason",
            "operator reviewed support",
            "--attestation",
            attestation_key.to_str().expect("attestation key path utf8"),
        ],
    );
    assert_exit(&out, 0);
    principle_id.to_string()
}

// ── Tests ──────────────────────────────────────────────────────────────────

#[test]
fn context_build_with_doctrine_includes_promoted_principles() {
    let tmp = tempfile::tempdir().unwrap();
    let db_path = init(tmp.path());

    // Promote a principle so doctrine is non-empty.
    promote_principle(tmp.path(), &db_path);

    let out = run_in(
        tmp.path(),
        &[
            "context",
            "build",
            "--task",
            "answer with doctrine",
            "--include-doctrine",
        ],
    );
    assert_exit(&out, 0);

    let pack: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("context stdout is JSON");

    let doctrine = pack["doctrine"]
        .as_array()
        .expect("output includes a doctrine array");
    assert_eq!(doctrine.len(), 1, "one promoted principle should appear");

    let entry = &doctrine[0];
    assert_eq!(
        entry["claim"], "Preserve evidence-bound guidance before doctrine promotion.",
        "doctrine entry should carry the principle claim"
    );
    assert!(
        entry["doctrine_id"].as_str().is_some(),
        "doctrine entry should carry a doctrine_id"
    );
    assert!(
        entry["source_principle"].as_str().is_some(),
        "doctrine entry should carry source_principle"
    );
    assert_eq!(entry["force"], "Advisory");
}

#[test]
fn context_build_without_doctrine_flag_excludes_doctrine() {
    let tmp = tempfile::tempdir().unwrap();
    let db_path = init(tmp.path());

    // Promote a principle — doctrine exists in the store.
    promote_principle(tmp.path(), &db_path);

    // Build without --include-doctrine.
    let out = run_in(
        tmp.path(),
        &["context", "build", "--task", "answer without doctrine"],
    );
    assert_exit(&out, 0);

    let pack: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("context stdout is JSON");

    assert!(
        pack.get("doctrine").is_none(),
        "doctrine key must be absent when --include-doctrine is not set"
    );
}