kiromi-ai-cli 0.2.2

Operator and developer CLI for the kiromi-ai-memory store: append, search, snapshot, regenerate, migrate-scheme, gc, audit-tail.
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! CLI error envelope + the spec § 17 exit-code matrix.

use std::process::ExitCode as ProcExitCode;

use kiromi_ai_memory::Error as CoreError;

/// Stable exit codes the CLI promises to operators.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExitCode {
    /// 0 — success.
    Success,
    /// 1 — anything else.
    Generic,
    /// 2 — config / usage error.
    Config,
    /// 3 — not found.
    NotFound,
    /// 4 — tombstoned.
    Tombstoned,
    /// 5 — backend (storage / metadata / IO / index corruption / recovery).
    Backend,
    /// 6 — embedder error.
    Embedder,
    /// 7 — scheme / embedder mismatch.
    Mismatch,
}

impl ExitCode {
    /// Translate to a `std::process::ExitCode`.
    #[must_use]
    pub(crate) fn into_proc(self) -> ProcExitCode {
        ProcExitCode::from(self.as_u8())
    }

    /// Raw u8 for testing.
    #[must_use]
    pub(crate) fn as_u8(self) -> u8 {
        match self {
            ExitCode::Success => 0,
            ExitCode::Generic => 1,
            ExitCode::Config => 2,
            ExitCode::NotFound => 3,
            ExitCode::Tombstoned => 4,
            ExitCode::Backend => 5,
            ExitCode::Embedder => 6,
            ExitCode::Mismatch => 7,
        }
    }
}

/// Wraps an `anyhow::Error` with the matching exit code.
#[derive(Debug)]
pub(crate) struct CliError {
    /// Exit code to return.
    pub(crate) kind: ExitCode,
    /// Underlying error.
    pub(crate) source: anyhow::Error,
}

impl std::fmt::Display for CliError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.source)
    }
}

impl std::error::Error for CliError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(self.source.as_ref())
    }
}

impl From<CoreError> for CliError {
    fn from(e: CoreError) -> Self {
        let kind = exit_code_for(&e);
        CliError {
            kind,
            source: anyhow::Error::new(e),
        }
    }
}

/// Map a core error to its CLI exit code per spec § 17.
pub(crate) fn exit_code_for(e: &CoreError) -> ExitCode {
    match e {
        CoreError::Storage { .. }
        | CoreError::Metadata { .. }
        | CoreError::IndexCorrupt(_)
        | CoreError::Recovery(_)
        | CoreError::Io(_) => ExitCode::Backend,
        CoreError::Embedder { .. } => ExitCode::Embedder,
        CoreError::EmbedderMismatch { .. } | CoreError::PartitionSchemeMismatch { .. } => {
            ExitCode::Mismatch
        }
        CoreError::PartitionInvalid(_)
        | CoreError::PartitionSchemeInvalid(_)
        | CoreError::TenantInvalid(_)
        | CoreError::Config(_)
        | CoreError::LinkInvalid(_) => ExitCode::Config,
        CoreError::MemoryNotFound(_) => ExitCode::NotFound,
        CoreError::Tombstoned(_) => ExitCode::Tombstoned,
        // `#[non_exhaustive]` — future variants get the generic bucket.
        _ => ExitCode::Generic,
    }
}

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

    #[test]
    fn known_variants_map_correctly() {
        assert_eq!(
            exit_code_for(&CoreError::MemoryNotFound("x".into())),
            ExitCode::NotFound
        );
        assert_eq!(
            exit_code_for(&CoreError::Tombstoned("x".into())),
            ExitCode::Tombstoned
        );
        assert_eq!(
            exit_code_for(&CoreError::Config("bad".into())),
            ExitCode::Config
        );
    }
}