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
//! `Runtime` — owns the opened `Memory` for the lifetime of one command.

use kiromi_ai_memory::{Memory, TenantId};

use crate::cli::GlobalArgs;
use crate::config::Config;
use crate::embedder::resolve as resolve_embedder;
use crate::error::{CliError, ExitCode};
use crate::uri::{parse_metadata, parse_storage};

/// Bag of plumbing each subcommand needs.
pub(crate) struct Runtime {
    /// Opened engine.
    pub(crate) mem: Memory,
    /// Resolved config (for inspect / debug).
    pub(crate) cfg: Config,
    /// Whether `--json` was requested.
    pub(crate) json: bool,
}

impl Runtime {
    /// Open the engine. Used by every subcommand (including `init` — `Memory::open`
    /// already writes `schema_meta` on first open and validates on reopen).
    pub(crate) async fn open(globals: &GlobalArgs) -> Result<Self, CliError> {
        let cfg = Config::load(globals)?;
        let storage_uri = cfg
            .storage
            .as_deref()
            .ok_or_else(|| missing_field("storage"))?;
        let metadata_uri = cfg
            .metadata
            .as_deref()
            .ok_or_else(|| missing_field("metadata"))?;
        let scheme = cfg
            .scheme
            .as_deref()
            .ok_or_else(|| missing_field("scheme"))?;
        let tenant_str = cfg
            .tenant
            .as_deref()
            .ok_or_else(|| missing_field("tenant"))?;
        let tenant = TenantId::new(tenant_str).map_err(|e| CliError {
            kind: ExitCode::Config,
            source: anyhow::anyhow!("tenant: {e}"),
        })?;

        let storage = parse_storage(storage_uri).await?;
        let metadata = parse_metadata(metadata_uri).await?;
        let embedder = if globals.no_embedder {
            None
        } else {
            resolve_embedder(cfg.embedder.as_ref()).await?
        };

        let mut builder = Memory::builder()
            .storage_boxed(storage)
            .metadata_boxed(metadata)
            .tenant(tenant);
        if let Some(e) = embedder {
            builder = builder.embedder_boxed(e);
        }
        if let Some(actor) = &cfg.actor {
            builder = builder.actor(actor.clone());
        }
        let mem = builder
            .partition_scheme(scheme)
            .map_err(CliError::from)?
            .open()
            .await
            .map_err(CliError::from)?;

        Ok(Runtime {
            mem,
            cfg,
            json: globals.json,
        })
    }
}

fn missing_field(name: &str) -> CliError {
    CliError {
        kind: ExitCode::Config,
        source: anyhow::anyhow!(
            "{name} is required (set via --{name}, KIROMI_AI_{}, or config.toml)",
            name.to_uppercase()
        ),
    }
}

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

    fn empty_globals() -> GlobalArgs {
        GlobalArgs {
            config: None,
            storage: None,
            metadata: None,
            tenant: None,
            scheme: None,
            embedder_family: None,
            embedder_config: None,
            no_embedder: false,
            actor: None,
            json: false,
            verbose: 0,
        }
    }

    fn assert_open_err(err: CliError, expected_substring: &str) {
        assert_eq!(err.kind, ExitCode::Config);
        let s = err.source.to_string();
        assert!(
            s.contains(expected_substring),
            "expected {expected_substring:?} in {s:?}"
        );
    }

    #[tokio::test]
    async fn missing_storage_returns_config_error() {
        let g = empty_globals();
        match Runtime::open(&g).await {
            Ok(_) => panic!("expected config error"),
            Err(e) => assert_open_err(e, "storage"),
        }
    }

    #[tokio::test]
    async fn missing_metadata_returns_config_error() {
        let mut g = empty_globals();
        g.storage = Some("memory:".into());
        match Runtime::open(&g).await {
            Ok(_) => panic!("expected config error"),
            Err(e) => assert_open_err(e, "metadata"),
        }
    }

    #[tokio::test]
    async fn missing_scheme_returns_config_error() {
        let mut g = empty_globals();
        g.storage = Some("memory:".into());
        g.metadata = Some("sqlite::memory:".into());
        match Runtime::open(&g).await {
            Ok(_) => panic!("expected config error"),
            Err(e) => assert_open_err(e, "scheme"),
        }
    }

    #[tokio::test]
    async fn missing_tenant_returns_config_error() {
        let mut g = empty_globals();
        g.storage = Some("memory:".into());
        g.metadata = Some("sqlite::memory:".into());
        g.scheme = Some("user={user}".into());
        match Runtime::open(&g).await {
            Ok(_) => panic!("expected config error"),
            Err(e) => assert_open_err(e, "tenant"),
        }
    }
}