ckg-storage 1.3.0

CozoDB-backed storage layer for ckg (per-repo + registry DBs).
Documentation
//! Storage facade over CozoDB. One DB per repo, plus a shared registry DB.
//!
//! DDL is run idempotently on first open: Cozo errors if a relation already
//! exists, and we detect that string and swallow it so re-opens are no-ops.
//!
//! Sub-modules own the implementation of each concern:
//! - `lifecycle`  — open / schema-version check / rebuild / path-swap
//! - `meta`       — boolean Meta sentinels (needs_reindex, index_in_progress)
//! - `insert`     — put_symbols / put_edges
//! - `resolve`    — resolve_cross_file_calls / detect_test_edges

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

use ckg_core::{Error, Result};
use cozo::{DataValue, DbInstance, ScriptMutability};

use self::meta::{read_meta_bool, stamp_meta_bool, stamp_needs_reindex};

mod insert;
mod lifecycle;
mod meta;
mod registry;
mod resolve;

pub use registry::RegistryStorage;

// ---------------------------------------------------------------------------
// Shared helpers (used by multiple sub-modules via `super::map_err`)
// ---------------------------------------------------------------------------

pub(super) fn map_err(e: impl std::fmt::Display) -> Error {
    Error::Storage(e.to_string())
}

// ---------------------------------------------------------------------------
// Core structs
// ---------------------------------------------------------------------------

/// Per-repo Cozo DB handle.
pub struct Storage {
    pub(super) repo_id: ckg_core::RepoId,
    pub(super) db_path: PathBuf,
    pub(super) db: DbInstance,
}

impl Storage {
    // --- accessors ----------------------------------------------------------

    pub fn repo_id(&self) -> &ckg_core::RepoId {
        &self.repo_id
    }

    pub fn db_path(&self) -> &Path {
        &self.db_path
    }

    /// CR-storage-M-7: returns the currently-recorded `Meta.root_path`,
    /// which may differ from the caller's input shape if open_at auto-
    /// migrated a symlink-equivalent path (canonicalize-equal). Callers
    /// that also stamp the path elsewhere (e.g. registry's `Repo.root_path`
    /// via `RegistryStorage::put_repo`) should use this accessor so the
    /// two authoritative shapes stay consistent.
    ///
    /// CR-storage-H3: returns `Result<Option<String>>` so callers can
    /// distinguish "no row recorded" (Ok(None) — fresh DB) from "Meta
    /// read failed" (Err — disk error / corruption). Pre-fix the
    /// `.ok()?` collapse made both cases indistinguishable, mirroring
    /// the same bug pattern that `RootPathProbe::ReadFailed` was
    /// introduced to fix in `lifecycle.rs`.
    pub fn recorded_root_path(&self) -> Result<Option<String>> {
        let rows = self
            .db
            .run_script(
                "?[v] := *Meta{key: \"root_path\", value: v}",
                BTreeMap::new(),
                ScriptMutability::Immutable,
            )
            .map_err(map_err)?;
        Ok(rows.rows.first().and_then(|r| r.first()).and_then(|v| match v {
            DataValue::Str(s) => Some(s.to_string()),
            _ => None,
        }))
    }

    pub fn db(&self) -> &DbInstance {
        &self.db
    }

    // --- script runners -----------------------------------------------------

    /// Run a Cozo script with **mutable** access (`:put`, `:rm`, `:create`
    /// etc. are allowed). Safety relies on Cozo's `ScriptMutability::Mutable`
    /// runtime gate — there is no string-level prefilter.
    ///
    /// **STORAGE-H2 / danger:** The name is intentionally verbose. Callers
    /// must hold an explicit intent to mutate; prefer `Self::run_immutable`
    /// or `Self::run_with_immutable` for all read paths and any
    /// caller-supplied Datalog (e.g. MCP `query` tool). This keeps the
    /// footprint of mutable execution small and auditable.
    pub fn run_mutable_unchecked(&self, script: &str) -> Result<cozo::NamedRows> {
        self.db
            .run_script(script, BTreeMap::new(), ScriptMutability::Mutable)
            .map_err(map_err)
    }

    /// Read-only run — passes `ScriptMutability::Immutable` to Cozo which
    /// rejects scripts containing `:put`, `:rm`, `:create`, `:replace`,
    /// `:ensure_not`, etc. **at execution time** (not string-prefiltered).
    /// Use this for any caller-supplied Datalog (MCP `query` tool) so a
    /// malicious client can't drop or mutate relations.
    pub fn run_immutable(&self, script: &str) -> Result<cozo::NamedRows> {
        self.db
            .run_script(script, BTreeMap::new(), ScriptMutability::Immutable)
            .map_err(map_err)
    }

    /// Mutable run with parameters. Same safety model as `Self::run` — relies
    /// on `ScriptMutability::Mutable` runtime gate, no string prefilter.
    pub fn run_with(
        &self,
        script: &str,
        params: BTreeMap<String, DataValue>,
    ) -> Result<cozo::NamedRows> {
        self.db
            .run_script(script, params, ScriptMutability::Mutable)
            .map_err(map_err)
    }

    /// Read-only variant of `run_with` — caller-supplied params, but the
    /// script is rejected if it contains `:put` / `:rm` / `:create` /
    /// `:replace`. Use for any caller-controlled Datalog so a malicious /
    /// typo'd script can't mutate.
    pub fn run_with_immutable(
        &self,
        script: &str,
        params: BTreeMap<String, DataValue>,
    ) -> Result<cozo::NamedRows> {
        self.db
            .run_script(script, params, ScriptMutability::Immutable)
            .map_err(map_err)
    }

    // --- meta sentinels -----------------------------------------------------

    /// True if this repo was schema-rebuilt and hasn't been re-indexed since.
    /// Set when `Storage::open_*` triggers `rebuild_at_path`, cleared via
    /// `mark_indexed()` after a successful `ckg index` run.
    ///
    /// Returns `false` on any read failure — the sentinel is best-effort UX
    /// guidance, not a correctness gate.
    pub fn needs_reindex(&self) -> bool {
        read_meta_bool(&self.db, "needs_reindex")
    }

    /// CR-I-2: Atomicity sentinel. Stamp `index_in_progress=true` BEFORE the
    /// first `put_symbols` / `put_edges` of a fresh index run. On next
    /// `Storage::open`, if this flag is still set, `needs_reindex` is promoted.
    /// Cleared by `mark_indexed`.
    pub fn mark_index_in_progress(&self) -> Result<()> {
        stamp_meta_bool(&self.db, "index_in_progress", true)
    }

    /// CR-I-2: True if a previous index run started but didn't reach
    /// `mark_indexed`. Mostly internal — `Storage::open_at` checks this on
    /// every open.
    pub fn is_index_in_progress(&self) -> bool {
        read_meta_bool(&self.db, "index_in_progress")
    }

    /// Clear the `needs_reindex` AND `index_in_progress` sentinels after a
    /// successful `ckg index` run. Safe to call when the sentinels are already
    /// absent.
    pub fn mark_indexed(&self) -> Result<()> {
        stamp_needs_reindex(&self.db, false)?;
        stamp_meta_bool(&self.db, "index_in_progress", false)?;
        Ok(())
    }

}

// Tests live in a sibling `tests.rs` so the production surface of
// `store/mod.rs` stays auditable on its own. The test module reaches
// back via `super::*` for the same items it had inline.
#[cfg(test)]
mod tests;