crtx 0.1.1

CLI for the Cortex supervisory memory substrate.
//! `cortex init` — bootstrap the local data directory.
//!
//! Creates the data directory at `$XDG_DATA_HOME/cortex/` (or the path
//! implied by `--db` / `--event-log`), an empty SQLite database file, and an
//! empty JSONL event log. The command is **idempotent**: a second invocation
//! over an already-initialised directory writes nothing and exits
//! [`Exit::Ok`].
//!
//! ## Flags
//!
//! - `--db <path>` — explicit DB path. Defaults to `$DATA_DIR/cortex.db`.
//! - `--event-log <path>` — explicit JSONL log path. Defaults to
//!   `$DATA_DIR/events.jsonl`.
//! - `--validate-perms` — assert that the data directory is mode `0700`.
//!   Returns [`Exit::PreconditionUnmet`] otherwise. Mode is created `0700`
//!   on first init; this flag is the audit-friendly check on subsequent
//!   runs.
//!
//! ## Why empty files
//!
//! At lane 1.C the SQLite schema is not yet wired (cortex-store is a stub),
//! and the JSONL log opens lazily via [`cortex_ledger::JsonlLog::open`].
//! `init` therefore reserves the file paths so subsequent commands hit
//! "open existing empty file" rather than "create on first append" — a
//! cheap consistency win for operators inspecting the data dir.

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use clap::Args;
use serde::Serialize;

use crate::exit::Exit;
use crate::output::{self, Envelope};
use crate::paths::{assert_secure_data_dir, DataLayout};
use cortex_core::SCHEMA_VERSION;

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

/// `cortex init` flags.
#[derive(Debug, Args)]
pub struct InitArgs {
    /// Explicit SQLite database path. Defaults under `$XDG_DATA_HOME/cortex/`.
    #[arg(long, value_name = "PATH")]
    pub db: Option<PathBuf>,

    /// Explicit JSONL event-log path. Defaults under `$XDG_DATA_HOME/cortex/`.
    #[arg(long = "event-log", value_name = "PATH")]
    pub event_log: Option<PathBuf>,

    /// Assert the data directory is mode `0700`. Returns
    /// [`Exit::PreconditionUnmet`] (`7`) otherwise.
    #[arg(long = "validate-perms")]
    pub validate_perms: bool,
}

/// Outcome of `init`, primarily for tests / structured callers.
#[derive(Debug, Clone)]
pub struct InitOutcome {
    /// The resolved data layout.
    pub layout: DataLayout,
    /// `true` if the DB file was created by this invocation.
    pub created_db: bool,
    /// `true` if the JSONL log file was created by this invocation.
    pub created_event_log: bool,
}

/// Run the init command. Returns [`Exit::Ok`] on success or a diagnostic
/// variant on failure.
pub fn run(args: InitArgs) -> Exit {
    match run_inner(args) {
        Ok(outcome) => {
            if output::json_enabled() {
                let envelope =
                    Envelope::new("cortex.init", Exit::Ok, InitReport::from_outcome(&outcome));
                output::emit(&envelope, Exit::Ok)
            } else {
                print_summary(&outcome);
                Exit::Ok
            }
        }
        Err(e) => {
            if output::json_enabled() {
                let envelope = Envelope::new("cortex.init", e, serde_json::Value::Null);
                output::emit(&envelope, e)
            } else {
                e
            }
        }
    }
}

/// Structured `cortex init` report payload.
#[derive(Debug, Serialize)]
struct InitReport {
    data_dir: String,
    db_path: String,
    event_log_path: String,
    schema_version: u16,
    created_db: bool,
    created_event_log: bool,
    /// Migrations applied during this invocation. `cortex init` reserves
    /// the file paths and does not apply migrations itself today, so the
    /// list is empty; the field is present so the envelope shape is stable
    /// once migrations move into init's responsibility.
    applied_migrations: Vec<String>,
}

impl InitReport {
    fn from_outcome(outcome: &InitOutcome) -> Self {
        Self {
            data_dir: outcome.layout.data_dir.display().to_string(),
            db_path: outcome.layout.db_path.display().to_string(),
            event_log_path: outcome.layout.event_log_path.display().to_string(),
            schema_version: SCHEMA_VERSION,
            created_db: outcome.created_db,
            created_event_log: outcome.created_event_log,
            applied_migrations: Vec::new(),
        }
    }
}

/// Programmatic entry point. Returns the outcome instead of printing — used
/// by integration tests in `tests/`.
pub fn run_inner(args: InitArgs) -> Result<InitOutcome, Exit> {
    let layout = DataLayout::resolve(args.db, args.event_log)?;

    // Create the data dir if missing. We force mode 0700 on creation so the
    // default install passes `--validate-perms` without operator action.
    if !layout.data_dir.exists() {
        fs::create_dir_all(&layout.data_dir).map_err(|_| Exit::PreconditionUnmet)?;
        #[cfg(unix)]
        {
            let perms = fs::Permissions::from_mode(0o700);
            fs::set_permissions(&layout.data_dir, perms).map_err(|_| Exit::PreconditionUnmet)?;
        }
    }

    if args.validate_perms {
        assert_secure_data_dir(&layout.data_dir)?;
    }

    preflight_no_sqlite_sidecars(&layout.db_path)?;

    let created_db = create_if_missing(&layout.db_path)?;
    let created_event_log = create_if_missing(&layout.event_log_path)?;

    Ok(InitOutcome {
        layout,
        created_db,
        created_event_log,
    })
}

/// Touch `path` only if it does not yet exist. Returns `true` if the file
/// was created, `false` if it already existed (idempotent path).
fn create_if_missing(path: &Path) -> Result<bool, Exit> {
    if path.exists() {
        return Ok(false);
    }
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() && !parent.exists() {
            fs::create_dir_all(parent).map_err(|_| Exit::PreconditionUnmet)?;
        }
    }
    let mut f = fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(path)
        .map_err(|_| Exit::PreconditionUnmet)?;
    // Explicit zero-byte write so the file shows up with size 0 even on
    // file systems that defer block allocation.
    f.write_all(b"").map_err(|_| Exit::PreconditionUnmet)?;
    f.sync_all().map_err(|_| Exit::PreconditionUnmet)?;
    Ok(true)
}

/// `cortex init` must not silently accept a stray WAL/SHM sidecar before a
/// store-open path has verified its expected length and hash.
fn preflight_no_sqlite_sidecars(db_path: &Path) -> Result<(), Exit> {
    for sidecar in sqlite_sidecar_paths(db_path) {
        if sidecar.exists() {
            eprintln!(
                "cortex init: precondition unmet: sqlite sidecar {} exists before store verification; no state was changed.",
                sidecar.display()
            );
            return Err(Exit::PreconditionUnmet);
        }
    }
    Ok(())
}

fn sqlite_sidecar_paths(db_path: &Path) -> [PathBuf; 2] {
    let raw = db_path.as_os_str().to_string_lossy();
    [
        PathBuf::from(format!("{raw}-wal")),
        PathBuf::from(format!("{raw}-shm")),
    ]
}

fn print_summary(out: &InitOutcome) {
    println!("cortex init: data_dir = {}", out.layout.data_dir.display());
    println!(
        "cortex init: db        = {} ({})",
        out.layout.db_path.display(),
        if out.created_db { "created" } else { "exists" },
    );
    println!(
        "cortex init: event_log = {} ({})",
        out.layout.event_log_path.display(),
        if out.created_event_log {
            "created"
        } else {
            "exists"
        },
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    fn args_with(tmp: &Path) -> InitArgs {
        InitArgs {
            db: Some(tmp.join("cortex.db")),
            event_log: Some(tmp.join("events.jsonl")),
            validate_perms: false,
        }
    }

    #[test]
    fn init_creates_db_and_log() {
        let tmp = tempdir().unwrap();
        let out = run_inner(args_with(tmp.path())).expect("init ok");
        assert!(out.created_db);
        assert!(out.created_event_log);
        assert!(tmp.path().join("cortex.db").exists());
        assert!(tmp.path().join("events.jsonl").exists());
    }

    #[test]
    fn init_is_idempotent() {
        let tmp = tempdir().unwrap();
        let _first = run_inner(args_with(tmp.path())).unwrap();
        let second = run_inner(args_with(tmp.path())).expect("idempotent ok");
        assert!(!second.created_db);
        assert!(!second.created_event_log);
    }

    #[test]
    fn init_rejects_existing_wal_sidecar() {
        let tmp = tempdir().unwrap();
        std::fs::write(tmp.path().join("cortex.db-wal"), b"unverified wal").unwrap();

        let err = run_inner(args_with(tmp.path())).unwrap_err();

        assert_eq!(err, Exit::PreconditionUnmet);
        assert!(
            !tmp.path().join("cortex.db").exists(),
            "init must not create db after sidecar preflight failure"
        );
        assert!(
            !tmp.path().join("events.jsonl").exists(),
            "init must not create event log after sidecar preflight failure"
        );
    }

    #[cfg(unix)]
    #[test]
    fn validate_perms_rejects_0755_dir() {
        use std::fs;
        let tmp = tempdir().unwrap();
        let dir = tmp.path().join("data");
        fs::create_dir(&dir).unwrap();
        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
        let args = InitArgs {
            db: Some(dir.join("cortex.db")),
            event_log: Some(dir.join("events.jsonl")),
            validate_perms: true,
        };
        let err = run_inner(args).unwrap_err();
        assert_eq!(err, Exit::PreconditionUnmet);
    }
}