use std::path::PathBuf;
use figment::Figment;
use figment::providers::{Env, Format, Toml};
use serde::{Deserialize, Serialize};
use crate::cli::GlobalArgs;
use crate::error::{CliError, ExitCode};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct Config {
pub(crate) storage: Option<String>,
pub(crate) metadata: Option<String>,
pub(crate) tenant: Option<String>,
pub(crate) scheme: Option<String>,
pub(crate) embedder: Option<EmbedderConfig>,
pub(crate) actor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct EmbedderConfig {
pub(crate) family: String,
#[serde(default)]
pub(crate) config: serde_json::Value,
}
impl Config {
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)
}
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));
}
fig = fig.merge(Env::prefixed("KIROMI_AI_").split("__"));
let mut cfg: Config = fig.extract().unwrap_or_default();
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());
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"));
}
}