use serde::Deserialize;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize)]
pub struct CartogConfig {
pub database: Option<DatabaseConfig>,
pub embedding: Option<EmbeddingConfig>,
pub reranker: Option<RerankerConfig>,
pub rag: Option<RagConfig>,
pub remote: Option<RemoteConfig>,
pub security: Option<SecurityConfig>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct SecurityConfig {
pub redact_secrets: Option<bool>,
}
impl SecurityConfig {
#[must_use]
pub fn redact_secrets(&self) -> bool {
self.redact_secrets.unwrap_or(true)
}
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(not(feature = "remote-s3"), allow(dead_code))]
pub struct RemoteConfig {
pub url: Option<String>,
pub region: Option<String>,
pub endpoint: Option<String>,
pub path_style: Option<bool>,
}
const CREDENTIAL_KEY_PREFIXES: &[&str] = &["aws_", "access_", "secret_"];
const CREDENTIAL_KEYS: &[&str] = &[
"access_key",
"secret_key",
"credentials",
"token",
"session_token",
"password",
];
fn validate_remote_no_credentials(table: &toml::value::Table) -> Result<(), String> {
fn walk(prefix: &str, val: &toml::Value) -> Result<(), String> {
match val {
toml::Value::Table(t) => {
for (k, v) in t {
let lower = k.to_lowercase();
if CREDENTIAL_KEYS.iter().any(|ck| lower == *ck)
|| CREDENTIAL_KEY_PREFIXES.iter().any(|p| lower.starts_with(p))
{
return Err(format!(
"{prefix}.{k} looks like a credential — cartog does not read \
credentials from .cartog.toml. Use the AWS environment chain \
instead (AWS_ACCESS_KEY_ID / AWS_PROFILE / IMDS)."
));
}
walk(&format!("{prefix}.{k}"), v)?;
}
}
toml::Value::Array(arr) => {
for (i, v) in arr.iter().enumerate() {
walk(&format!("{prefix}[{i}]"), v)?;
}
}
_ => {}
}
Ok(())
}
walk("[remote]", &toml::Value::Table(table.clone()))
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct RagConfig {
pub retrieval_multiplier: Option<u32>,
pub retrieval_floor: Option<u32>,
pub rerank_max: Option<u32>,
pub rerank_min: Option<u32>,
}
impl RagConfig {
pub fn to_search_tuning(&self) -> cartog_rag::search::SearchTuning {
let d = cartog_rag::search::SearchTuning::default();
let retrieval_multiplier = self
.retrieval_multiplier
.unwrap_or(d.retrieval_multiplier)
.max(1);
let retrieval_floor = self.retrieval_floor.unwrap_or(d.retrieval_floor).max(1);
let rerank_max = self.rerank_max.unwrap_or(d.rerank_max);
let rerank_min = self.rerank_min.unwrap_or(d.rerank_min);
let rerank_min = rerank_min.min(rerank_max);
cartog_rag::search::SearchTuning {
retrieval_multiplier,
retrieval_floor,
rerank_max,
rerank_min,
}
}
}
#[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>,
pub intra_threads: Option<usize>,
}
#[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 model: 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_redaction_config(config: &CartogConfig) -> cartog_indexer::RedactionConfig {
let enabled = config
.security
.as_ref()
.map_or(true, SecurityConfig::redact_secrets);
cartog_indexer::RedactionConfig { enabled }
}
pub fn to_provider_config(config: &CartogConfig) -> cartog_rag::EmbeddingProviderConfig {
let reranker_provider = config
.reranker
.as_ref()
.map(|r| r.provider().to_string())
.unwrap_or_else(|| DEFAULT_RERANKER_PROVIDER.to_string());
let reranker_model = config.reranker.as_ref().and_then(|r| r.model.clone());
match &config.embedding {
Some(embed) => {
let (query_prefix, document_prefix, intra_threads) = match &embed.local {
Some(local) => (
local.query_prefix.clone(),
local.document_prefix.clone(),
local.intra_threads,
),
None => (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,
reranker_model,
intra_threads,
}
}
None => cartog_rag::EmbeddingProviderConfig {
reranker_provider,
reranker_model,
..Default::default()
},
}
}
#[allow(clippy::large_enum_variant)]
pub enum ConfigLoad {
Loaded { config: CartogConfig, path: PathBuf },
Missing,
Rejected { path: PathBuf },
}
impl ConfigLoad {
pub fn config_or_default(self) -> CartogConfig {
match self {
ConfigLoad::Loaded { config, .. } => config,
_ => CartogConfig::default(),
}
}
pub fn path(&self) -> Option<&Path> {
match self {
ConfigLoad::Loaded { path, .. } | ConfigLoad::Rejected { path } => Some(path),
ConfigLoad::Missing => None,
}
}
pub fn is_rejected(&self) -> bool {
matches!(self, ConfigLoad::Rejected { .. })
}
}
pub fn load_config() -> ConfigLoad {
match local_config_path() {
Some(p) => match read_config(&p) {
Some(config) => ConfigLoad::Loaded { config, path: p },
None => ConfigLoad::Rejected { path: p },
},
None => ConfigLoad::Missing,
}
}
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
}
const KNOWN_CONFIG_SECTIONS: &[&str] = &[
"database",
"embedding",
"reranker",
"rag",
"remote",
"security",
];
fn unknown_sections(raw: &toml::value::Table) -> Vec<&str> {
raw.keys()
.map(String::as_str)
.filter(|k| !KNOWN_CONFIG_SECTIONS.contains(k))
.collect()
}
fn config_diagnostics_visible() -> bool {
std::io::stderr().is_terminal()
}
fn warn_unknown_sections(raw: &toml::value::Table, path: &Path) {
if !config_diagnostics_visible() {
return;
}
for key in unknown_sections(raw) {
eprintln!(
"cartog: warning: unknown config key '{key}' in {} (ignored)",
path.display()
);
}
}
fn read_config(path: &Path) -> Option<CartogConfig> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
eprintln!("cartog: error reading {}: {e}", path.display());
return None;
}
};
if let Ok(raw) = toml::from_str::<toml::value::Table>(&text) {
if let Some(toml::Value::Table(remote)) = raw.get("remote") {
if let Err(msg) = validate_remote_no_credentials(remote) {
eprintln!("cartog: error in {}: {msg}", path.display());
return None;
}
}
warn_unknown_sections(&raw, path);
}
let parsed = match toml::from_str::<CartogConfig>(&text) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("cartog: warning: failed to parse {}: {e}", path.display());
return None;
}
};
if let Some(remote) = parsed.remote.as_ref() {
if let Err(msg) = validate_endpoint(remote.endpoint.as_deref()) {
eprintln!("cartog: error in {}: {msg}", path.display());
return None;
}
}
if let Err(msg) = validate_providers(&parsed) {
eprintln!("cartog: error in {}: {msg}", path.display());
return None;
}
Some(parsed)
}
fn validate_providers(config: &CartogConfig) -> Result<(), String> {
const EMBEDDING_PROVIDERS: &[&str] = &["local", "ollama"];
const RERANKER_PROVIDERS: &[&str] = &["local", "none"];
if let Some(p) = config
.embedding
.as_ref()
.and_then(|e| e.provider.as_deref())
{
if !EMBEDDING_PROVIDERS.contains(&p) {
return Err(format!(
"unknown embedding provider '{p}'; supported: {}",
EMBEDDING_PROVIDERS.join(", ")
));
}
}
if let Some(p) = config.reranker.as_ref().and_then(|r| r.provider.as_deref()) {
if !RERANKER_PROVIDERS.contains(&p) {
return Err(format!(
"unknown reranker provider '{p}'; supported: {}",
RERANKER_PROVIDERS.join(", ")
));
}
}
Ok(())
}
fn validate_endpoint(endpoint: Option<&str>) -> Result<(), String> {
let ep = match endpoint {
Some(s) if !s.is_empty() => s,
_ => return Ok(()),
};
let after_scheme = ep.split_once("://").map(|x| x.1).unwrap_or(ep);
let authority = after_scheme
.split('/')
.next()
.unwrap_or(after_scheme)
.split('?')
.next()
.unwrap_or(after_scheme)
.split('#')
.next()
.unwrap_or(after_scheme);
if authority.contains('@') {
return Err(format!(
"[remote].endpoint embeds credentials in its URL ({ep:?}) — cartog \
does not accept credentials in config. Move them to the AWS \
environment chain (AWS_ACCESS_KEY_ID / AWS_PROFILE / IMDS) and \
use a plain endpoint URL."
));
}
Ok(())
}
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 resolve_root_db_path(&dir);
}
if !dir.pop() {
break;
}
}
}
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
resolve_root_db_path(&cwd)
}
fn resolve_root_db_path(root: &Path) -> PathBuf {
let new_path = root.join(cartog_db::DB_DIR).join(cartog_db::DB_FILENAME);
let legacy = root.join(cartog_db::LEGACY_DB_FILE);
if new_path.exists() {
if legacy.exists() {
warn_orphan_legacy_once(&legacy);
}
return new_path;
}
if legacy.exists() {
warn_legacy_db_once(&legacy);
return legacy;
}
new_path
}
fn warn_legacy_db_once(path: &Path) {
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED: AtomicBool = AtomicBool::new(false);
if WARNED.swap(true, Ordering::Relaxed) {
return;
}
if !config_diagnostics_visible() {
return;
}
eprintln!(
"cartog: using legacy database at {}; run `cartog self migrate-db` to move it into .cartog/",
path.display()
);
}
fn warn_orphan_legacy_once(path: &Path) {
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED: AtomicBool = AtomicBool::new(false);
if WARNED.swap(true, Ordering::Relaxed) {
return;
}
if !config_diagnostics_visible() {
return;
}
eprintln!(
"cartog: found legacy database at {} alongside the new layout; the legacy file is ignored",
path.display()
);
}
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 unknown_sections_flags_typos_but_not_known_keys() {
let raw: toml::value::Table =
toml::from_str("[embeddings]\nprovider = \"ollama\"\n[database]\npath = \"x\"\n")
.unwrap();
let unknown = unknown_sections(&raw);
assert_eq!(unknown, vec!["embeddings"]);
}
#[test]
fn unknown_sections_empty_for_all_known() {
let raw: toml::value::Table =
toml::from_str("[database]\npath = \"x\"\n[embedding]\nprovider = \"local\"\n")
.unwrap();
assert!(unknown_sections(&raw).is_empty());
}
#[test]
fn validate_providers_accepts_known_values() {
let config: CartogConfig =
toml::from_str("[embedding]\nprovider = \"ollama\"\n[reranker]\nprovider = \"none\"\n")
.unwrap();
assert!(validate_providers(&config).is_ok());
}
#[test]
fn validate_providers_accepts_absent_provider() {
let config = CartogConfig::default();
assert!(validate_providers(&config).is_ok());
}
#[test]
fn validate_providers_rejects_unknown_embedding_provider() {
let config: CartogConfig = toml::from_str("[embedding]\nprovider = \"ollma\"\n").unwrap();
let err = validate_providers(&config).unwrap_err();
assert!(
err.contains("ollma"),
"error should name the bad value: {err}"
);
}
#[test]
fn validate_providers_rejects_unknown_reranker_provider() {
let config: CartogConfig = toml::from_str("[reranker]\nprovider = \"bogus\"\n").unwrap();
let err = validate_providers(&config).unwrap_err();
assert!(
err.contains("bogus"),
"error should name the bad value: {err}"
);
}
#[test]
fn read_config_rejects_unknown_provider() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "[embedding]\nprovider = \"ollma\"\n").unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[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 redact_secrets_defaults_true_when_absent() {
let cfg = CartogConfig::default();
assert!(to_redaction_config(&cfg).enabled);
}
#[test]
fn redact_secrets_can_be_disabled_via_config() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "[security]\nredact_secrets = false\n").unwrap();
let cfg = read_config(&cfg_path).expect("should parse");
assert!(!to_redaction_config(&cfg).enabled);
}
#[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_remote_config_valid_minimal() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(
&cfg_path,
r#"[remote]
url = "s3://team-bucket/cartog/main"
region = "us-east-1"
"#,
)
.unwrap();
let cfg = read_config(&cfg_path).expect("should parse");
let remote = cfg.remote.expect("remote section parsed");
assert_eq!(remote.url.as_deref(), Some("s3://team-bucket/cartog/main"));
assert_eq!(remote.region.as_deref(), Some("us-east-1"));
assert_eq!(remote.path_style, None);
}
#[test]
fn test_remote_config_full_minio_shape() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(
&cfg_path,
r#"[remote]
url = "s3://b/k"
region = "us-east-1"
endpoint = "https://minio.local"
path_style = true
"#,
)
.unwrap();
let cfg = read_config(&cfg_path).expect("should parse");
let r = cfg.remote.unwrap();
assert_eq!(r.endpoint.as_deref(), Some("https://minio.local"));
assert_eq!(r.path_style, Some(true));
}
#[test]
fn test_remote_config_rejects_credential_keys() {
for bad in [
"access_key = \"AKIA...\"",
"secret_key = \"...\"",
"credentials = \"...\"",
"token = \"...\"",
"session_token = \"...\"",
"password = \"...\"",
"aws_access_key_id = \"...\"",
"AWS_SECRET = \"...\"",
] {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(&cfg_path, format!("[remote]\nurl = \"s3://b/k\"\n{bad}\n")).unwrap();
assert!(
read_config(&cfg_path).is_none(),
"should reject credential key: {bad}"
);
}
}
#[test]
fn test_remote_config_rejects_nested_credential_keys() {
for bad_section in [
"[remote.aws]\naccess_key = \"AKIA...\"\n",
"[remote.creds]\nsecret_key = \"...\"\n",
"[remote.minio]\naws_session_token = \"...\"\n",
] {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(
&cfg_path,
format!("[remote]\nurl = \"s3://b/k\"\n{bad_section}"),
)
.unwrap();
assert!(
read_config(&cfg_path).is_none(),
"should reject nested credential: {bad_section}"
);
}
}
#[test]
fn test_remote_config_rejects_credential_prefixes() {
for bad in [
"access_token_v2 = \"...\"",
"secret_value = \"...\"",
"aws_role_arn = \"...\"",
] {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(&cfg_path, format!("[remote]\nurl = \"s3://b/k\"\n{bad}\n")).unwrap();
assert!(
read_config(&cfg_path).is_none(),
"should reject prefix-matched credential key: {bad}"
);
}
}
#[test]
fn test_remote_config_rejects_unknown_field() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(
&cfg_path,
"[remote]\nurl = \"s3://b/k\"\npathstyle = true\n",
)
.unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
fn test_remote_config_rejects_endpoint_with_userinfo() {
for bad in [
"http://AKIA:secret@minio.local",
"https://user@s3.example.com",
"AKIA:secret@host:9000",
"https://user:pass@host/path",
] {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(
&cfg_path,
format!("[remote]\nurl = \"s3://b/k\"\nendpoint = \"{bad}\"\n"),
)
.unwrap();
assert!(
read_config(&cfg_path).is_none(),
"should reject endpoint with userinfo: {bad}"
);
}
}
#[test]
fn test_remote_config_accepts_clean_endpoints() {
for ok in [
"https://s3.us-east-1.amazonaws.com",
"https://minio.example.com:9000",
"https://r2.cloudflarestorage.com/path",
"http://localhost:4566",
] {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(
&cfg_path,
format!("[remote]\nurl = \"s3://b/k\"\nendpoint = \"{ok}\"\n"),
)
.unwrap();
assert!(
read_config(&cfg_path).is_some(),
"should accept clean endpoint: {ok}"
);
}
}
#[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 canonical = dir.path().canonicalize().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,
canonical
.join(cartog_db::DB_DIR)
.join(cartog_db::DB_FILENAME)
);
}
#[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_DIR)
.join(cartog_db::DB_FILENAME)
);
}
#[test]
#[serial]
fn test_resolve_prefers_new_layout_over_legacy() {
let dir = tempfile::TempDir::new().unwrap();
let canonical_root = dir.path().canonicalize().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::create_dir(dir.path().join(cartog_db::DB_DIR)).unwrap();
std::fs::write(
dir.path()
.join(cartog_db::DB_DIR)
.join(cartog_db::DB_FILENAME),
b"",
)
.unwrap();
std::fs::write(dir.path().join(cartog_db::LEGACY_DB_FILE), b"").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,
canonical_root
.join(cartog_db::DB_DIR)
.join(cartog_db::DB_FILENAME)
);
}
#[test]
#[serial]
fn test_resolve_falls_back_to_legacy_db_file() {
let dir = tempfile::TempDir::new().unwrap();
let canonical_root = dir.path().canonicalize().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(cartog_db::LEGACY_DB_FILE), b"").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, canonical_root.join(cartog_db::LEGACY_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_reranker_model_defaults_to_none_in_config() {
let cfg = RerankerConfig::default();
assert!(cfg.model.is_none());
}
#[test]
fn test_to_provider_config_reranker_model_from_toml() {
let toml_str = r#"
[reranker]
model = "BAAI/bge-reranker-base"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let pc = to_provider_config(&cfg);
assert_eq!(pc.reranker_model.as_deref(), Some("BAAI/bge-reranker-base"));
}
#[test]
fn test_to_provider_config_reranker_model_default_is_none() {
let cfg = CartogConfig::default();
let pc = to_provider_config(&cfg);
assert!(pc.reranker_model.is_none());
}
#[test]
fn test_reranker_provider_none_keeps_model_inert() {
let toml_str = r#"
[reranker]
provider = "none"
model = "BAAI/bge-reranker-base"
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let pc = to_provider_config(&cfg);
assert_eq!(pc.reranker_provider, "none");
assert_eq!(pc.reranker_model.as_deref(), Some("BAAI/bge-reranker-base"));
}
#[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());
}
#[test]
fn to_search_tuning_clamps_zero_retrieval() {
let cfg = RagConfig {
retrieval_multiplier: Some(0),
retrieval_floor: Some(0),
rerank_max: None,
rerank_min: None,
};
let t = cfg.to_search_tuning();
assert_eq!(t.retrieval_multiplier, 1);
assert_eq!(t.retrieval_floor, 1);
}
#[test]
fn to_search_tuning_caps_rerank_min_at_max() {
let cfg = RagConfig {
retrieval_multiplier: None,
retrieval_floor: None,
rerank_max: Some(10),
rerank_min: Some(50),
};
let t = cfg.to_search_tuning();
assert_eq!(t.rerank_max, 10);
assert_eq!(t.rerank_min, 10);
}
#[test]
fn to_search_tuning_passes_valid_values() {
let cfg = RagConfig {
retrieval_multiplier: Some(5),
retrieval_floor: Some(40),
rerank_max: Some(100),
rerank_min: Some(10),
};
let t = cfg.to_search_tuning();
assert_eq!(t.retrieval_multiplier, 5);
assert_eq!(t.retrieval_floor, 40);
assert_eq!(t.rerank_max, 100);
assert_eq!(t.rerank_min, 10);
}
}