crtx 0.1.0

CLI for the Cortex supervisory memory substrate.
//! CLI config-reporting tests.

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

fn cortex_bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_cortex"))
}

fn run_in(cwd: &Path, args: &[&str]) -> std::process::Output {
    run_in_with_env(cwd, args, &[])
}

fn run_in_with_env(
    cwd: &Path,
    args: &[&str],
    extra_env: &[(&str, String)],
) -> std::process::Output {
    let mut cmd = Command::new(cortex_bin());
    cmd.current_dir(cwd)
        .env_remove("CORTEX_CONFIG")
        .env_remove("CORTEX_DATA_DIR")
        .env_remove("CORTEX_DB_PATH")
        .env_remove("CORTEX_EVENT_LOG_PATH")
        .env_remove("RUST_LOG")
        .env("XDG_CONFIG_HOME", cwd.join("xdg-config"))
        .env("XDG_DATA_HOME", cwd.join("xdg"))
        .env("HOME", cwd)
        .env("APPDATA", cwd.join("AppData").join("Roaming"))
        .env("CORTEX_PROFILE", "local")
        .env("CORTEX_API_TOKEN", "super-secret-token")
        .args(args);

    for (key, value) in extra_env {
        cmd.env(key, value);
    }

    cmd.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 expected_data_dir(cwd: &Path) -> PathBuf {
    cwd.join("xdg").join("cortex")
}

fn expected_config_path(cwd: &Path) -> PathBuf {
    cwd.join("xdg-config").join("cortex").join("config.toml")
}

fn toml_string(path: &Path) -> String {
    format!("{:?}", path.display().to_string())
}

fn write_config(path: &Path, body: &str) {
    fs::create_dir_all(path.parent().expect("config has parent")).unwrap();
    fs::write(path, body).unwrap();
}

fn field_value(line: &str, key: &str) -> Option<String> {
    let cleaned = strip_ansi_codes(line);
    let needle = format!("{key}=");
    let value = cleaned.split(&needle).nth(1)?;
    if let Some(quoted) = value.strip_prefix('"') {
        return quoted.split('"').next().map(ToOwned::to_owned);
    }
    value.split_whitespace().next().map(ToOwned::to_owned)
}

fn strip_ansi_codes(line: &str) -> String {
    let mut cleaned = String::with_capacity(line.len());
    let mut chars = line.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\u{1b}' && chars.peek() == Some(&'[') {
            chars.next();
            for code_ch in chars.by_ref() {
                if code_ch.is_ascii_alphabetic() {
                    break;
                }
            }
            continue;
        }
        cleaned.push(ch);
    }
    cleaned
}

#[test]
fn print_config_reports_effective_paths_and_redacts_secret_env() {
    let tmp = tempfile::tempdir().unwrap();
    let data_dir = expected_data_dir(tmp.path());
    let out = run_in(tmp.path(), &["--print-config"]);

    assert_exit(&out, 0);
    assert!(
        out.stderr.is_empty(),
        "stderr should be empty: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        !stdout.contains("super-secret-token"),
        "stdout must not leak secret env values: {stdout}"
    );

    let json: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("stdout is valid config JSON");
    assert_eq!(json["data_dir"], data_dir.display().to_string());
    assert_eq!(
        json["db_path"],
        data_dir.join("cortex.db").display().to_string()
    );
    assert_eq!(
        json["event_log_path"],
        data_dir.join("events.jsonl").display().to_string()
    );
    assert_eq!(json["env"]["CORTEX_PROFILE"], "local");
    assert_eq!(json["env"]["CORTEX_API_TOKEN"], "<redacted>");
}

#[test]
fn command_observability_logs_command_lifecycle_without_secrets() {
    let tmp = tempfile::tempdir().unwrap();
    let out = run_in_with_env(
        tmp.path(),
        &["--print-config"],
        &[("RUST_LOG", "cortex=info".to_string())],
    );

    assert_exit(&out, 0);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("super-secret-token"),
        "observability stderr must not leak secret env values: {stderr}"
    );

    let lifecycle_lines: Vec<&str> = stderr
        .lines()
        .filter(|line| line.contains("command_started") || line.contains("command_completed"))
        .collect();
    assert_eq!(
        lifecycle_lines.len(),
        2,
        "expected start and completion observability lines, got: {stderr}"
    );

    let start = lifecycle_lines
        .iter()
        .find(|line| line.contains("command_started"))
        .expect("start line");
    let completed = lifecycle_lines
        .iter()
        .find(|line| line.contains("command_completed"))
        .expect("completed line");

    for line in [*start, *completed] {
        assert_eq!(
            field_value(line, "operation").as_deref(),
            Some("cli.command")
        );
        assert_eq!(
            field_value(line, "command").as_deref(),
            Some("print_config")
        );
        assert_eq!(
            field_value(line, "audit_schema_version").as_deref(),
            Some("1")
        );
        assert_eq!(field_value(line, "proof_state").as_deref(), Some("UNKNOWN"));
        assert_eq!(
            field_value(line, "authority_class").as_deref(),
            Some("diagnostic_only")
        );
        assert_eq!(
            field_value(line, "diagnostic_only").as_deref(),
            Some("true")
        );
        assert!(
            field_value(line, "correlation_id")
                .as_deref()
                .is_some_and(|value| value.starts_with("cmd_")),
            "correlation_id should use cmd_ prefix: {line}"
        );
    }

    assert_eq!(
        field_value(start, "correlation_id"),
        field_value(completed, "correlation_id"),
        "start/completed lines should share the same correlation id"
    );
    assert_eq!(field_value(completed, "exit_code").as_deref(), Some("0"));
    assert_eq!(field_value(completed, "status").as_deref(), Some("ok"));
}

#[test]
fn command_observability_does_not_create_persisted_audit_artifacts() {
    let tmp = tempfile::tempdir().unwrap();
    let data_dir = expected_data_dir(tmp.path());
    let out = run_in_with_env(
        tmp.path(),
        &["--print-config"],
        &[("RUST_LOG", "cortex=info".to_string())],
    );

    assert_exit(&out, 0);
    assert!(
        !data_dir.join("audit_records.jsonl").exists(),
        "diagnostic command tracing must not create audit_records.jsonl"
    );
    assert!(
        !data_dir.join("cortex.sqlite3").exists(),
        "diagnostic command tracing must not open or create the SQLite store"
    );
    assert!(
        !data_dir.join("cortex.db").exists(),
        "diagnostic command tracing must not create legacy DB artifacts"
    );
}

#[test]
fn print_config_loads_default_config_file_layer() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = expected_config_path(tmp.path());
    let data_dir = tmp.path().join("configured-data");
    let db_path = tmp.path().join("configured.db");
    let event_log_path = tmp.path().join("configured-events.jsonl");
    write_config(
        &config_path,
        &format!(
            "data_dir = {}\ndb_path = {}\nevent_log_path = {}\n",
            toml_string(&data_dir),
            toml_string(&db_path),
            toml_string(&event_log_path),
        ),
    );

    let out = run_in(tmp.path(), &["--print-config"]);

    assert_exit(&out, 0);
    let json: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("stdout is valid config JSON");
    assert_eq!(json["data_dir"], data_dir.display().to_string());
    assert_eq!(json["db_path"], db_path.display().to_string());
    assert_eq!(json["event_log_path"], event_log_path.display().to_string());
}

#[test]
fn print_config_uses_cortex_config_and_env_overrides_file() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("custom-config.toml");
    let file_data_dir = tmp.path().join("file-data");
    let file_db_path = tmp.path().join("file.db");
    let file_event_log_path = tmp.path().join("file-events.jsonl");
    let env_data_dir = tmp.path().join("env-data");
    let env_db_path = tmp.path().join("env.db");
    write_config(
        &config_path,
        &format!(
            "data_dir = {}\ndb_path = {}\nevent_log_path = {}\n",
            toml_string(&file_data_dir),
            toml_string(&file_db_path),
            toml_string(&file_event_log_path),
        ),
    );

    let out = run_in_with_env(
        tmp.path(),
        &["--print-config"],
        &[
            ("CORTEX_CONFIG", config_path.display().to_string()),
            ("CORTEX_DATA_DIR", env_data_dir.display().to_string()),
            ("CORTEX_DB_PATH", env_db_path.display().to_string()),
        ],
    );

    assert_exit(&out, 0);
    let json: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("stdout is valid config JSON");
    assert_eq!(json["data_dir"], env_data_dir.display().to_string());
    assert_eq!(json["db_path"], env_db_path.display().to_string());
    assert_eq!(
        json["event_log_path"],
        file_event_log_path.display().to_string()
    );
    assert_eq!(
        json["env"]["CORTEX_CONFIG"],
        config_path.display().to_string()
    );
}

#[test]
fn print_config_rejects_subcommand_combination() {
    let tmp = tempfile::tempdir().unwrap();
    let out = run_in(tmp.path(), &["--print-config", "init"]);

    assert_exit(&out, 2);
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("cannot be combined"),
        "stderr should explain invalid combination: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}