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//! The file is entirely optional: no file, or a file with only some keys, is
24//! not an error. A file that exists but cannot be parsed **is** an error —
25//! silently ignoring a malformed config is how a daemon ends up quietly
26//! running on defaults the operator believes they overrode.
27
28use std::collections::BTreeMap;
29use std::path::{Path, PathBuf};
30
31use serde::Deserialize;
32
33/// Environment variable naming the config file explicitly.
34pub const CONFIG_PATH_VAR: &str = "VELESDB_MEMORY_CONFIG";
35
36/// File name looked up in the default locations.
37pub const CONFIG_FILE_NAME: &str = "velesdb-memory.toml";
38
39/// Why a config file could not be used.
40#[derive(Debug, thiserror::Error)]
41pub enum ConfigError {
42 /// The file could not be read.
43 #[error("config file {path} could not be read: {source}")]
44 Read {
45 /// The path that failed.
46 path: PathBuf,
47 /// The underlying I/O failure.
48 source: std::io::Error,
49 },
50 /// The file is not valid TOML, or does not match the expected shape.
51 #[error("config file {path} is not valid: {message}")]
52 Parse {
53 /// The path that failed.
54 path: PathBuf,
55 /// The parser's complaint.
56 message: String,
57 },
58 /// A path list could not be joined into the platform's list syntax.
59 #[error("config file {path}: {field} contains a path with the list separator in it")]
60 PathList {
61 /// The path that failed.
62 path: PathBuf,
63 /// The offending field.
64 field: &'static str,
65 },
66}
67
68/// Top-level shape of `velesdb-memory.toml`.
69///
70/// `deny_unknown_fields` is deliberate: a typo'd key (`mdoel = "…"`) that is
71/// silently dropped leaves the operator convinced they set something they did
72/// not. Failing loudly at startup is the whole reason to have a file.
73#[derive(Debug, Default, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct ConfigFile {
76 /// Store directory (`VELESDB_MEMORY_PATH`).
77 pub path: Option<String>,
78 /// Suppress the startup banner (`VELESDB_MEMORY_QUIET`).
79 pub quiet: Option<bool>,
80 /// Default TTL in seconds applied to facts with no explicit one
81 /// (`VELESDB_MEMORY_DEFAULT_TTL`).
82 pub default_ttl: Option<u64>,
83 /// HTTP transport settings.
84 #[serde(default)]
85 pub http: HttpConfig,
86 /// Embedding backend settings.
87 #[serde(default)]
88 pub embedder: EmbedderConfig,
89 /// Extraction backend settings.
90 #[serde(default)]
91 pub extractor: ExtractorConfig,
92 /// Context-compiler settings.
93 #[serde(default)]
94 pub context: ContextConfig,
95 /// Knowledge-graph settings.
96 #[serde(default)]
97 pub graph: GraphConfig,
98}
99
100/// `[graph]` — how much structure the memory builds on its own.
101#[derive(Debug, Default, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct GraphConfig {
104 /// Let every `remember` also wire the entities, typed edges and attributes
105 /// its text states (`VELESDB_MEMORY_AUTOGRAPH`).
106 ///
107 /// Off by default. It costs one generation per `remember`, so it is a
108 /// deliberate choice, not something to inherit silently — and it needs an
109 /// `[extractor]` backend to have anything to do.
110 pub autograph: Option<bool>,
111}
112
113/// `[http]` — the streamable-HTTP transport (multi-client daemon mode).
114#[derive(Debug, Default, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct HttpConfig {
117 /// Serve over HTTP instead of stdio (`VELESDB_MEMORY_HTTP`).
118 pub enabled: Option<bool>,
119 /// Address to bind (`VELESDB_MEMORY_HTTP_BIND`).
120 pub bind: Option<String>,
121 /// Serve plaintext instead of TLS (`VELESDB_MEMORY_HTTP_INSECURE`).
122 pub insecure: Option<bool>,
123 /// Permit a non-loopback bind (`VELESDB_MEMORY_HTTP_ALLOW_REMOTE`).
124 pub allow_remote: Option<bool>,
125 /// Request body ceiling (`VELESDB_MEMORY_HTTP_MAX_BODY_BYTES`).
126 pub max_body_bytes: Option<u64>,
127 /// Concurrent session ceiling (`VELESDB_MEMORY_HTTP_MAX_SESSIONS`).
128 pub max_sessions: Option<u64>,
129 /// Directory holding the local CA and leaf certificate
130 /// (`VELESDB_MEMORY_TLS_DIR`).
131 pub tls_dir: Option<String>,
132}
133
134/// `[embedder]` — how text becomes vectors.
135#[derive(Debug, Default, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct EmbedderConfig {
138 /// `hash` or `ollama` (`VELESDB_MEMORY_EMBEDDER`).
139 pub backend: Option<String>,
140 /// Ollama model (`VELESDB_MEMORY_OLLAMA_MODEL`).
141 pub model: Option<String>,
142 /// Ollama base URL (`VELESDB_MEMORY_OLLAMA_URL`).
143 pub url: Option<String>,
144 /// How long Ollama keeps the model resident
145 /// (`VELESDB_MEMORY_OLLAMA_KEEP_ALIVE`).
146 pub keep_alive: Option<String>,
147}
148
149/// `[extractor]` — the backend that reads facts, relations and attributes out
150/// of raw text for `remember_extracted`.
151#[derive(Debug, Default, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct ExtractorConfig {
154 /// `ollama`, or absent for none (`VELESDB_MEMORY_EXTRACTOR`).
155 pub backend: Option<String>,
156 /// Generative model (`VELESDB_MEMORY_EXTRACTOR_MODEL`).
157 pub model: Option<String>,
158 /// Base URL (`VELESDB_MEMORY_EXTRACTOR_URL`).
159 pub url: Option<String>,
160}
161
162/// `[context]` — the deterministic context compiler.
163#[derive(Debug, Default, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct ContextConfig {
166 /// Directories `path`-referenced fragments may be read from
167 /// (`VELESDB_MEMORY_INGEST_ROOTS`). Written as a list here and joined
168 /// into the platform's `PATH` syntax, so the file stays readable and
169 /// portable where the raw variable is neither.
170 pub ingest_roots: Option<Vec<String>>,
171}
172
173/// Where the config file was found, and what it asked for.
174#[derive(Debug)]
175pub struct LoadedConfig {
176 /// The file that was read.
177 pub path: PathBuf,
178 /// The variables it defines, in `VELESDB_MEMORY_*` form.
179 pub values: BTreeMap<String, String>,
180}
181
182/// Resolve the config file path: an explicit `--config`, then
183/// [`CONFIG_PATH_VAR`], then `<store>/velesdb-memory.toml`, then
184/// `./velesdb-memory.toml`.
185///
186/// The store directory is checked before the working directory on purpose: a
187/// daemon's working directory is whatever launchd or systemd happened to give
188/// it, which is not a location an operator would think to put a file in.
189#[must_use]
190pub fn resolve_path(explicit: Option<&str>, store_dir: Option<&Path>) -> Option<PathBuf> {
191 if let Some(explicit) = explicit {
192 return Some(PathBuf::from(explicit));
193 }
194 if let Ok(from_env) = std::env::var(CONFIG_PATH_VAR) {
195 if !from_env.trim().is_empty() {
196 return Some(PathBuf::from(from_env));
197 }
198 }
199 if let Some(dir) = store_dir {
200 let candidate = dir.join(CONFIG_FILE_NAME);
201 if candidate.is_file() {
202 return Some(candidate);
203 }
204 }
205 let cwd = PathBuf::from(CONFIG_FILE_NAME);
206 cwd.is_file().then_some(cwd)
207}
208
209/// Read and parse `path` into the `VELESDB_MEMORY_*` variables it defines.
210///
211/// # Errors
212/// Returns [`ConfigError`] if the file cannot be read or is not valid TOML.
213pub fn load(path: &Path) -> Result<LoadedConfig, ConfigError> {
214 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
215 path: path.to_path_buf(),
216 source,
217 })?;
218 let file: ConfigFile = toml::from_str(&text).map_err(|err| ConfigError::Parse {
219 path: path.to_path_buf(),
220 message: err.to_string(),
221 })?;
222 Ok(LoadedConfig {
223 path: path.to_path_buf(),
224 values: file.into_env(path)?,
225 })
226}
227
228/// Export `values` into the process environment, skipping any variable that is
229/// already set. Returns the names actually applied, in order.
230///
231/// The skip is the precedence rule: the environment was set by whoever
232/// launched the process, and that intent outranks a file on disk.
233#[must_use]
234pub fn apply(values: &BTreeMap<String, String>) -> Vec<String> {
235 let mut applied = Vec::new();
236 for (key, value) in values {
237 if std::env::var_os(key).is_some() {
238 continue;
239 }
240 std::env::set_var(key, value);
241 applied.push(key.clone());
242 }
243 applied
244}
245
246impl ConfigFile {
247 /// Flatten the typed sections into the `VELESDB_MEMORY_*` variables the
248 /// rest of the binary already reads.
249 fn into_env(self, path: &Path) -> Result<BTreeMap<String, String>, ConfigError> {
250 let mut out = BTreeMap::new();
251 let mut set = |key: &str, value: Option<String>| {
252 if let Some(value) = value {
253 out.insert(key.to_string(), value);
254 }
255 };
256 set("VELESDB_MEMORY_PATH", self.path);
257 set("VELESDB_MEMORY_QUIET", self.quiet.map(flag));
258 set(
259 "VELESDB_MEMORY_DEFAULT_TTL",
260 self.default_ttl.map(|v| v.to_string()),
261 );
262
263 set("VELESDB_MEMORY_HTTP", self.http.enabled.map(flag));
264 set("VELESDB_MEMORY_HTTP_BIND", self.http.bind);
265 set("VELESDB_MEMORY_HTTP_INSECURE", self.http.insecure.map(flag));
266 set(
267 "VELESDB_MEMORY_HTTP_ALLOW_REMOTE",
268 self.http.allow_remote.map(flag),
269 );
270 set(
271 "VELESDB_MEMORY_HTTP_MAX_BODY_BYTES",
272 self.http.max_body_bytes.map(|v| v.to_string()),
273 );
274 set(
275 "VELESDB_MEMORY_HTTP_MAX_SESSIONS",
276 self.http.max_sessions.map(|v| v.to_string()),
277 );
278 set("VELESDB_MEMORY_TLS_DIR", self.http.tls_dir);
279
280 set("VELESDB_MEMORY_EMBEDDER", self.embedder.backend);
281 set("VELESDB_MEMORY_OLLAMA_MODEL", self.embedder.model);
282 set("VELESDB_MEMORY_OLLAMA_URL", self.embedder.url);
283 set("VELESDB_MEMORY_OLLAMA_KEEP_ALIVE", self.embedder.keep_alive);
284
285 set("VELESDB_MEMORY_EXTRACTOR", self.extractor.backend);
286 set("VELESDB_MEMORY_EXTRACTOR_MODEL", self.extractor.model);
287 set("VELESDB_MEMORY_EXTRACTOR_URL", self.extractor.url);
288
289 set("VELESDB_MEMORY_AUTOGRAPH", self.graph.autograph.map(flag));
290
291 if let Some(roots) = self.context.ingest_roots {
292 let joined = std::env::join_paths(roots).map_err(|_| ConfigError::PathList {
293 path: path.to_path_buf(),
294 field: "context.ingest_roots",
295 })?;
296 out.insert(
297 "VELESDB_MEMORY_INGEST_ROOTS".to_string(),
298 joined.to_string_lossy().into_owned(),
299 );
300 }
301 Ok(out)
302 }
303}
304
305/// Render a boolean the way every reader in the binary tests for it: the
306/// truthy form is the exact string `"1"`. `false` becomes `"0"` rather than
307/// being omitted, so writing `enabled = false` in the file genuinely holds the
308/// setting off instead of falling through to a default that might be on.
309fn flag(value: bool) -> String {
310 if value { "1" } else { "0" }.to_string()
311}
312
313#[cfg(test)]
314#[path = "config_tests.rs"]
315mod tests;