use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize)]
pub struct CartogConfig {
pub database: Option<DatabaseConfig>,
pub embedding: Option<EmbeddingConfig>,
pub reranker: Option<RerankerConfig>,
}
#[derive(Debug, Default, Deserialize)]
pub struct DatabaseConfig {
pub path: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct EmbeddingConfig {
pub provider: Option<String>,
pub model: Option<String>,
pub dimension: Option<usize>,
pub local: Option<LocalEmbeddingConfig>,
pub ollama: Option<OllamaConfig>,
}
pub const DEFAULT_EMBEDDING_PROVIDER: &str = "local";
impl EmbeddingConfig {
pub fn provider(&self) -> &str {
self.provider
.as_deref()
.unwrap_or(DEFAULT_EMBEDDING_PROVIDER)
}
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct LocalEmbeddingConfig {
pub query_prefix: Option<String>,
pub document_prefix: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct OllamaConfig {
pub base_url: Option<String>,
pub model: Option<String>,
}
pub const DEFAULT_OLLAMA_BASE_URL: &str = cartog_rag::providers::DEFAULT_OLLAMA_BASE_URL;
pub const DEFAULT_OLLAMA_MODEL: &str = cartog_rag::providers::DEFAULT_OLLAMA_MODEL;
impl OllamaConfig {
pub fn base_url(&self) -> &str {
self.base_url.as_deref().unwrap_or(DEFAULT_OLLAMA_BASE_URL)
}
pub fn model(&self) -> &str {
self.model.as_deref().unwrap_or(DEFAULT_OLLAMA_MODEL)
}
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct RerankerConfig {
pub provider: Option<String>,
}
pub const DEFAULT_RERANKER_PROVIDER: &str = "local";
impl RerankerConfig {
pub fn provider(&self) -> &str {
self.provider
.as_deref()
.unwrap_or(DEFAULT_RERANKER_PROVIDER)
}
}
pub fn to_provider_config(config: &CartogConfig) -> cartog_rag::EmbeddingProviderConfig {
match &config.embedding {
Some(embed) => {
let (query_prefix, document_prefix) = match &embed.local {
Some(local) => (local.query_prefix.clone(), local.document_prefix.clone()),
None => (None, None),
};
let ollama = embed.ollama.as_ref();
cartog_rag::EmbeddingProviderConfig {
provider: embed.provider().to_string(),
model: embed
.model
.clone()
.or_else(|| ollama.map(|o| o.model().to_string())),
dimension: embed.dimension,
query_prefix,
document_prefix,
base_url: ollama.map(|o| o.base_url().to_string()),
reranker_provider: config
.reranker
.as_ref()
.map(|r| r.provider().to_string())
.unwrap_or_else(|| DEFAULT_RERANKER_PROVIDER.to_string()),
}
}
None => cartog_rag::EmbeddingProviderConfig::default(),
}
}
pub fn load_config() -> (CartogConfig, Option<PathBuf>) {
match local_config_path() {
Some(p) => match read_config(&p) {
Some(cfg) => (cfg, Some(p)),
None => (CartogConfig::default(), None),
},
None => (CartogConfig::default(), None),
}
}
fn local_config_path() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join(".cartog.toml");
if candidate.exists() {
return Some(candidate);
}
if dir.join(".git").exists() {
return None;
}
if !dir.pop() {
break;
}
}
None
}
fn read_config(path: &Path) -> Option<CartogConfig> {
let text = std::fs::read_to_string(path).ok()?;
match toml::from_str::<CartogConfig>(&text) {
Ok(cfg) => Some(cfg),
Err(e) => {
eprintln!("cartog: warning: failed to parse {}: {e}", path.display());
None
}
}
}
pub fn resolve_db_path(explicit: Option<PathBuf>, config: &CartogConfig) -> PathBuf {
if let Some(p) = explicit {
return expand_tilde(p);
}
if let Some(path_str) = config.database.as_ref().and_then(|d| d.path.as_deref()) {
return expand_tilde(PathBuf::from(path_str));
}
if let Ok(mut dir) = std::env::current_dir() {
loop {
if dir.join(".git").exists() {
return dir.join(cartog_db::DB_FILE);
}
if !dir.pop() {
break;
}
}
}
PathBuf::from(cartog_db::DB_FILE)
}
pub fn expand_tilde(p: PathBuf) -> PathBuf {
let s = p.to_string_lossy();
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
return PathBuf::from(home).join(rest);
}
}
p
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
#[test]
fn test_expand_tilde_with_home() {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/tmp".into());
let expanded = expand_tilde(PathBuf::from("~/foo/bar"));
assert_eq!(expanded, PathBuf::from(home).join("foo/bar"));
}
#[test]
fn test_expand_tilde_no_tilde() {
let p = PathBuf::from("/absolute/path");
assert_eq!(expand_tilde(p.clone()), p);
}
#[test]
fn test_read_config_valid_toml() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "[database]\npath = \"/tmp/test.db\"\n").unwrap();
let cfg = read_config(&cfg_path).expect("should parse");
assert_eq!(
cfg.database.as_ref().unwrap().path.as_deref(),
Some("/tmp/test.db")
);
}
#[test]
fn test_read_config_missing_file_returns_none() {
let result = read_config(Path::new("/nonexistent/path/config.toml"));
assert!(result.is_none());
}
#[test]
fn test_read_config_invalid_toml_returns_none() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "this is {{ not valid toml").unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
fn test_read_config_empty_toml_returns_default() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "").unwrap();
let cfg = read_config(&cfg_path).expect("empty toml is valid");
assert!(cfg.database.is_none());
}
#[test]
fn test_resolve_explicit_wins_over_config() {
let cfg = CartogConfig {
database: Some(DatabaseConfig {
path: Some("/config/path.db".to_string()),
}),
..Default::default()
};
let result = resolve_db_path(Some(PathBuf::from("/explicit/path.db")), &cfg);
assert_eq!(result, PathBuf::from("/explicit/path.db"));
}
#[test]
fn test_resolve_config_path_used_when_no_explicit() {
let cfg = CartogConfig {
database: Some(DatabaseConfig {
path: Some("/config/proj.db".to_string()),
}),
..Default::default()
};
let result = resolve_db_path(None, &cfg);
assert_eq!(result, PathBuf::from("/config/proj.db"));
}
#[test]
#[serial]
fn test_resolve_fallback_when_no_config_and_no_git() {
let dir = tempfile::TempDir::new().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = resolve_db_path(None, &CartogConfig::default());
std::env::set_current_dir(original).unwrap();
assert_eq!(result, PathBuf::from(cartog_db::DB_FILE));
}
#[test]
#[serial]
fn test_resolve_git_root_detection() {
let dir = tempfile::TempDir::new().unwrap();
let canonical_root = dir.path().canonicalize().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir(&git_dir).unwrap();
let subdir = dir.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(&subdir).unwrap();
let result = resolve_db_path(None, &CartogConfig::default());
std::env::set_current_dir(original).unwrap();
assert_eq!(result, canonical_root.join(cartog_db::DB_FILE));
}
#[test]
fn test_embedding_config_defaults() {
let cfg = EmbeddingConfig::default();
assert_eq!(cfg.provider(), "local");
assert!(cfg.dimension.is_none());
assert!(cfg.model.is_none());
assert!(cfg.local.is_none());
assert!(cfg.ollama.is_none());
}
#[test]
fn test_embedding_config_from_toml() {
let toml_str = r#"
[embedding]
provider = "ollama"
model = "nomic-embed-text"
dimension = 768
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let embed = cfg.embedding.unwrap();
assert_eq!(embed.provider(), "ollama");
assert_eq!(embed.model.as_deref(), Some("nomic-embed-text"));
assert_eq!(embed.dimension, Some(768));
}
#[test]
fn test_embedding_config_local_with_prefixes() {
let toml_str = r#"
[embedding]
provider = "local"
model = "BAAI/bge-small-en-v1.5"
[embedding.local]
query_prefix = "search_query: "
document_prefix = "search_document: "
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let embed = cfg.embedding.unwrap();
assert_eq!(embed.provider(), "local");
let local = embed.local.unwrap();
assert_eq!(local.query_prefix.as_deref(), Some("search_query: "));
assert_eq!(local.document_prefix.as_deref(), Some("search_document: "));
}
#[test]
fn test_ollama_config_defaults() {
let cfg = OllamaConfig::default();
assert_eq!(cfg.base_url(), "http://localhost:11434");
assert_eq!(cfg.model(), "nomic-embed-text");
}
#[test]
fn test_ollama_config_from_toml() {
let toml_str = r#"
[embedding.ollama]
base_url = "http://gpu-server:11434"
model = "mxbai-embed-large"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let ollama = cfg.embedding.unwrap().ollama.unwrap();
assert_eq!(ollama.base_url(), "http://gpu-server:11434");
assert_eq!(ollama.model(), "mxbai-embed-large");
}
#[test]
fn test_reranker_config_defaults() {
let cfg = RerankerConfig::default();
assert_eq!(cfg.provider(), "local");
}
#[test]
fn test_reranker_config_none() {
let toml_str = r#"
[reranker]
provider = "none"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.reranker.unwrap().provider(), "none");
}
#[test]
fn test_full_config_backward_compat() {
let toml_str = r#"
[database]
path = "/tmp/test.db"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
assert!(cfg.embedding.is_none());
assert!(cfg.reranker.is_none());
assert_eq!(cfg.database.unwrap().path.as_deref(), Some("/tmp/test.db"));
}
#[test]
fn test_config_unknown_fields_ignored() {
let toml_str = r#"
[embedding]
provider = "local"
unknown_field = "should be ignored"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.embedding.unwrap().provider(), "local");
}
#[test]
fn test_to_provider_config_defaults() {
let cfg = CartogConfig::default();
let pc = to_provider_config(&cfg);
assert_eq!(pc.provider, "local");
assert!(pc.model.is_none());
assert_eq!(pc.resolved_dimension(), 384);
assert!(pc.query_prefix.is_none());
assert!(pc.document_prefix.is_none());
}
#[test]
fn test_to_provider_config_from_toml() {
let toml_str = r#"
[embedding]
provider = "ollama"
model = "nomic-embed-text"
dimension = 768
[embedding.local]
query_prefix = "search_query: "
document_prefix = "search_document: "
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let pc = to_provider_config(&cfg);
assert_eq!(pc.provider, "ollama");
assert_eq!(pc.model.as_deref(), Some("nomic-embed-text"));
assert_eq!(pc.resolved_dimension(), 768);
assert_eq!(pc.query_prefix.as_deref(), Some("search_query: "));
assert_eq!(pc.document_prefix.as_deref(), Some("search_document: "));
}
#[test]
fn test_provider_config_dimension_override() {
let pc = cartog_rag::EmbeddingProviderConfig {
dimension: Some(1536),
..Default::default()
};
assert_eq!(pc.resolved_dimension(), 1536);
}
#[test]
fn test_provider_config_dimension_default_fallback() {
let pc = cartog_rag::EmbeddingProviderConfig::default();
assert_eq!(pc.resolved_dimension(), 384);
assert!(pc.dimension.is_none());
}
#[test]
fn test_to_provider_config_ollama_model_fallback() {
let toml_str = r#"
[embedding]
provider = "ollama"
[embedding.ollama]
model = "mxbai-embed-large"
base_url = "http://gpu:11434"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let pc = to_provider_config(&cfg);
assert_eq!(pc.provider, "ollama");
assert_eq!(pc.model.as_deref(), Some("mxbai-embed-large"));
assert_eq!(pc.base_url.as_deref(), Some("http://gpu:11434"));
}
#[test]
fn test_to_provider_config_top_level_model_wins() {
let toml_str = r#"
[embedding]
provider = "ollama"
model = "top-level-model"
[embedding.ollama]
model = "ollama-model"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let pc = to_provider_config(&cfg);
assert_eq!(pc.model.as_deref(), Some("top-level-model"),);
}
#[test]
fn test_to_provider_config_base_url_threaded() {
let toml_str = r#"
[embedding]
provider = "ollama"
[embedding.ollama]
base_url = "http://custom:11434"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let pc = to_provider_config(&cfg);
assert_eq!(pc.base_url.as_deref(), Some("http://custom:11434"));
}
#[test]
fn test_to_provider_config_no_base_url_when_local() {
let toml_str = r#"
[embedding]
provider = "local"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let pc = to_provider_config(&cfg);
assert!(pc.base_url.is_none());
}
}