use super::*;
use crate::config::repair::{is_unknown_field_error, reparse_ignoring_unknown_keys};
use std::collections::HashMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
#[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 { .. })
}
#[must_use]
pub fn consent(&self) -> IndexConsent {
match self {
ConfigLoad::Loaded { .. } | ConfigLoad::Rejected { .. } => IndexConsent::Granted,
ConfigLoad::Missing => IndexConsent::Absent,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexConsent {
Granted,
Absent,
}
impl IndexConsent {
#[must_use]
pub fn is_granted(self) -> bool {
matches!(self, IndexConsent::Granted)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexCreation {
Allowed,
RefusedNoConsent,
RefusedUnknownDbPath,
}
impl IndexCreation {
#[must_use]
pub fn resolve(
db_path: &Path,
consent: IndexConsent,
config_rejected: bool,
db_override: Option<&Path>,
) -> Self {
if config_rejected && db_override.is_none() && !db_path.exists() {
return Self::RefusedUnknownDbPath;
}
if allow_index_creation(db_path, consent) {
Self::Allowed
} else {
Self::RefusedNoConsent
}
}
#[must_use]
pub fn is_allowed(self) -> bool {
matches!(self, Self::Allowed)
}
#[must_use]
pub fn is_db_path_unknown(self) -> bool {
matches!(self, Self::RefusedUnknownDbPath)
}
}
pub(crate) const LSP_SCALAR_KEYS: &[&str] = &["max_concurrent_servers"];
fn strip_unknown_lsp_scalars(raw: &mut toml::value::Table, path: &Path) -> bool {
let Some(toml::Value::Table(lsp)) = raw.get_mut("lsp") else {
return false;
};
let bad: Vec<String> = lsp
.iter()
.filter(|(k, v)| !v.is_table() && !LSP_SCALAR_KEYS.contains(&k.as_str()))
.map(|(k, _)| k.clone())
.collect();
for key in &bad {
lsp.remove(key);
eprintln!(
"cartog: warning: unknown key '{key}' in [lsp] of {} (ignored); \
expected one of {}, or a per-language table like [lsp.rust]",
path.display(),
LSP_SCALAR_KEYS.join(", ")
);
}
!bad.is_empty()
}
pub const CONFIG_FILENAME: &str = ".cartog.toml";
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(CONFIG_FILENAME);
if candidate.exists() {
return Some(candidate);
}
if dir.join(".git").exists() {
return None;
}
if !dir.pop() {
break;
}
}
None
}
pub(crate) const KNOWN_CONFIG_SECTIONS: &[&str] = &[
"database",
"embedding",
"reranker",
"rag",
"remote",
"security",
"lsp",
"index",
"project",
"mcp",
];
pub(crate) fn unknown_sections(raw: &toml::value::Table) -> Vec<&str> {
raw.keys()
.map(String::as_str)
.filter(|k| !KNOWN_CONFIG_SECTIONS.contains(k))
.collect()
}
pub(crate) 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()
);
}
}
pub(crate) 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;
}
};
let mut text = text;
if let Ok(mut 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);
if strip_unknown_lsp_scalars(&mut raw, path) {
if let Ok(cleaned) = toml::to_string(&raw) {
text = cleaned;
}
}
}
let parsed = match toml::from_str::<CartogConfig>(&text) {
Ok(cfg) => cfg,
Err(e) if is_unknown_field_error(&e) => {
eprintln!("cartog: warning: in {}: {e}", path.display());
match reparse_ignoring_unknown_keys(&text) {
Some(cfg) => {
eprintln!(
"cartog: warning: ignoring that key; the rest of the config still applies."
);
cfg
}
None => {
eprintln!("cartog: warning: failed to parse {}", path.display());
return None;
}
}
}
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;
}
if let Err(msg) = validate_lsp_overrides(&parsed) {
eprintln!("cartog: error in {}: {msg}", path.display());
return None;
}
if let Err(msg) = to_walk_filter(&parsed) {
eprintln!("cartog: error in {}: {msg}", path.display());
return None;
}
if let Err(msg) = validate_project(&parsed) {
eprintln!("cartog: error in {}: {msg}", path.display());
return None;
}
Some(parsed)
}
pub(crate) fn validate_project(config: &CartogConfig) -> Result<(), String> {
let Some(project) = config.project.as_ref() else {
return Ok(());
};
check_project_field("name", project.name.as_deref(), PROJECT_NAME_MAX_CHARS)?;
check_project_field(
"description",
project.description.as_deref(),
PROJECT_DESCRIPTION_MAX_CHARS,
)
}
fn check_project_field(field: &str, value: Option<&str>, max_chars: usize) -> Result<(), String> {
let Some(raw) = value else {
return Ok(());
};
if raw.trim().is_empty() {
return Err(format!(
"[project] {field} is empty; omit the key instead of setting it to a blank value"
));
}
if let Some(c) = raw.chars().find(|c| c.is_control()) {
return Err(format!(
"[project] {field} contains a control character ({}); it must be a single line of plain text",
c.escape_debug()
));
}
let count = raw.trim().chars().count();
if count > max_chars {
return Err(format!(
"[project] {field} exceeds {max_chars} characters (got {count})"
));
}
Ok(())
}
fn validate_lsp_overrides(config: &CartogConfig) -> Result<(), String> {
if let Some(lsp) = config.lsp.as_ref() {
for (lang, cfg) in &lsp.langs {
if cfg.command.is_empty() {
return Err(format!(
"[lsp.{lang}] command is empty; provide at least the executable, \
e.g. command = [\"some-lsp\", \"--stdio\"]"
));
}
#[cfg(feature = "lsp")]
if !cartog_lsp::servers::has_server_spec(lang) {
return Err(format!(
"[lsp.{lang}] is not a recognized cartog language; \
overrides are keyed by language (rust, python, go, dart, ...)"
));
}
}
}
Ok(())
}
#[must_use]
pub fn to_lsp_overrides(config: &CartogConfig) -> HashMap<String, Vec<String>> {
config
.lsp
.as_ref()
.map(|lsp| {
lsp.langs
.iter()
.map(|(lang, cfg)| (lang.clone(), cfg.command.clone()))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn validate_providers(config: &CartogConfig) -> Result<(), String> {
const EMBEDDING_PROVIDERS: &[&str] = &["local", "ollama", "openai"];
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 const AUTO_INIT_ENV: &str = "CARTOG_AUTO_INIT";
#[must_use]
pub fn allow_index_creation(db_path: &Path, consent: IndexConsent) -> bool {
consent.is_granted() || db_path.exists() || auto_init_enabled()
}
fn auto_init_enabled() -> bool {
std::env::var(AUTO_INIT_ENV)
.map(|v| !v.is_empty())
.unwrap_or(false)
}
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)
}
#[derive(Debug, Clone)]
pub struct ProjectAtRoot {
pub db_path: PathBuf,
pub declared: DeclaredAtRoot,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeclaredAtRoot {
Known {
name: Option<String>,
description: Option<String>,
},
Unreadable,
}
#[must_use]
pub fn resolve_project_at(root: &Path) -> ProjectAtRoot {
let config_path = root.join(CONFIG_FILENAME);
let config = read_config(&config_path);
let rejected = config.is_none() && config_path.exists();
let project = config.as_ref().and_then(|c| c.project.as_ref());
let db_path = match config
.as_ref()
.and_then(|c| c.database.as_ref())
.and_then(|d| d.path.as_deref())
{
Some(p) => {
let expanded = expand_tilde(PathBuf::from(p));
if expanded.is_absolute() {
expanded
} else {
root.join(expanded)
}
}
None => resolve_root_db_path(root),
};
let declared = if rejected {
DeclaredAtRoot::Unreadable
} else {
DeclaredAtRoot::Known {
name: project.and_then(|p| p.name()).map(str::to_string),
description: project.and_then(|p| p.description()).map(str::to_string),
}
};
ProjectAtRoot { db_path, declared }
}
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 Ok(rest) = p.strip_prefix("~") else {
return p;
};
match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
Ok(home) => PathBuf::from(home).join(rest),
Err(_) => p,
}
}