crtx 0.1.1

CLI for the Cortex supervisory memory substrate.
//! Filesystem path resolution for the CLI.
//!
//! Cortex stores per-user state under the XDG base directory spec (BUILD_SPEC ยง11).
//! We default to `$XDG_DATA_HOME/cortex/` (NOT a dotfile under `$HOME/.cortex/`)
//! so that the data directory lives on the same axis as other modern Unix
//! tools (`$XDG_DATA_HOME/$tool/`). On platforms where `dirs::data_dir`
//! resolves a different OS-specific location (macOS: `~/Library/Application
//! Support/cortex/`; Windows: `%APPDATA%\cortex\`) we follow that โ€” XDG and
//! the platform conventions are the same idea, and `dirs` is the canonical
//! cross-platform mapping.
//!
//! ## Rationale for not using a dotfile
//!
//! A dotfile under `$HOME/.cortex/` couples Cortex to a user's home and
//! conflates state, config, and cache. The XDG split (`data` for ledger
//! rows, `config` for user prefs, `cache` for ephemeral state) keeps
//! operator workflows clean: backups target one tree, secrets another. We
//! pay one extra `dirs` dependency for the right ergonomics.

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

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

use crate::exit::Exit;

/// Resolved filesystem layout used by every subcommand.
#[derive(Debug, Clone)]
pub struct DataLayout {
    /// The base data directory, e.g. `$XDG_DATA_HOME/cortex/`.
    pub data_dir: PathBuf,
    /// Path to the SQLite database file.
    pub db_path: PathBuf,
    /// Path to the JSONL event log file.
    pub event_log_path: PathBuf,
}

impl DataLayout {
    /// Resolve paths from optional `--db` and `--event-log` overrides.
    ///
    /// When neither override is supplied, both files live under
    /// [`default_data_dir`] as `cortex.db` and `events.jsonl`.
    ///
    /// When one of them is supplied, the other still defaults under
    /// [`default_data_dir`] โ€” operators can override piecewise (e.g. point
    /// the event log at a separate volume).
    pub fn resolve(db: Option<PathBuf>, event_log: Option<PathBuf>) -> Result<Self, Exit> {
        let data_dir = match (&db, &event_log) {
            (Some(db_path), _) => parent_or_default(db_path)?,
            (None, Some(log_path)) => parent_or_default(log_path)?,
            (None, None) => default_data_dir().ok_or(Exit::PreconditionUnmet)?,
        };
        let db_path = db.unwrap_or_else(|| data_dir.join("cortex.db"));
        let event_log_path = event_log.unwrap_or_else(|| data_dir.join("events.jsonl"));
        Ok(Self {
            data_dir,
            db_path,
            event_log_path,
        })
    }
}

/// Default base data directory: `CORTEX_DATA_DIR` when explicitly set, otherwise
/// `$XDG_DATA_HOME/cortex/` (or its platform equivalent via [`dirs::data_dir`]).
///
/// Returns `None` when no home / data dir can be located โ€” typically only on
/// degenerate environments (containers without `$HOME` and no XDG vars).
#[must_use]
pub fn default_data_dir() -> Option<PathBuf> {
    if let Some(data_dir) = std::env::var_os("CORTEX_DATA_DIR").filter(|value| !value.is_empty()) {
        return Some(PathBuf::from(data_dir));
    }
    if let Some(data_home) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
        return Some(PathBuf::from(data_home).join("cortex"));
    }
    dirs::data_dir().map(|d| d.join("cortex"))
}

/// Assert that `dir` exists, is a directory, and (on Unix) is mode `0700`.
///
/// Returns [`Exit::PreconditionUnmet`] on any failure. Used by
/// `cortex init --validate-perms`.
///
/// On non-Unix platforms the mode check is a no-op (Windows does not have
/// POSIX mode bits in the same shape; we still verify existence).
pub fn assert_secure_data_dir(dir: &Path) -> Result<(), Exit> {
    let meta = std::fs::metadata(dir).map_err(|_| Exit::PreconditionUnmet)?;
    if !meta.is_dir() {
        return Err(Exit::PreconditionUnmet);
    }
    #[cfg(unix)]
    {
        let mode = meta.permissions().mode() & 0o777;
        if mode != 0o700 {
            return Err(Exit::PreconditionUnmet);
        }
    }
    Ok(())
}

fn parent_or_default(path: &Path) -> Result<PathBuf, Exit> {
    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
        Ok(parent.to_path_buf())
    } else {
        default_data_dir().ok_or(Exit::PreconditionUnmet)
    }
}

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

    #[test]
    fn resolve_with_explicit_db_uses_db_parent() {
        let tmp = tempdir().unwrap();
        let db = tmp.path().join("custom.db");
        let layout = DataLayout::resolve(Some(db.clone()), None).unwrap();
        assert_eq!(layout.data_dir, tmp.path());
        assert_eq!(layout.db_path, db);
        assert_eq!(layout.event_log_path, tmp.path().join("events.jsonl"));
    }

    #[test]
    fn resolve_with_both_overrides_honors_both() {
        let tmp = tempdir().unwrap();
        let db = tmp.path().join("a/db.sqlite");
        let log = tmp.path().join("b/log.jsonl");
        let layout = DataLayout::resolve(Some(db.clone()), Some(log.clone())).unwrap();
        assert_eq!(layout.db_path, db);
        assert_eq!(layout.event_log_path, log);
    }

    #[test]
    fn default_data_dir_resolves_under_xdg_or_platform_root() {
        // Just assert it resolves to *something* on this host. If it didn't,
        // the rest of the CLI would refuse to operate without `--db`, which
        // is the documented precondition.
        let dir = default_data_dir();
        assert!(
            dir.is_some(),
            "default_data_dir() should resolve on test host"
        );
        assert!(dir.unwrap().ends_with("cortex"));
    }

    #[cfg(unix)]
    #[test]
    fn assert_secure_data_dir_rejects_0755() {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;
        let tmp = tempdir().unwrap();
        let d = tmp.path().join("loose");
        fs::create_dir(&d).unwrap();
        fs::set_permissions(&d, fs::Permissions::from_mode(0o755)).unwrap();
        let err = assert_secure_data_dir(&d).unwrap_err();
        assert_eq!(err, Exit::PreconditionUnmet);
    }

    #[cfg(unix)]
    #[test]
    fn assert_secure_data_dir_accepts_0700() {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;
        let tmp = tempdir().unwrap();
        let d = tmp.path().join("tight");
        fs::create_dir(&d).unwrap();
        fs::set_permissions(&d, fs::Permissions::from_mode(0o700)).unwrap();
        assert_secure_data_dir(&d).expect("0700 should pass");
    }
}