ckg-storage 1.3.0

CozoDB-backed storage layer for ckg (per-repo + registry DBs).
Documentation
//! Cozo error compatibility — typed-shape facade over `cozo-ce-0.7.x`.
//!
//! ## Why this module exists
//!
//! Cozo errors are `miette::Error` wrappers around private, internal
//! error types — there is no public typed enum to pattern-match on
//! (verified against `cozo-ce-0.7.13-alpha.3`: `pub use miette::Error`,
//! no re-export of typed variants). Callers therefore inspect
//! `Error::to_string()` against known message substrings.
//!
//! Substring matching is fragile across Cozo releases. To contain that
//! fragility, every caller speaks `CozoErrorKind` instead of doing its
//! own `.contains(...)` check. Today the kind is computed by substring
//! matching; tomorrow — when Cozo exposes typed errors upstream — only
//! `CozoErrorKind::of` changes, and every consumer carries on unchanged.
//!
//! ## Upstream tracking
//!
//! As of `cozo-ce-0.7.13-alpha.3` (the newest release on crates.io at
//! 2026-05-14), Cozo re-exports only `miette::Error` from `lib.rs`.
//! When a future release ships a typed `ScriptError` (or similar) enum,
//! migrate `CozoErrorKind::of` to a typed `match` and the rest of the
//! codebase is unaffected.
//!
//! ## Adding a new kind
//!
//! 1. Add a variant to `CozoErrorKind`.
//! 2. Add the actual Cozo message strings (often multiple, since the
//!    message has changed across Cozo releases) to `CozoErrorKind::of`.
//! 3. Add positive + negative test cases in the `tests` module below.
//!
//! ## Adding a new substring to an existing kind
//!
//! 1. Append the new substring to the relevant arm of `CozoErrorKind::of`.
//! 2. Add a positive test case so a future contributor (or `git blame`)
//!    can see when each substring was added and why.
//!
//! ## Boolean helpers (legacy, retained for ergonomics)
//!
//! The original API was a set of `is_*` predicates. Those are now thin
//! wrappers over `CozoErrorKind::of` and call sites are free to use
//! either — predicates for one-line `if`s, the enum for
//! exhaustive-`match` decisions.

use ckg_core::Error;

/// Classified shape of a Cozo error. The enum exists so call sites can
/// `match` on a typed value instead of duplicating substring checks.
/// When Cozo upstream exposes typed errors, only [`CozoErrorKind::of`]
/// changes — every caller continues to operate on this enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CozoErrorKind {
    /// Cozo doesn't know the function / operator referenced in the
    /// script. Typically signals an older Cozo build lacking a
    /// modern built-in (`is_in`, `str_includes`). Callers should
    /// fall back to a lower-fidelity query rather than fail.
    UnsupportedBuiltin,
    /// A relation referenced in a `:rm` / `:put` / read-projection
    /// does not exist. Recoverable in idempotent-DDL paths — create
    /// the relation and retry.
    RelationMissing,
    /// Concurrent writer collided with a `:rm` + `:put` pair on the
    /// same relation. Retry the operation.
    RelationConflict,
    /// `:create` ran against a relation that already exists. Benign
    /// in idempotent DDL paths.
    RelationAlreadyExists,
    /// None of the known shapes — treat as a real failure
    /// (I/O, parse, OOM, lock contention, …).
    Other,
}

impl CozoErrorKind {
    /// Classify a Cozo error by inspecting its `Display` output.
    ///
    /// **Implementation note (upstream-blocked):** Cozo re-exports
    /// `miette::Error` with no typed variants, so we cannot match on a
    /// concrete enum. Each arm lists the wording observed in
    /// `cozo-ce-0.7.13-alpha.3`; add new substrings as Cozo's wording
    /// evolves. When Cozo eventually exposes a typed `ScriptError`,
    /// this function becomes a `match` and the rest of the API stays
    /// stable.
    pub fn of(err: &Error) -> Self {
        let msg = err.to_string();
        // Order matters: "does not exist" is the broadest substring and
        // overlaps with "operator X does not exist"; check the
        // builtin-shape first so a future Cozo wording change doesn't
        // silently mis-classify.
        if msg.contains("unknown function")
            || msg.contains("Unknown function")
            || msg.contains("CannotFindOp")
            || msg.contains("undefined op")
            || msg.contains("undefined function")
            || msg.contains("operator") && msg.contains("not found")
        {
            return Self::UnsupportedBuiltin;
        }
        if msg.contains("EvalRelationConflict") {
            return Self::RelationConflict;
        }
        if msg.contains("does not exist") {
            return Self::RelationMissing;
        }
        if msg.contains("already exists") {
            return Self::RelationAlreadyExists;
        }
        Self::Other
    }

    /// `true` if this kind is benign in a cross-repo flush context —
    /// either the target relation hasn't been created yet
    /// (`RelationMissing`) or a concurrent writer collided
    /// (`RelationConflict`). Both recover by re-creating the relation
    /// and retrying.
    pub fn is_flush_recoverable(self) -> bool {
        matches!(self, Self::RelationMissing | Self::RelationConflict)
    }
}

// --- legacy predicate wrappers --------------------------------------
//
// These remain `pub` so existing one-line `if cozo_compat::is_*(...)`
// call sites stay readable. New code is free to call
// `CozoErrorKind::of(err)` directly when more than one kind is in scope.

pub fn is_unsupported_builtin(err: &Error) -> bool {
    matches!(CozoErrorKind::of(err), CozoErrorKind::UnsupportedBuiltin)
}

pub fn is_relation_missing(err: &Error) -> bool {
    matches!(CozoErrorKind::of(err), CozoErrorKind::RelationMissing)
}

pub fn is_relation_conflict(err: &Error) -> bool {
    matches!(CozoErrorKind::of(err), CozoErrorKind::RelationConflict)
}

pub fn is_relation_already_exists(err: &Error) -> bool {
    matches!(CozoErrorKind::of(err), CozoErrorKind::RelationAlreadyExists)
}

pub fn is_flush_recoverable(err: &Error) -> bool {
    CozoErrorKind::of(err).is_flush_recoverable()
}

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

    fn s(msg: &str) -> Error {
        Error::Storage(msg.into())
    }

    #[test]
    fn matches_unsupported_builtin_messages() {
        for m in [
            "operator str_includes not found",
            "Unknown function: str_includes",
            "CannotFindOp(\"is_in\")",
            "undefined op: foo",
            "undefined function bar",
        ] {
            assert_eq!(
                CozoErrorKind::of(&s(m)),
                CozoErrorKind::UnsupportedBuiltin,
                "should classify as UnsupportedBuiltin: {m}"
            );
            assert!(is_unsupported_builtin(&s(m)));
        }
    }

    #[test]
    fn does_not_match_real_errors() {
        for m in [
            "I/O error: disk full",
            "permission denied",
            "expected ; near line 5",
            "value of type bool expected, got string",
            "RocksDB lock contention",
        ] {
            assert_eq!(
                CozoErrorKind::of(&s(m)),
                CozoErrorKind::Other,
                "should classify as Other: {m}"
            );
            assert!(!is_unsupported_builtin(&s(m)));
        }
    }

    #[test]
    fn classifies_relation_missing() {
        assert_eq!(
            CozoErrorKind::of(&s("relation CROSS_CALLS does not exist")),
            CozoErrorKind::RelationMissing
        );
        assert!(is_relation_missing(&s("relation CROSS_CALLS does not exist")));
        assert!(!is_relation_missing(&s("I/O error: disk full")));
    }

    #[test]
    fn classifies_relation_conflict() {
        assert_eq!(
            CozoErrorKind::of(&s("EvalRelationConflict(\"X\")")),
            CozoErrorKind::RelationConflict
        );
        assert!(is_relation_conflict(&s("EvalRelationConflict(\"X\")")));
        assert!(!is_relation_conflict(&s("RocksDB lock contention")));
    }

    #[test]
    fn classifies_already_exists() {
        assert_eq!(
            CozoErrorKind::of(&s("relation Symbol already exists")),
            CozoErrorKind::RelationAlreadyExists
        );
        assert!(is_relation_already_exists(&s("relation Symbol already exists")));
        assert!(!is_relation_already_exists(&s("not found")));
    }

    #[test]
    fn flush_recoverable_combines_missing_and_conflict() {
        assert!(CozoErrorKind::RelationMissing.is_flush_recoverable());
        assert!(CozoErrorKind::RelationConflict.is_flush_recoverable());
        assert!(!CozoErrorKind::Other.is_flush_recoverable());
        assert!(!CozoErrorKind::UnsupportedBuiltin.is_flush_recoverable());
        assert!(!CozoErrorKind::RelationAlreadyExists.is_flush_recoverable());
        // Boolean wrapper agrees.
        assert!(is_flush_recoverable(&s("relation CROSS_CALLS does not exist")));
        assert!(is_flush_recoverable(&s("EvalRelationConflict")));
        assert!(!is_flush_recoverable(&s("disk full")));
    }

    /// The "operator X not found" form must classify as
    /// `UnsupportedBuiltin`, NOT `RelationMissing`, even though
    /// "operator X does not exist" would also be a plausible Cozo
    /// wording. The order of checks in `of` enforces this — if a
    /// future contributor reorders them, this test fires.
    #[test]
    fn operator_not_found_classifies_as_builtin_not_relation_missing() {
        let e = s("operator str_includes not found");
        assert_eq!(CozoErrorKind::of(&e), CozoErrorKind::UnsupportedBuiltin);
    }

    /// Match exhaustiveness guard: every variant must be reachable
    /// from at least one observed Cozo message. If a future
    /// contributor adds an unreachable variant, the compiler can't
    /// catch it — this test does.
    #[test]
    fn every_variant_has_at_least_one_matching_message() {
        use CozoErrorKind::*;
        let probes = [
            ("Unknown function: foo", UnsupportedBuiltin),
            ("relation CROSS_CALLS does not exist", RelationMissing),
            ("EvalRelationConflict(\"X\")", RelationConflict),
            ("relation Symbol already exists", RelationAlreadyExists),
            ("I/O error: disk full", Other),
        ];
        for (msg, want) in probes {
            assert_eq!(CozoErrorKind::of(&s(msg)), want, "probe: {msg}");
        }
    }
}