1use std::path::{Path, PathBuf};
19
20use crate::{Config, Database, DatabaseBuilder, Embedder, HostError, OpenAiCompatEmbedder};
21
22const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
24const ENV_EMBEDDER: &str = "PLUGMEM_EMBEDDER";
26pub(crate) const ENGINE_SETTING_KEYS: &[&str] = &[
30 "dim",
31 "max_bytes",
32 "max_text",
33 "max_blob",
34 "shards_facts",
35 "shards_entities",
36 "shards_edges",
37 "shards_temporal",
38 "shards_postings",
39];
40pub(crate) const DATABASE_SETTING_KEYS: &[&str] = &["path"];
41pub(crate) const EMBEDDER_SETTING_KEYS: &[&str] = &["kind", "url", "model", "api_key_env"];
42pub(crate) const MAINTENANCE_SETTING_KEYS: &[&str] = &[
43 "snapshot_every_ops",
44 "snapshot_journal_bytes",
45 "maintain_every_forgets",
46];
47
48#[derive(Debug, thiserror::Error)]
52#[non_exhaustive]
53pub enum SettingsError {
54 #[error("{0}")]
56 Config(String),
57}
58
59impl SettingsError {
60 fn config(msg: impl Into<String>) -> Self {
61 SettingsError::Config(msg.into())
62 }
63}
64
65pub struct Settings {
69 pub database_path: Option<PathBuf>,
72 pub config: Config,
74 pub embedder: Option<Box<dyn Embedder>>,
77 pub snapshot_every_ops: Option<u64>,
79 pub snapshot_journal_bytes: Option<u64>,
81 pub maintain_every_forgets: Option<u64>,
83}
84
85impl Settings {
86 pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
90 let table = read_config(flag)?;
91 Settings::from_table(table.as_ref())
92 }
93
94 pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
99 let mut config = Config::default();
100 let mut database_path = None;
101 let mut embedder = EmbedderCfg::default();
102 let mut snapshot_every_ops = None;
103 let mut snapshot_journal_bytes = None;
104 let mut maintain_every_forgets = None;
105
106 if let Some(table) = table {
107 if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
108 database_path = t
109 .get(DATABASE_SETTING_KEYS[0])
110 .map(|value| {
111 let path = value.as_str().ok_or_else(|| {
112 SettingsError::config("[database].path must be a string")
113 })?;
114 if path.is_empty() {
115 return Err(SettingsError::config("[database].path must not be empty"));
116 }
117 Ok(PathBuf::from(path))
118 })
119 .transpose()?;
120 }
121 if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
122 apply_engine(&mut config, t)?;
123 }
124 if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
125 embedder.merge(t);
126 }
127 if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
128 snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
129 snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
130 maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
131 }
132 }
133
134 if let Some(kind) = std::env::var_os(ENV_EMBEDDER) {
135 embedder.kind = Some(kind.to_string_lossy().into_owned());
136 }
137
138 let embedder = embedder.build(config.dim)?;
139 Ok(Settings {
140 database_path,
141 config,
142 embedder,
143 snapshot_every_ops,
144 snapshot_journal_bytes,
145 maintain_every_forgets,
146 })
147 }
148
149 pub fn open(self, path: &Path) -> Result<Database, HostError> {
154 let mut b: DatabaseBuilder = Database::builder(self.config);
155 if let Some(v) = self.snapshot_every_ops {
156 b = b.snapshot_every_ops(v);
157 }
158 if let Some(v) = self.snapshot_journal_bytes {
159 b = b.snapshot_journal_bytes(v);
160 }
161 if let Some(v) = self.maintain_every_forgets {
162 b = b.maintain_every_forgets(v);
163 }
164 if let Some(e) = self.embedder {
165 b = b.embedder(e);
166 }
167 Ok(b.open(path)?.0)
168 }
169}
170
171pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
178 let text = match read_config_text(flag)? {
179 Some(t) => t,
180 None => return Ok(None),
181 };
182 let table: toml::Table = text
183 .parse()
184 .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
185 Ok(Some(table))
186}
187
188pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
190 t.get(key)
191 .and_then(toml::Value::as_integer)
192 .filter(|n| *n >= 0)
193 .map(|n| n as u64)
194}
195
196fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
198 if let Some(p) = flag {
199 return std::fs::read_to_string(p)
200 .map(Some)
201 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
202 }
203 let candidate = std::env::var_os(ENV_CONFIG)
204 .map(PathBuf::from)
205 .or_else(crate::default_config_path);
206 match candidate {
207 Some(p) if p.exists() => std::fs::read_to_string(&p)
208 .map(Some)
209 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
210 _ => Ok(None),
211 }
212}
213
214fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
218 let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
219 (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
220 (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
221 (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
222 (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
223 (ENGINE_SETTING_KEYS[4], &mut cfg.shards_facts),
224 (ENGINE_SETTING_KEYS[5], &mut cfg.shards_entities),
225 (ENGINE_SETTING_KEYS[6], &mut cfg.shards_edges),
226 (ENGINE_SETTING_KEYS[7], &mut cfg.shards_temporal),
227 (ENGINE_SETTING_KEYS[8], &mut cfg.shards_postings),
228 ];
229 for (key, slot) in fields {
230 if let Some(v) = t.get(key) {
231 let n = v.as_integer().filter(|n| *n >= 0).ok_or_else(|| {
232 SettingsError::config(format!("[engine].{key} must be a non-negative integer"))
233 })?;
234 *slot = n as usize;
235 }
236 }
237 Ok(())
238}
239
240#[derive(Default)]
242struct EmbedderCfg {
243 kind: Option<String>,
244 url: Option<String>,
245 model: Option<String>,
246 api_key_env: Option<String>,
247}
248
249impl EmbedderCfg {
250 fn merge(&mut self, t: &toml::Table) {
251 let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
252 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[0]) {
253 self.kind = Some(v);
254 }
255 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
256 self.url = Some(v);
257 }
258 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
259 self.model = Some(v);
260 }
261 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
262 self.api_key_env = Some(v);
263 }
264 }
265
266 fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
271 let kind = self.kind.as_deref().unwrap_or("none");
272 match kind {
273 "none" | "" => Ok(None),
274 "ollama" | "openai" | "openai-compat" | "lmstudio" | "vllm" | "llamacpp" => {
275 let url = self.url.clone().ok_or_else(|| {
276 SettingsError::config(format!("[embedder] kind \"{kind}\" needs a url"))
277 })?;
278 let model = self.model.clone().ok_or_else(|| {
279 SettingsError::config(format!("[embedder] kind \"{kind}\" needs a model"))
280 })?;
281 if dim == 0 {
282 return Err(SettingsError::config(
283 "[embedder] requires [engine].dim > 0 (the embedding size)",
284 ));
285 }
286 let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
287 if let Some(env) = &self.api_key_env
288 && let Some(key) = std::env::var_os(env)
289 {
290 e = e.with_api_key(key.to_string_lossy().into_owned());
291 }
292 Ok(Some(Box::new(e)))
293 }
294 other => Err(SettingsError::config(format!(
295 "unknown [embedder] kind: {other}"
296 ))),
297 }
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 struct TempDir(PathBuf);
307 impl TempDir {
308 fn new(tag: &str) -> Self {
309 let dir = std::env::temp_dir().join(format!(
310 "plugmem-settings-{tag}-{}-{}",
311 std::process::id(),
312 std::time::SystemTime::now()
313 .duration_since(std::time::UNIX_EPOCH)
314 .unwrap()
315 .as_nanos()
316 ));
317 std::fs::create_dir_all(&dir).unwrap();
318 TempDir(dir)
319 }
320 }
321 impl Drop for TempDir {
322 fn drop(&mut self) {
323 let _ = std::fs::remove_dir_all(&self.0);
324 }
325 }
326
327 #[test]
328 fn engine_and_maintenance_parse() {
329 let text = "\
330[engine]
331dim = 384
332shards_facts = 16
333[maintenance]
334snapshot_every_ops = 50
335snapshot_journal_bytes = 8192
336maintain_every_forgets = 3
337";
338 let table: toml::Table = text.parse().unwrap();
339 let s = Settings::from_table(Some(&table)).unwrap();
340 assert_eq!(s.config.dim, 384);
341 assert_eq!(s.config.shards_facts, 16);
342 assert_eq!(s.snapshot_every_ops, Some(50));
343 assert_eq!(s.snapshot_journal_bytes, Some(8192));
344 assert_eq!(s.maintain_every_forgets, Some(3));
345
346 let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
347 assert!(matches!(
348 Settings::from_table(Some(&bad)),
349 Err(SettingsError::Config(_))
350 ));
351 }
352
353 #[test]
354 fn defaults_when_no_table() {
355 let s = Settings::from_table(None).unwrap();
356 assert!(s.database_path.is_none());
357 assert_eq!(s.config.dim, Config::default().dim);
358 assert!(s.embedder.is_none());
359 assert_eq!(s.snapshot_every_ops, None);
360 }
361
362 #[test]
363 fn embedder_merge_reads_every_field() {
364 let text = "\
365[embedder]
366kind = \"ollama\"
367url = \"http://localhost:11434/v1\"
368model = \"nomic-embed-text\"
369api_key_env = \"SOME_ENV\"
370[engine]
371dim = 8
372";
373 let table: toml::Table = text.parse().unwrap();
374 let s = Settings::from_table(Some(&table)).unwrap();
376 assert!(s.embedder.is_some());
377 }
378
379 #[test]
380 fn database_path_reads_and_validates_from_config() {
381 let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
382 .parse()
383 .unwrap();
384 let settings = Settings::from_table(Some(&table)).unwrap();
385 assert_eq!(
386 settings.database_path.as_deref(),
387 Some(std::path::Path::new("/tmp/memory.plugmem"))
388 );
389
390 let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
391 assert!(matches!(
392 Settings::from_table(Some(&bad)),
393 Err(SettingsError::Config(message)) if message == "[database].path must be a string"
394 ));
395 }
396
397 #[test]
398 fn settings_open_applies_maintenance_and_embedder() {
399 let tmp = TempDir::new("open");
403 let mut config = Config::default();
404 config.dim = 8;
405 let embedder = EmbedderCfg {
406 kind: Some("ollama".into()),
407 url: Some("http://127.0.0.1:0/v1".into()),
408 model: Some("m".into()),
409 api_key_env: None,
410 }
411 .build(8)
412 .unwrap();
413 assert!(embedder.is_some());
414 let settings = Settings {
415 database_path: None,
416 config,
417 embedder,
418 snapshot_every_ops: Some(4),
419 snapshot_journal_bytes: Some(4096),
420 maintain_every_forgets: Some(2),
421 };
422 let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
423 assert_eq!(db.stats().facts, 0);
424 }
425
426 #[test]
427 fn embedder_build_rules() {
428 assert!(EmbedderCfg::default().build(0).unwrap().is_none());
429 let no_url = EmbedderCfg {
430 kind: Some("ollama".into()),
431 ..Default::default()
432 };
433 assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
434 let no_model = EmbedderCfg {
435 kind: Some("ollama".into()),
436 url: Some("http://x/v1".into()),
437 ..Default::default()
438 };
439 assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
440 let zero_dim = EmbedderCfg {
441 kind: Some("ollama".into()),
442 url: Some("http://x/v1".into()),
443 model: Some("m".into()),
444 api_key_env: None,
445 };
446 assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
447 let ok = EmbedderCfg {
448 kind: Some("openai".into()),
449 url: Some("http://x/v1".into()),
450 model: Some("m".into()),
451 api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
452 };
453 assert!(ok.build(384).unwrap().is_some());
454 let weird = EmbedderCfg {
455 kind: Some("weird".into()),
456 ..Default::default()
457 };
458 assert!(matches!(weird.build(384), Err(SettingsError::Config(_))));
459 }
460
461 #[test]
462 fn load_reads_the_config_file() {
463 let tmp = TempDir::new("load");
464 let cfgfile = tmp.0.join("config.toml");
465 std::fs::write(
466 &cfgfile,
467 "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n[maintenance]\nsnapshot_every_ops = 64\n",
468 )
469 .unwrap();
470 let s = Settings::load(Some(&cfgfile)).unwrap();
471 assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
472 assert_eq!(s.config.dim, 512);
473 assert!(s.embedder.is_none());
474 assert_eq!(s.snapshot_every_ops, Some(64));
475
476 assert!(matches!(
478 Settings::load(Some(&tmp.0.join("nope.toml"))),
479 Err(SettingsError::Config(_))
480 ));
481 }
482
483 #[test]
484 fn read_config_none_and_batch_extra() {
485 let tmp = TempDir::new("extra");
487 let missing = tmp.0.join("absent.toml");
488 assert!(read_config(Some(&missing)).is_err());
491
492 let cfgfile = tmp.0.join("config.toml");
493 std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
494 let table = read_config(Some(&cfgfile)).unwrap().unwrap();
495 let batch = table
496 .get("maintenance")
497 .and_then(toml::Value::as_table)
498 .and_then(|m| table_u64(m, "batch_size"));
499 assert_eq!(batch, Some(256));
500 }
501
502 #[test]
503 fn every_host_setting_is_documented() {
504 let docs = crate::settings_help::settings_help().docs();
505 for (section, keys) in [
506 ("database", DATABASE_SETTING_KEYS),
507 ("engine", ENGINE_SETTING_KEYS),
508 ("embedder", EMBEDDER_SETTING_KEYS),
509 ("maintenance", MAINTENANCE_SETTING_KEYS),
510 ] {
511 let documented: Vec<_> = docs
512 .iter()
513 .filter(|doc| {
514 doc.section == section
515 && doc.scope == crate::settings_help::SettingScope::Shared
516 })
517 .map(|doc| doc.key)
518 .collect();
519 assert_eq!(
520 documented.as_slice(),
521 keys,
522 "undocumented {section} setting"
523 );
524 }
525 }
526}