use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
pub const CONFIG_PATH_VAR: &str = "VELESDB_MEMORY_CONFIG";
pub const CONFIG_FILE_NAME: &str = "velesdb-memory.toml";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive] pub enum ConfigError {
#[error("config file {path} could not be read: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("config file {path} is not valid: {message}")]
Parse {
path: PathBuf,
message: String,
},
#[error("config file {path}: {field} contains a path with the list separator in it")]
PathList {
path: PathBuf,
field: &'static str,
},
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
pub path: Option<String>,
pub quiet: Option<bool>,
pub default_ttl: Option<u64>,
#[serde(default)]
pub http: HttpConfig,
#[serde(default)]
pub embedder: EmbedderConfig,
#[serde(default)]
pub extractor: ExtractorConfig,
#[serde(default)]
pub context: ContextConfig,
#[serde(default)]
pub graph: GraphConfig,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GraphConfig {
pub autograph: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpConfig {
pub enabled: Option<bool>,
pub bind: Option<String>,
pub insecure: Option<bool>,
pub allow_remote: Option<bool>,
pub max_body_bytes: Option<u64>,
pub max_sessions: Option<u64>,
pub tls_dir: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmbedderConfig {
pub backend: Option<String>,
pub model: Option<String>,
pub url: Option<String>,
pub keep_alive: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExtractorConfig {
pub backend: Option<String>,
pub model: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContextConfig {
pub ingest_roots: Option<Vec<String>>,
}
#[derive(Debug)]
pub struct LoadedConfig {
pub path: PathBuf,
pub values: BTreeMap<String, String>,
}
#[must_use]
pub fn resolve_path(explicit: Option<&str>, store_dir: Option<&Path>) -> Option<PathBuf> {
if let Some(explicit) = explicit {
return Some(PathBuf::from(explicit));
}
if let Ok(from_env) = std::env::var(CONFIG_PATH_VAR) {
if !from_env.trim().is_empty() {
return Some(PathBuf::from(from_env));
}
}
if let Some(dir) = store_dir {
let candidate = dir.join(CONFIG_FILE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
let cwd = PathBuf::from(CONFIG_FILE_NAME);
cwd.is_file().then_some(cwd)
}
pub fn load(path: &Path) -> Result<LoadedConfig, ConfigError> {
let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
let file: ConfigFile = toml::from_str(&text).map_err(|err| ConfigError::Parse {
path: path.to_path_buf(),
message: describe_parse_failure(&err.to_string()),
})?;
Ok(LoadedConfig {
path: path.to_path_buf(),
values: file.into_env(path)?,
})
}
pub const TOKEN_HINT: &str = "an API token is read from the environment only, never from a \
file: set VELESDB_MEMORY_EMBEDDER_API_TOKEN or \
VELESDB_MEMORY_EXTRACTOR_API_TOKEN instead. A credential in a TOML is one \
`git add .` away from a public history, and no pre-commit secret scan can \
police a file it has never seen.";
fn describe_parse_failure(rendered: &str) -> String {
if rendered.contains("api_token") {
return format!("unknown field `api_token` — {TOKEN_HINT}");
}
rendered.to_owned()
}
pub struct AliasResolution {
pub value: Option<String>,
pub conflicting: bool,
}
#[must_use]
pub fn resolve_alias(canonical: Option<&str>, legacy: Option<&str>) -> AliasResolution {
AliasResolution {
conflicting: matches!((canonical, legacy), (Some(role), Some(old)) if role != old),
value: canonical.or(legacy).map(str::to_owned),
}
}
#[must_use]
pub fn alias_conflict_notice(conflicts: &[(&str, &str)]) -> Option<String> {
if conflicts.is_empty() {
return None;
}
let pairs = conflicts
.iter()
.map(|(canonical, legacy)| format!("{canonical} over {legacy}"))
.collect::<Vec<_>>()
.join(", ");
Some(format!(
"[velesdb-memory] set under two names with different values — using {pairs}. \
The role-named variable wins; unset the other to silence this."
))
}
#[must_use]
pub fn apply(values: &BTreeMap<String, String>) -> Vec<String> {
let mut applied = Vec::new();
for (key, value) in values {
if std::env::var_os(key).is_some() {
continue;
}
std::env::set_var(key, value);
applied.push(key.clone());
}
applied
}
impl ConfigFile {
fn into_env(self, path: &Path) -> Result<BTreeMap<String, String>, ConfigError> {
let mut out = BTreeMap::new();
let mut set = |key: &str, value: Option<String>| {
if let Some(value) = value {
out.insert(key.to_string(), value);
}
};
set("VELESDB_MEMORY_PATH", self.path);
set("VELESDB_MEMORY_QUIET", self.quiet.map(flag));
set(
"VELESDB_MEMORY_DEFAULT_TTL",
self.default_ttl.map(|v| v.to_string()),
);
set("VELESDB_MEMORY_HTTP", self.http.enabled.map(flag));
set("VELESDB_MEMORY_HTTP_BIND", self.http.bind);
set("VELESDB_MEMORY_HTTP_INSECURE", self.http.insecure.map(flag));
set(
"VELESDB_MEMORY_HTTP_ALLOW_REMOTE",
self.http.allow_remote.map(flag),
);
set(
"VELESDB_MEMORY_HTTP_MAX_BODY_BYTES",
self.http.max_body_bytes.map(|v| v.to_string()),
);
set(
"VELESDB_MEMORY_HTTP_MAX_SESSIONS",
self.http.max_sessions.map(|v| v.to_string()),
);
set("VELESDB_MEMORY_TLS_DIR", self.http.tls_dir);
set("VELESDB_MEMORY_EMBEDDER", self.embedder.backend);
set("VELESDB_MEMORY_EMBEDDER_MODEL", self.embedder.model);
set("VELESDB_MEMORY_EMBEDDER_URL", self.embedder.url);
set("VELESDB_MEMORY_OLLAMA_KEEP_ALIVE", self.embedder.keep_alive);
set("VELESDB_MEMORY_EXTRACTOR", self.extractor.backend);
set("VELESDB_MEMORY_EXTRACTOR_MODEL", self.extractor.model);
set("VELESDB_MEMORY_EXTRACTOR_URL", self.extractor.url);
set("VELESDB_MEMORY_AUTOGRAPH", self.graph.autograph.map(flag));
if let Some(roots) = self.context.ingest_roots {
let joined = std::env::join_paths(roots).map_err(|_| ConfigError::PathList {
path: path.to_path_buf(),
field: "context.ingest_roots",
})?;
out.insert(
"VELESDB_MEMORY_INGEST_ROOTS".to_string(),
joined.to_string_lossy().into_owned(),
);
}
Ok(out)
}
}
fn flag(value: bool) -> String {
if value { "1" } else { "0" }.to_string()
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;