velesdb_memory/config.rs
1//! The optional TOML configuration file: one place to set every knob.
2//!
3//! Until now the daemon was configured exclusively through eighteen
4//! `VELESDB_MEMORY_*` environment variables. That is workable for a one-shot
5//! shell invocation and painful for a long-lived daemon: a launchd plist or a
6//! systemd unit is the wrong place to keep a model name, and nothing there can
7//! carry a comment explaining *why* a value is what it is.
8//!
9//! This module adds a file without taking anything away. It resolves the
10//! config, then exports each setting into the process environment **only when
11//! that variable is not already set**. Every existing reader keeps reading the
12//! environment exactly as before, and the precedence falls out of that one
13//! rule:
14//!
15//! ```text
16//! command line > environment > config file > built-in default
17//! ```
18//!
19//! So an operator can pin a model in the file and still override it for a
20//! single run with `VELESDB_MEMORY_OLLAMA_MODEL=… velesdb-memory`, which is
21//! the behaviour anyone who has used a dotfile-driven tool expects.
22//!
23//! # Where the library is allowed to read the environment
24//!
25//! Audited 2026-08-17: every `std::env::var` in this library sits in a module
26//! whose *purpose* is resolving daemon configuration — this one,
27//! [`crate::logging`], [`crate::http`], [`crate::tls`], and
28//! [`crate::remote_endpoint`] (the single door for role endpoints and
29//! credentials). Those reads are the doors, named by their variable, and the
30//! export-into-env rule above is what lets one file feed them all. The single
31//! exception is [`crate::embedder`]'s keep-alive knob, read **per call** on
32//! purpose: it is shared by both Ollama roles and documented as a live
33//! setting, and hoisting it to construction time would silently change when
34//! it takes effect. Role modules otherwise receive values from their callers;
35//! a new `env::var` anywhere else in the library should be treated as a
36//! defect against this paragraph.
37//!
38//! The file is entirely optional: no file, or a file with only some keys, is
39//! not an error. A file that exists but cannot be parsed **is** an error —
40//! silently ignoring a malformed config is how a daemon ends up quietly
41//! running on defaults the operator believes they overrode.
42
43use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use serde::Deserialize;
47
48/// Environment variable naming the config file explicitly.
49pub const CONFIG_PATH_VAR: &str = "VELESDB_MEMORY_CONFIG";
50
51/// File name looked up in the default locations.
52pub const CONFIG_FILE_NAME: &str = "velesdb-memory.toml";
53
54/// Why a config file could not be used.
55#[derive(Debug, thiserror::Error)]
56#[non_exhaustive] // error enum, grows by nature; matching externally requires a wildcard arm
57pub enum ConfigError {
58 /// The file could not be read.
59 #[error("config file {path} could not be read: {source}")]
60 Read {
61 /// The path that failed.
62 path: PathBuf,
63 /// The underlying I/O failure.
64 source: std::io::Error,
65 },
66 /// The file is not valid TOML, or does not match the expected shape.
67 #[error("config file {path} is not valid: {message}")]
68 Parse {
69 /// The path that failed.
70 path: PathBuf,
71 /// The parser's complaint.
72 message: String,
73 },
74 /// A path list could not be joined into the platform's list syntax.
75 #[error("config file {path}: {field} contains a path with the list separator in it")]
76 PathList {
77 /// The path that failed.
78 path: PathBuf,
79 /// The offending field.
80 field: &'static str,
81 },
82}
83
84/// Top-level shape of `velesdb-memory.toml`.
85///
86/// `deny_unknown_fields` is deliberate: a typo'd key (`mdoel = "…"`) that is
87/// silently dropped leaves the operator convinced they set something they did
88/// not. Failing loudly at startup is the whole reason to have a file.
89#[derive(Debug, Default, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct ConfigFile {
92 /// Store directory (`VELESDB_MEMORY_PATH`).
93 pub path: Option<String>,
94 /// Suppress the startup banner (`VELESDB_MEMORY_QUIET`).
95 pub quiet: Option<bool>,
96 /// Default TTL in seconds applied to facts with no explicit one
97 /// (`VELESDB_MEMORY_DEFAULT_TTL`).
98 pub default_ttl: Option<u64>,
99 /// HTTP transport settings.
100 #[serde(default)]
101 pub http: HttpConfig,
102 /// Embedding backend settings.
103 #[serde(default)]
104 pub embedder: EmbedderConfig,
105 /// Extraction backend settings.
106 #[serde(default)]
107 pub extractor: ExtractorConfig,
108 /// Context-compiler settings.
109 #[serde(default)]
110 pub context: ContextConfig,
111 /// Knowledge-graph settings.
112 #[serde(default)]
113 pub graph: GraphConfig,
114}
115
116/// `[graph]` — how much structure the memory builds on its own.
117#[derive(Debug, Default, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct GraphConfig {
120 /// Let every `remember` also wire the entities, typed edges and attributes
121 /// its text states (`VELESDB_MEMORY_AUTOGRAPH`).
122 ///
123 /// Off by default. It costs one generation per `remember`, so it is a
124 /// deliberate choice, not something to inherit silently — and it needs an
125 /// `[extractor]` backend to have anything to do.
126 pub autograph: Option<bool>,
127}
128
129/// `[http]` — the streamable-HTTP transport (multi-client daemon mode).
130#[derive(Debug, Default, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct HttpConfig {
133 /// Serve over HTTP instead of stdio (`VELESDB_MEMORY_HTTP`).
134 pub enabled: Option<bool>,
135 /// Address to bind (`VELESDB_MEMORY_HTTP_BIND`).
136 pub bind: Option<String>,
137 /// Serve plaintext instead of TLS (`VELESDB_MEMORY_HTTP_INSECURE`).
138 pub insecure: Option<bool>,
139 /// Permit a non-loopback bind (`VELESDB_MEMORY_HTTP_ALLOW_REMOTE`).
140 pub allow_remote: Option<bool>,
141 /// Request body ceiling (`VELESDB_MEMORY_HTTP_MAX_BODY_BYTES`).
142 pub max_body_bytes: Option<u64>,
143 /// Concurrent session ceiling (`VELESDB_MEMORY_HTTP_MAX_SESSIONS`).
144 pub max_sessions: Option<u64>,
145 /// Directory holding the local CA and leaf certificate
146 /// (`VELESDB_MEMORY_TLS_DIR`).
147 pub tls_dir: Option<String>,
148}
149
150/// `[embedder]` — how text becomes vectors.
151///
152/// **No `api_token` field, deliberately** — see [`TOKEN_HINT`]. The two roles
153/// carry the same four settings under the same names on purpose: an operator
154/// who has configured one has configured the other.
155#[derive(Debug, Default, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct EmbedderConfig {
158 /// `hash`, `ollama` or `openai` (`VELESDB_MEMORY_EMBEDDER`).
159 pub backend: Option<String>,
160 /// Embedding model (`VELESDB_MEMORY_EMBEDDER_MODEL`).
161 pub model: Option<String>,
162 /// Base URL, origin and port, no path (`VELESDB_MEMORY_EMBEDDER_URL`).
163 pub url: Option<String>,
164 /// How long Ollama keeps the model resident
165 /// (`VELESDB_MEMORY_OLLAMA_KEEP_ALIVE`).
166 ///
167 /// The one setting here that keeps a product name, and legitimately: it is
168 /// a field of Ollama's own wire protocol, not a role-level knob an
169 /// OpenAI-compatible server would know what to do with.
170 pub keep_alive: Option<String>,
171}
172
173/// `[extractor]` — the backend that reads facts, relations and attributes out
174/// of raw text for `remember_extracted`.
175///
176/// **No `api_token` field, deliberately** — see [`TOKEN_HINT`].
177#[derive(Debug, Default, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct ExtractorConfig {
180 /// `outline`, `ollama`, `openai`, or absent for none
181 /// (`VELESDB_MEMORY_EXTRACTOR`).
182 pub backend: Option<String>,
183 /// Generative model (`VELESDB_MEMORY_EXTRACTOR_MODEL`).
184 pub model: Option<String>,
185 /// Base URL, origin and port, no path (`VELESDB_MEMORY_EXTRACTOR_URL`).
186 pub url: Option<String>,
187}
188
189/// `[context]` — the deterministic context compiler.
190#[derive(Debug, Default, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct ContextConfig {
193 /// Directories `path`-referenced fragments may be read from
194 /// (`VELESDB_MEMORY_INGEST_ROOTS`). Written as a list here and joined
195 /// into the platform's `PATH` syntax, so the file stays readable and
196 /// portable where the raw variable is neither.
197 pub ingest_roots: Option<Vec<String>>,
198}
199
200/// Where the config file was found, and what it asked for.
201#[derive(Debug)]
202pub struct LoadedConfig {
203 /// The file that was read.
204 pub path: PathBuf,
205 /// The variables it defines, in `VELESDB_MEMORY_*` form.
206 pub values: BTreeMap<String, String>,
207}
208
209/// Resolve the config file path: an explicit `--config`, then
210/// [`CONFIG_PATH_VAR`], then `<store>/velesdb-memory.toml`, then
211/// `./velesdb-memory.toml`.
212///
213/// The store directory is checked before the working directory on purpose: a
214/// daemon's working directory is whatever launchd or systemd happened to give
215/// it, which is not a location an operator would think to put a file in.
216#[must_use]
217pub fn resolve_path(explicit: Option<&str>, store_dir: Option<&Path>) -> Option<PathBuf> {
218 if let Some(explicit) = explicit {
219 return Some(PathBuf::from(explicit));
220 }
221 if let Ok(from_env) = std::env::var(CONFIG_PATH_VAR) {
222 if !from_env.trim().is_empty() {
223 return Some(PathBuf::from(from_env));
224 }
225 }
226 if let Some(dir) = store_dir {
227 let candidate = dir.join(CONFIG_FILE_NAME);
228 if candidate.is_file() {
229 return Some(candidate);
230 }
231 }
232 let cwd = PathBuf::from(CONFIG_FILE_NAME);
233 cwd.is_file().then_some(cwd)
234}
235
236/// Read and parse `path` into the `VELESDB_MEMORY_*` variables it defines.
237///
238/// # Errors
239/// Returns [`ConfigError`] if the file cannot be read or is not valid TOML.
240pub fn load(path: &Path) -> Result<LoadedConfig, ConfigError> {
241 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
242 path: path.to_path_buf(),
243 source,
244 })?;
245 let file: ConfigFile = toml::from_str(&text).map_err(|err| ConfigError::Parse {
246 path: path.to_path_buf(),
247 message: describe_parse_failure(&err.to_string()),
248 })?;
249 Ok(LoadedConfig {
250 path: path.to_path_buf(),
251 values: file.into_env(path)?,
252 })
253}
254
255/// Where an API token belongs, quoted verbatim by the refusal that rejects one
256/// found in the file.
257///
258/// `deny_unknown_fields` already refuses an `api_token` key, since no section
259/// declares one. What it cannot do is say where the operator should put the
260/// token they legitimately have — so the refusal is rewritten to carry this.
261pub const TOKEN_HINT: &str = "an API token is read from the environment only, never from a \
262 file: set VELESDB_MEMORY_EMBEDDER_API_TOKEN or \
263 VELESDB_MEMORY_EXTRACTOR_API_TOKEN instead. A credential in a TOML is one \
264 `git add .` away from a public history, and no pre-commit secret scan can \
265 police a file it has never seen.";
266
267/// Render a TOML parse failure, redacting it when it concerns a credential.
268///
269/// `toml`'s own error quotes the offending source line back — which is exactly
270/// the right thing for `mdoel = "bge-m3"` and exactly the wrong thing for
271/// `api_token = "sk-…"`: the daemon would print the secret to stderr, where a
272/// launch agent's log file keeps it. So a failure naming `api_token` loses the
273/// snippet and gains [`TOKEN_HINT`]; every other failure is untouched.
274fn describe_parse_failure(rendered: &str) -> String {
275 if rendered.contains("api_token") {
276 return format!("unknown field `api_token` — {TOKEN_HINT}");
277 }
278 rendered.to_owned()
279}
280
281/// One setting reachable under two environment-variable names: the canonical,
282/// role-named one and a legacy alias kept working for compatibility.
283///
284/// See [`resolve_alias`] for the precedence rule.
285pub struct AliasResolution {
286 /// The value to use, or `None` when neither name is set.
287 pub value: Option<String>,
288 /// Both names are set to **different** values. The caller is expected to
289 /// gather these and emit a single [`alias_conflict_notice`].
290 pub conflicting: bool,
291}
292
293/// Resolve a setting the caller can name two ways: canonical wins, the legacy
294/// alias is the fallback (C1).
295///
296/// The embedding role's URL and model were named after a *product*
297/// (`VELESDB_MEMORY_OLLAMA_URL`) while the extraction role's were named after
298/// the *role* (`VELESDB_MEMORY_EXTRACTOR_URL`). Once a non-Ollama backend can
299/// serve either role, a variable that says `OLLAMA` while pointing at oMLX is
300/// a lie the operator has to hold in their head. This closes the asymmetry
301/// without breaking a single existing setup: the alias keeps working, and only
302/// a genuine disagreement between the two is worth a word.
303///
304/// Canonical wins **whatever the source** — including a role-named value that
305/// came from the config file against a legacy one exported by the shell, which
306/// is the one case where this rule and [`apply`]'s "environment outranks the
307/// file" point different ways. That case is not silent: it is precisely what
308/// [`alias_conflict_notice`] reports.
309#[must_use]
310pub fn resolve_alias(canonical: Option<&str>, legacy: Option<&str>) -> AliasResolution {
311 AliasResolution {
312 conflicting: matches!((canonical, legacy), (Some(role), Some(old)) if role != old),
313 value: canonical.or(legacy).map(str::to_owned),
314 }
315}
316
317/// One line naming every variable whose legacy alias disagrees with it, or
318/// `None` when nothing disagrees.
319///
320/// **One notice, however many settings conflict.** A warning per variable is
321/// how a startup log becomes noise an operator learns to scroll past, and the
322/// operator's next action is the same for all of them. This is also
323/// deliberately not a deprecation warning: the aliases are supported, and
324/// shouting at someone whose setup works is how a message gets filtered out
325/// before the day it finally matters.
326#[must_use]
327pub fn alias_conflict_notice(conflicts: &[(&str, &str)]) -> Option<String> {
328 if conflicts.is_empty() {
329 return None;
330 }
331 let pairs = conflicts
332 .iter()
333 .map(|(canonical, legacy)| format!("{canonical} over {legacy}"))
334 .collect::<Vec<_>>()
335 .join(", ");
336 Some(format!(
337 "[velesdb-memory] set under two names with different values — using {pairs}. \
338 The role-named variable wins; unset the other to silence this."
339 ))
340}
341
342/// Export `values` into the process environment, skipping any variable that is
343/// already set. Returns the names actually applied, in order.
344///
345/// The skip is the precedence rule: the environment was set by whoever
346/// launched the process, and that intent outranks a file on disk.
347#[must_use]
348pub fn apply(values: &BTreeMap<String, String>) -> Vec<String> {
349 let mut applied = Vec::new();
350 for (key, value) in values {
351 if std::env::var_os(key).is_some() {
352 continue;
353 }
354 std::env::set_var(key, value);
355 applied.push(key.clone());
356 }
357 applied
358}
359
360impl ConfigFile {
361 /// Flatten the typed sections into the `VELESDB_MEMORY_*` variables the
362 /// rest of the binary already reads.
363 fn into_env(self, path: &Path) -> Result<BTreeMap<String, String>, ConfigError> {
364 let mut out = BTreeMap::new();
365 let mut set = |key: &str, value: Option<String>| {
366 if let Some(value) = value {
367 out.insert(key.to_string(), value);
368 }
369 };
370 set("VELESDB_MEMORY_PATH", self.path);
371 set("VELESDB_MEMORY_QUIET", self.quiet.map(flag));
372 set(
373 "VELESDB_MEMORY_DEFAULT_TTL",
374 self.default_ttl.map(|v| v.to_string()),
375 );
376
377 set("VELESDB_MEMORY_HTTP", self.http.enabled.map(flag));
378 set("VELESDB_MEMORY_HTTP_BIND", self.http.bind);
379 set("VELESDB_MEMORY_HTTP_INSECURE", self.http.insecure.map(flag));
380 set(
381 "VELESDB_MEMORY_HTTP_ALLOW_REMOTE",
382 self.http.allow_remote.map(flag),
383 );
384 set(
385 "VELESDB_MEMORY_HTTP_MAX_BODY_BYTES",
386 self.http.max_body_bytes.map(|v| v.to_string()),
387 );
388 set(
389 "VELESDB_MEMORY_HTTP_MAX_SESSIONS",
390 self.http.max_sessions.map(|v| v.to_string()),
391 );
392 set("VELESDB_MEMORY_TLS_DIR", self.http.tls_dir);
393
394 set("VELESDB_MEMORY_EMBEDDER", self.embedder.backend);
395 // The role-named variables, not the `VELESDB_MEMORY_OLLAMA_*` aliases:
396 // the section is named after the role, so what it writes should be too.
397 // The aliases stay readable from the environment (see
398 // [`resolve_alias`]) for setups that already export them.
399 set("VELESDB_MEMORY_EMBEDDER_MODEL", self.embedder.model);
400 set("VELESDB_MEMORY_EMBEDDER_URL", self.embedder.url);
401 set("VELESDB_MEMORY_OLLAMA_KEEP_ALIVE", self.embedder.keep_alive);
402
403 set("VELESDB_MEMORY_EXTRACTOR", self.extractor.backend);
404 set("VELESDB_MEMORY_EXTRACTOR_MODEL", self.extractor.model);
405 set("VELESDB_MEMORY_EXTRACTOR_URL", self.extractor.url);
406
407 set("VELESDB_MEMORY_AUTOGRAPH", self.graph.autograph.map(flag));
408
409 if let Some(roots) = self.context.ingest_roots {
410 let joined = std::env::join_paths(roots).map_err(|_| ConfigError::PathList {
411 path: path.to_path_buf(),
412 field: "context.ingest_roots",
413 })?;
414 out.insert(
415 "VELESDB_MEMORY_INGEST_ROOTS".to_string(),
416 joined.to_string_lossy().into_owned(),
417 );
418 }
419 Ok(out)
420 }
421}
422
423/// Render a boolean the way every reader in the binary tests for it: the
424/// truthy form is the exact string `"1"`. `false` becomes `"0"` rather than
425/// being omitted, so writing `enabled = false` in the file genuinely holds the
426/// setting off instead of falling through to a default that might be on.
427fn flag(value: bool) -> String {
428 if value { "1" } else { "0" }.to_string()
429}
430
431#[cfg(test)]
432#[path = "config_tests.rs"]
433mod tests;