use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default)]
pub struct Loaded {
pub effective: Config,
pub user: Config,
pub project: Config,
pub user_path: Option<PathBuf>,
pub project_path: Option<PathBuf>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
pub models: ModelsConfig,
pub infer: InferConfig,
pub duplicates: DuplicatesConfig,
pub ingest: IngestConfig,
pub serve: ServeConfig,
pub debt: DebtConfig,
pub paths: PathsConfig,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct DebtConfig {
pub ignore: Option<Vec<String>>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct PathsConfig {
pub model_store: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct ModelsConfig {
pub embedding: Option<String>,
pub generative: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct IngestConfig {
pub prose: Option<bool>,
pub pdf: Option<bool>,
pub ocr: Option<bool>,
pub vision: Option<bool>,
pub audio: Option<bool>,
}
impl IngestConfig {
#[must_use]
pub fn resolve(&self) -> rto_graph::IngestConfig {
let default = rto_graph::IngestConfig::default();
rto_graph::IngestConfig {
prose: self.prose.unwrap_or(default.prose),
pdf: self.pdf.unwrap_or(default.pdf),
ocr: self.ocr.unwrap_or(default.ocr),
vision: self.vision.unwrap_or(default.vision),
audio: self.audio.unwrap_or(default.audio),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct ServeConfig {
pub addr: Option<String>,
pub models: Option<Vec<String>>,
pub tools: Option<bool>,
pub memory_budget_mb: Option<u64>,
pub tls_cert: Option<String>,
pub tls_key: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct InferConfig {
pub min_confidence: Option<f64>,
pub top_k: Option<usize>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct DuplicatesConfig {
pub min_similarity: Option<f64>,
pub limit: Option<usize>,
}
impl Config {
fn overlaid_with(&self, over: &Config) -> Config {
Config {
models: ModelsConfig {
embedding: over
.models
.embedding
.clone()
.or(self.models.embedding.clone()),
generative: over
.models
.generative
.clone()
.or(self.models.generative.clone()),
},
infer: InferConfig {
min_confidence: over.infer.min_confidence.or(self.infer.min_confidence),
top_k: over.infer.top_k.or(self.infer.top_k),
},
duplicates: DuplicatesConfig {
min_similarity: over
.duplicates
.min_similarity
.or(self.duplicates.min_similarity),
limit: over.duplicates.limit.or(self.duplicates.limit),
},
ingest: IngestConfig {
prose: over.ingest.prose.or(self.ingest.prose),
pdf: over.ingest.pdf.or(self.ingest.pdf),
ocr: over.ingest.ocr.or(self.ingest.ocr),
vision: over.ingest.vision.or(self.ingest.vision),
audio: over.ingest.audio.or(self.ingest.audio),
},
serve: ServeConfig {
addr: over.serve.addr.clone().or(self.serve.addr.clone()),
models: over.serve.models.clone().or(self.serve.models.clone()),
tools: over.serve.tools.or(self.serve.tools),
memory_budget_mb: over.serve.memory_budget_mb.or(self.serve.memory_budget_mb),
tls_cert: over.serve.tls_cert.clone().or(self.serve.tls_cert.clone()),
tls_key: over.serve.tls_key.clone().or(self.serve.tls_key.clone()),
},
debt: DebtConfig {
ignore: over.debt.ignore.clone().or(self.debt.ignore.clone()),
},
paths: PathsConfig {
model_store: over
.paths
.model_store
.clone()
.or(self.paths.model_store.clone()),
},
}
}
}
pub fn load(cwd: &Path) -> anyhow::Result<Loaded> {
let user_path = user_config_path().filter(|p| p.is_file());
let project_path = find_project_config(cwd);
load_from(user_path, project_path)
}
fn load_from(user_path: Option<PathBuf>, project_path: Option<PathBuf>) -> anyhow::Result<Loaded> {
let user = read_config(user_path.as_deref())?;
let project = read_config(project_path.as_deref())?;
let effective = user.overlaid_with(&project);
Ok(Loaded {
effective,
user,
project,
user_path,
project_path,
})
}
fn read_config(path: Option<&Path>) -> anyhow::Result<Config> {
let Some(path) = path else {
return Ok(Config::default());
};
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading config {}: {e}", path.display()))?;
toml::from_str(&text).map_err(|e| anyhow::anyhow!("parsing config {}: {e}", path.display()))
}
fn user_config_path() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("ROTEIRO_HOME") {
return Some(PathBuf::from(home).join("config.toml"));
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".roteiro").join("config.toml"))
}
fn find_project_config(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join(".git").exists() {
let candidate = d.join("roteiro.toml");
return candidate.is_file().then_some(candidate);
}
dir = d.parent();
}
None
}
#[cfg(test)]
mod tests {
use super::{Config, find_project_config, load_from};
#[test]
fn project_config_is_repo_root_bounded() {
let root = std::env::temp_dir().join(format!("roteiro-disc-{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
let repo = root.join("repo");
let sub = repo.join("crate").join("src");
std::fs::create_dir_all(&sub).expect("mkdir");
std::fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
std::fs::write(root.join("roteiro.toml"), "[infer]\ntop_k = 1\n").expect("write outside");
assert_eq!(
find_project_config(&sub),
None,
"no repo-root config → None, never the parent-dir one"
);
let at_root = repo.join("roteiro.toml");
std::fs::write(&at_root, "[infer]\ntop_k = 2\n").expect("write root");
assert_eq!(find_project_config(&sub), Some(at_root));
assert_eq!(find_project_config(&root), None);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn config_layering_precedence_and_errors() {
let dir = std::env::temp_dir().join(format!("roteiro-cfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let user = dir.join("config.toml");
let project = dir.join("roteiro.toml");
let loaded = load_from(None, None).expect("load");
assert_eq!(loaded.effective, Config::default());
assert!(loaded.project_path.is_none());
std::fs::write(
&user,
"[infer]\nmin_confidence = 0.3\ntop_k = 9\n[models]\nembedding = \"bge-base-en-v1.5\"\n",
)
.expect("write user");
std::fs::write(&project, "[infer]\nmin_confidence = 0.7\n").expect("write project");
let loaded = load_from(Some(user.clone()), Some(project.clone())).expect("load");
assert_eq!(loaded.effective.infer.min_confidence, Some(0.7));
assert_eq!(loaded.effective.infer.top_k, Some(9));
assert_eq!(
loaded.effective.models.embedding.as_deref(),
Some("bge-base-en-v1.5")
);
assert_eq!(loaded.user.infer.min_confidence, Some(0.3));
assert_eq!(loaded.project.infer.min_confidence, Some(0.7));
std::fs::write(&project, "[future]\nwhatever = true\n[infer]\ntop_k = 2\n").expect("write");
let loaded = load_from(None, Some(project.clone())).expect("unknown keys ignored");
assert_eq!(loaded.effective.infer.top_k, Some(2));
std::fs::write(&project, "[infer]\nmin_confidence = = =\n").expect("write");
assert!(
load_from(None, Some(project)).is_err(),
"malformed TOML must error"
);
std::fs::remove_dir_all(&dir).ok();
}
}