kiromi-ai-cli 0.1.0

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
//! User-facing config: defaults → TOML file → `KIROMI_AI_*` env → CLI flags.

use std::path::PathBuf;

use figment::providers::{Env, Format, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize};

use crate::cli::GlobalArgs;
use crate::error::{CliError, ExitCode};

/// Effective config used by every subcommand.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct Config {
    /// Storage URI (e.g. `local:./store`).
    pub(crate) storage: Option<String>,
    /// Metadata URI (e.g. `sqlite:./store/metadata.db`).
    pub(crate) metadata: Option<String>,
    /// Tenant id.
    pub(crate) tenant: Option<String>,
    /// Partition scheme template.
    pub(crate) scheme: Option<String>,
    /// Embedder section.
    pub(crate) embedder: Option<EmbedderConfig>,
    /// Audit-log actor.
    pub(crate) actor: Option<String>,
}

/// `[embedder]` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct EmbedderConfig {
    /// Family name (e.g. `"onnx"`, `"mock"`).
    pub(crate) family: String,
    /// Family-specific config.
    #[serde(default)]
    pub(crate) config: serde_json::Value,
}

impl Config {
    /// Resolve the on-disk config path. Honours `--config` first; then
    /// `$XDG_CONFIG_HOME/kiromi-ai/config.toml` on Linux,
    /// `~/Library/Application Support/kiromi-ai/config.toml` on macOS.
    /// Returns `None` if no file exists.
    pub(crate) fn default_path(explicit: Option<&PathBuf>) -> Option<PathBuf> {
        if let Some(p) = explicit {
            return Some(p.clone());
        }
        let dirs = directories::ProjectDirs::from("dev", "kiromi-ai", "kiromi-ai")?;
        let p = dirs.config_dir().join("config.toml");
        p.exists().then_some(p)
    }

    /// Build the effective `Config` by merging file → env → flags.
    pub(crate) fn load(globals: &GlobalArgs) -> Result<Self, CliError> {
        let mut fig = Figment::new();
        if let Some(path) = Self::default_path(globals.config.as_ref()) {
            fig = fig.merge(Toml::file(path));
        }
        // Use `__` as the nested key separator so `KIROMI_AI_STORAGE` stays a
        // top-level key while `KIROMI_AI_EMBEDDER__FAMILY` could later target
        // the nested table without colliding with `KIROMI_AI_EMBEDDER_FAMILY`
        // (which we read directly as a flag).
        fig = fig.merge(Env::prefixed("KIROMI_AI_").split("__"));
        let mut cfg: Config = fig.extract().unwrap_or_default();

        // Flag overrides.
        if let Some(s) = &globals.storage {
            cfg.storage = Some(s.clone());
        }
        if let Some(m) = &globals.metadata {
            cfg.metadata = Some(m.clone());
        }
        if let Some(t) = &globals.tenant {
            cfg.tenant = Some(t.clone());
        }
        if let Some(s) = &globals.scheme {
            cfg.scheme = Some(s.clone());
        }
        if let Some(a) = &globals.actor {
            cfg.actor = Some(a.clone());
        }
        if let Some(family) = &globals.embedder_family {
            let raw = match &globals.embedder_config {
                Some(s) if s.starts_with('@') => {
                    let path = &s[1..];
                    let bytes = std::fs::read(path).map_err(|e| CliError {
                        kind: ExitCode::Config,
                        source: anyhow::anyhow!("read embedder config {path}: {e}"),
                    })?;
                    serde_json::from_slice(&bytes).map_err(|e| CliError {
                        kind: ExitCode::Config,
                        source: anyhow::anyhow!("parse embedder config {path}: {e}"),
                    })?
                }
                Some(s) => serde_json::from_str(s).map_err(|e| CliError {
                    kind: ExitCode::Config,
                    source: anyhow::anyhow!("parse --embedder-config: {e}"),
                })?,
                None => serde_json::Value::Null,
            };
            cfg.embedder = Some(EmbedderConfig {
                family: family.clone(),
                config: raw,
            });
        }
        Ok(cfg)
    }
}

#[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,
        }
    }

    #[test]
    fn flags_override_defaults() {
        let mut g = empty_globals();
        g.storage = Some("local:./from-flag".into());
        let cfg = Config::load(&g).unwrap();
        assert_eq!(cfg.storage.as_deref(), Some("local:./from-flag"));
    }

    #[test]
    fn embedder_inline_json_parses() {
        let mut g = empty_globals();
        g.embedder_family = Some("onnx".into());
        g.embedder_config = Some("{\"model\":\"multilingual-e5-small\"}".into());
        let cfg = Config::load(&g).unwrap();
        let e = cfg.embedder.unwrap();
        assert_eq!(e.family, "onnx");
        assert_eq!(e.config["model"], "multilingual-e5-small");
    }

    #[test]
    fn embedder_at_path_reads_from_file() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("emb.json");
        std::fs::write(&p, "{\"model\":\"from-file\"}").unwrap();
        let mut g = empty_globals();
        g.embedder_family = Some("onnx".into());
        g.embedder_config = Some(format!("@{}", p.display()));
        let cfg = Config::load(&g).unwrap();
        assert_eq!(cfg.embedder.unwrap().config["model"], "from-file");
    }

    #[test]
    fn embedder_invalid_json_is_config_error() {
        let mut g = empty_globals();
        g.embedder_family = Some("onnx".into());
        g.embedder_config = Some("not json".into());
        let err = Config::load(&g).unwrap_err();
        assert_eq!(err.kind, crate::error::ExitCode::Config);
    }

    #[test]
    fn default_path_honours_explicit_override() {
        let p = std::path::PathBuf::from("/nonexistent/explicit.toml");
        let resolved = Config::default_path(Some(&p));
        assert_eq!(resolved, Some(p));
    }

    #[test]
    fn null_embedder_config_is_null_value() {
        let mut g = empty_globals();
        g.embedder_family = Some("mock".into());
        // No --embedder-config provided.
        let cfg = Config::load(&g).unwrap();
        let e = cfg.embedder.unwrap();
        assert_eq!(e.family, "mock");
        assert!(e.config.is_null());
    }

    #[test]
    fn actor_flag_propagates() {
        let mut g = empty_globals();
        g.actor = Some("alex@laptop".into());
        let cfg = Config::load(&g).unwrap();
        assert_eq!(cfg.actor.as_deref(), Some("alex@laptop"));
    }
}