1use std::path::{Path, PathBuf};
19
20use crate::{
21 Config, Database, DatabaseBuilder, Embedder, HostError, MAX_OPEN_CEILING, OpenAiCompatEmbedder,
22 Opener, SharedEmbedder, Workspace, WorkspaceLayout, WorkspaceLimits,
23};
24
25const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
27const ENV_EMBEDDER: &str = "PLUGMEM_EMBEDDER";
29pub(crate) const ENGINE_SETTING_KEYS: &[&str] = &["dim", "max_bytes", "max_text", "max_blob"];
33pub(crate) const DATABASE_SETTING_KEYS: &[&str] = &["path"];
34pub(crate) const WORKSPACE_SETTING_KEYS: &[&str] = &["dir", "max_open", "idle_timeout_ms"];
35pub(crate) const EMBEDDER_SETTING_KEYS: &[&str] = &["kind", "url", "model", "api_key_env"];
36pub(crate) const MAINTENANCE_SETTING_KEYS: &[&str] = &[
37 "snapshot_every_ops",
38 "snapshot_journal_bytes",
39 "maintain_every_forgets",
40];
41
42#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum SettingsError {
48 #[error("{0}")]
50 Config(String),
51}
52
53impl SettingsError {
54 fn config(msg: impl Into<String>) -> Self {
55 SettingsError::Config(msg.into())
56 }
57}
58
59pub struct Settings {
63 pub database_path: Option<PathBuf>,
66 pub config: Config,
68 pub embedder: Option<Box<dyn Embedder>>,
71 pub snapshot_every_ops: Option<u64>,
73 pub snapshot_journal_bytes: Option<u64>,
75 pub maintain_every_forgets: Option<u64>,
77 pub workspace: WorkspaceSettings,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct WorkspaceSettings {
87 pub dir: Option<PathBuf>,
90 pub limits: WorkspaceLimits,
92}
93
94impl Settings {
95 pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
99 let table = read_config(flag)?;
100 Settings::from_table(table.as_ref())
101 }
102
103 pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
108 let mut config = Config::default();
109 let mut database_path = None;
110 let mut embedder = EmbedderCfg::default();
111 let mut snapshot_every_ops = None;
112 let mut snapshot_journal_bytes = None;
113 let mut maintain_every_forgets = None;
114 let mut workspace = WorkspaceSettings {
115 dir: None,
116 limits: WorkspaceLimits::default(),
117 };
118
119 if let Some(table) = table {
120 if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
121 database_path = t
122 .get(DATABASE_SETTING_KEYS[0])
123 .map(|value| {
124 let path = value.as_str().ok_or_else(|| {
125 SettingsError::config("[database].path must be a string")
126 })?;
127 if path.is_empty() {
128 return Err(SettingsError::config("[database].path must not be empty"));
129 }
130 Ok(PathBuf::from(path))
131 })
132 .transpose()?;
133 }
134 if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
135 apply_engine(&mut config, t)?;
136 }
137 if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
138 embedder.merge(t);
139 }
140 if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
141 snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
142 snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
143 maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
144 }
145 if let Some(t) = table.get("workspace").and_then(toml::Value::as_table) {
146 workspace = parse_workspace(t)?;
147 }
148 }
149
150 if let Some(kind) = std::env::var_os(ENV_EMBEDDER) {
151 embedder.kind = Some(kind.to_string_lossy().into_owned());
152 }
153
154 let embedder = embedder.build(config.dim)?;
155 Ok(Settings {
156 database_path,
157 config,
158 embedder,
159 snapshot_every_ops,
160 snapshot_journal_bytes,
161 maintain_every_forgets,
162 workspace,
163 })
164 }
165
166 pub fn open(self, path: &Path) -> Result<Database, HostError> {
171 let mut b: DatabaseBuilder = Database::builder(self.config);
172 if let Some(v) = self.snapshot_every_ops {
173 b = b.snapshot_every_ops(v);
174 }
175 if let Some(v) = self.snapshot_journal_bytes {
176 b = b.snapshot_journal_bytes(v);
177 }
178 if let Some(v) = self.maintain_every_forgets {
179 b = b.maintain_every_forgets(v);
180 }
181 if let Some(e) = self.embedder {
182 b = b.embedder(e);
183 }
184 Ok(b.open(path)?.0)
185 }
186
187 pub fn open_workspace(self, root: &Path) -> Result<Workspace, crate::WorkspaceError> {
204 let Settings {
205 config,
206 embedder,
207 snapshot_every_ops,
208 snapshot_journal_bytes,
209 maintain_every_forgets,
210 workspace,
211 ..
212 } = self;
213 let shared = embedder.map(SharedEmbedder::new);
214
215 let open: Opener = Box::new(move |path: &Path| {
216 let mut b = Database::builder(config.clone());
217 if let Some(v) = snapshot_every_ops {
218 b = b.snapshot_every_ops(v);
219 }
220 if let Some(v) = snapshot_journal_bytes {
221 b = b.snapshot_journal_bytes(v);
222 }
223 if let Some(v) = maintain_every_forgets {
224 b = b.maintain_every_forgets(v);
225 }
226 if let Some(e) = &shared {
227 b = b.embedder(Box::new(e.clone()));
228 }
229 Ok(b.open(path)?.0)
230 });
231 Ok(Workspace::new(
232 WorkspaceLayout::new(root),
233 open,
234 workspace.limits,
235 ))
236 }
237}
238
239fn parse_workspace(t: &toml::Table) -> Result<WorkspaceSettings, SettingsError> {
243 let mut out = WorkspaceSettings {
244 dir: None,
245 limits: WorkspaceLimits::default(),
246 };
247 if let Some(value) = t.get(WORKSPACE_SETTING_KEYS[0]) {
248 let dir = value
249 .as_str()
250 .ok_or_else(|| SettingsError::config("[workspace].dir must be a string"))?;
251 if dir.is_empty() {
252 return Err(SettingsError::config("[workspace].dir must not be empty"));
253 }
254 out.dir = Some(PathBuf::from(dir));
255 }
256 if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[1]) {
257 if n == 0 || n > MAX_OPEN_CEILING as u64 {
258 return Err(SettingsError::config(format!(
259 "[workspace].max_open must be between 1 and {MAX_OPEN_CEILING} \
260 (one open database costs several file descriptors)"
261 )));
262 }
263 out.limits.max_open = n as usize;
267 }
268 if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[2]) {
269 out.limits.idle_timeout_ms = n;
270 }
271 Ok(out)
272}
273
274pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
281 let text = match read_config_text(flag)? {
282 Some(t) => t,
283 None => return Ok(None),
284 };
285 let table: toml::Table = text
286 .parse()
287 .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
288 Ok(Some(table))
289}
290
291pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
293 t.get(key)
294 .and_then(toml::Value::as_integer)
295 .filter(|n| *n >= 0)
296 .map(|n| n as u64)
297}
298
299fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
301 if let Some(p) = flag {
302 return std::fs::read_to_string(p)
303 .map(Some)
304 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
305 }
306 let candidate = std::env::var_os(ENV_CONFIG)
307 .map(PathBuf::from)
308 .or_else(crate::default_config_path);
309 match candidate {
310 Some(p) if p.exists() => std::fs::read_to_string(&p)
311 .map(Some)
312 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
313 _ => Ok(None),
314 }
315}
316
317fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
321 let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
322 (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
323 (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
324 (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
325 (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
326 ];
327 for (key, slot) in fields {
328 if let Some(v) = t.get(key) {
329 let n = v.as_integer().filter(|n| *n >= 0).ok_or_else(|| {
330 SettingsError::config(format!("[engine].{key} must be a non-negative integer"))
331 })?;
332 *slot = n as usize;
333 }
334 }
335 Ok(())
336}
337
338#[derive(Default)]
340struct EmbedderCfg {
341 kind: Option<String>,
342 url: Option<String>,
343 model: Option<String>,
344 api_key_env: Option<String>,
345}
346
347impl EmbedderCfg {
348 fn merge(&mut self, t: &toml::Table) {
349 let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
350 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[0]) {
351 self.kind = Some(v);
352 }
353 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
354 self.url = Some(v);
355 }
356 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
357 self.model = Some(v);
358 }
359 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
360 self.api_key_env = Some(v);
361 }
362 }
363
364 fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
369 let kind = self.kind.as_deref().unwrap_or("none");
370 match kind {
371 "none" | "" => Ok(None),
372 "ollama" | "openai" | "openai-compat" | "lmstudio" | "vllm" | "llamacpp" => {
373 let url = self.url.clone().ok_or_else(|| {
374 SettingsError::config(format!("[embedder] kind \"{kind}\" needs a url"))
375 })?;
376 let model = self.model.clone().ok_or_else(|| {
377 SettingsError::config(format!("[embedder] kind \"{kind}\" needs a model"))
378 })?;
379 if dim == 0 {
380 return Err(SettingsError::config(
381 "[embedder] requires [engine].dim > 0 (the embedding size)",
382 ));
383 }
384 let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
385 if let Some(env) = &self.api_key_env
386 && let Some(key) = std::env::var_os(env)
387 {
388 e = e.with_api_key(key.to_string_lossy().into_owned());
389 }
390 Ok(Some(Box::new(e)))
391 }
392 other => Err(SettingsError::config(format!(
393 "unknown [embedder] kind: {other}"
394 ))),
395 }
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 struct TempDir(PathBuf);
405 impl TempDir {
406 fn new(tag: &str) -> Self {
407 let dir = std::env::temp_dir().join(format!(
408 "plugmem-settings-{tag}-{}-{}",
409 std::process::id(),
410 std::time::SystemTime::now()
411 .duration_since(std::time::UNIX_EPOCH)
412 .unwrap()
413 .as_nanos()
414 ));
415 std::fs::create_dir_all(&dir).unwrap();
416 TempDir(dir)
417 }
418 }
419 impl Drop for TempDir {
420 fn drop(&mut self) {
421 let _ = std::fs::remove_dir_all(&self.0);
422 }
423 }
424
425 #[test]
426 fn engine_and_maintenance_parse() {
427 let text = "\
428[engine]
429dim = 384
430max_text = 2048
431[maintenance]
432snapshot_every_ops = 50
433snapshot_journal_bytes = 8192
434maintain_every_forgets = 3
435";
436 let table: toml::Table = text.parse().unwrap();
437 let s = Settings::from_table(Some(&table)).unwrap();
438 assert_eq!(s.config.dim, 384);
439 assert_eq!(s.config.max_text, 2048);
440 assert_eq!(s.snapshot_every_ops, Some(50));
441 assert_eq!(s.snapshot_journal_bytes, Some(8192));
442 assert_eq!(s.maintain_every_forgets, Some(3));
443
444 let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
445 assert!(matches!(
446 Settings::from_table(Some(&bad)),
447 Err(SettingsError::Config(_))
448 ));
449 }
450
451 #[test]
452 fn defaults_when_no_table() {
453 let s = Settings::from_table(None).unwrap();
454 assert!(s.database_path.is_none());
455 assert_eq!(s.config.dim, Config::default().dim);
456 assert!(s.embedder.is_none());
457 assert_eq!(s.snapshot_every_ops, None);
458 }
459
460 #[test]
461 fn embedder_merge_reads_every_field() {
462 let text = "\
463[embedder]
464kind = \"ollama\"
465url = \"http://localhost:11434/v1\"
466model = \"nomic-embed-text\"
467api_key_env = \"SOME_ENV\"
468[engine]
469dim = 8
470";
471 let table: toml::Table = text.parse().unwrap();
472 let s = Settings::from_table(Some(&table)).unwrap();
474 assert!(s.embedder.is_some());
475 }
476
477 #[test]
478 fn database_path_reads_and_validates_from_config() {
479 let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
480 .parse()
481 .unwrap();
482 let settings = Settings::from_table(Some(&table)).unwrap();
483 assert_eq!(
484 settings.database_path.as_deref(),
485 Some(std::path::Path::new("/tmp/memory.plugmem"))
486 );
487
488 let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
489 assert!(matches!(
490 Settings::from_table(Some(&bad)),
491 Err(SettingsError::Config(message)) if message == "[database].path must be a string"
492 ));
493 }
494
495 #[test]
496 fn settings_open_applies_maintenance_and_embedder() {
497 let tmp = TempDir::new("open");
501 let mut config = Config::default();
502 config.dim = 8;
503 let embedder = EmbedderCfg {
504 kind: Some("ollama".into()),
505 url: Some("http://127.0.0.1:0/v1".into()),
506 model: Some("m".into()),
507 api_key_env: None,
508 }
509 .build(8)
510 .unwrap();
511 assert!(embedder.is_some());
512 let settings = Settings {
513 database_path: None,
514 config,
515 embedder,
516 snapshot_every_ops: Some(4),
517 snapshot_journal_bytes: Some(4096),
518 maintain_every_forgets: Some(2),
519 workspace: WorkspaceSettings {
520 dir: None,
521 limits: WorkspaceLimits::default(),
522 },
523 };
524 let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
525 assert_eq!(db.stats().facts, 0);
526 }
527
528 #[test]
529 fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
530 let bare = Settings::from_table(None).unwrap();
533 assert_eq!(bare.workspace.dir, None);
534 assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
535
536 let table: toml::Table =
537 "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
538 .parse()
539 .unwrap();
540 let s = Settings::from_table(Some(&table)).unwrap();
541 assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
542 assert_eq!(s.workspace.limits.max_open, 4);
543 assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
544
545 let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
547 let s = Settings::from_table(Some(&only_dir)).unwrap();
548 assert_eq!(s.workspace.limits, WorkspaceLimits::default());
549 }
550
551 #[test]
552 fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
553 for bad in [
556 "[workspace]\nmax_open = 0\n".to_string(),
557 format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
558 "[workspace]\nmax_open = 9999999999\n".to_string(),
561 ] {
562 let table: toml::Table = bad.parse().unwrap();
563 assert!(
564 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
565 "{bad}"
566 );
567 }
568
569 for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
570 let table: toml::Table = bad.parse().unwrap();
571 assert!(
572 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
573 "{bad}"
574 );
575 }
576
577 let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
579 .parse()
580 .unwrap();
581 let s = Settings::from_table(Some(&table)).unwrap();
582 assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
583 }
584
585 #[test]
586 fn open_workspace_builds_databases_from_the_same_settings() {
587 let tmp = TempDir::new("open-workspace");
588 let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
589 snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
590 .parse()
591 .unwrap();
592 let settings = Settings::from_table(Some(&table)).unwrap();
593 let ws = settings.open_workspace(&tmp.0).unwrap();
594
595 let name = crate::DbName::parse("chat-42").unwrap();
596 let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
597 db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
598 .unwrap();
599 assert_eq!(db.stats().facts, 1);
600 assert!(ws.layout().exists(&name));
601 }
602
603 #[test]
604 fn embedder_build_rules() {
605 assert!(EmbedderCfg::default().build(0).unwrap().is_none());
606 let no_url = EmbedderCfg {
607 kind: Some("ollama".into()),
608 ..Default::default()
609 };
610 assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
611 let no_model = EmbedderCfg {
612 kind: Some("ollama".into()),
613 url: Some("http://x/v1".into()),
614 ..Default::default()
615 };
616 assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
617 let zero_dim = EmbedderCfg {
618 kind: Some("ollama".into()),
619 url: Some("http://x/v1".into()),
620 model: Some("m".into()),
621 api_key_env: None,
622 };
623 assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
624 let ok = EmbedderCfg {
625 kind: Some("openai".into()),
626 url: Some("http://x/v1".into()),
627 model: Some("m".into()),
628 api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
629 };
630 assert!(ok.build(384).unwrap().is_some());
631 let weird = EmbedderCfg {
632 kind: Some("weird".into()),
633 ..Default::default()
634 };
635 assert!(matches!(weird.build(384), Err(SettingsError::Config(_))));
636 }
637
638 #[test]
639 fn load_reads_the_config_file() {
640 let tmp = TempDir::new("load");
641 let cfgfile = tmp.0.join("config.toml");
642 std::fs::write(
643 &cfgfile,
644 "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n[maintenance]\nsnapshot_every_ops = 64\n",
645 )
646 .unwrap();
647 let s = Settings::load(Some(&cfgfile)).unwrap();
648 assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
649 assert_eq!(s.config.dim, 512);
650 assert!(s.embedder.is_none());
651 assert_eq!(s.snapshot_every_ops, Some(64));
652
653 assert!(matches!(
655 Settings::load(Some(&tmp.0.join("nope.toml"))),
656 Err(SettingsError::Config(_))
657 ));
658 }
659
660 #[test]
661 fn read_config_none_and_batch_extra() {
662 let tmp = TempDir::new("extra");
664 let missing = tmp.0.join("absent.toml");
665 assert!(read_config(Some(&missing)).is_err());
668
669 let cfgfile = tmp.0.join("config.toml");
670 std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
671 let table = read_config(Some(&cfgfile)).unwrap().unwrap();
672 let batch = table
673 .get("maintenance")
674 .and_then(toml::Value::as_table)
675 .and_then(|m| table_u64(m, "batch_size"));
676 assert_eq!(batch, Some(256));
677 }
678
679 #[test]
680 fn every_host_setting_is_documented() {
681 let docs = crate::settings_help::settings_help().docs();
682 for (section, keys) in [
683 ("database", DATABASE_SETTING_KEYS),
684 ("workspace", WORKSPACE_SETTING_KEYS),
685 ("engine", ENGINE_SETTING_KEYS),
686 ("embedder", EMBEDDER_SETTING_KEYS),
687 ("maintenance", MAINTENANCE_SETTING_KEYS),
688 ] {
689 let documented: Vec<_> = docs
690 .iter()
691 .filter(|doc| {
692 doc.section == section
693 && doc.scope == crate::settings_help::SettingScope::Shared
694 })
695 .map(|doc| doc.key)
696 .collect();
697 assert_eq!(
698 documented.as_slice(),
699 keys,
700 "undocumented {section} setting"
701 );
702 }
703 }
704}