1use std::path::{Path, PathBuf};
28
29use crate::{
30 Config, Database, DatabaseBuilder, Embedder, FsyncPolicy, HostError, MAX_OPEN_CEILING,
31 OpenAiCompatEmbedder, Opener, SettingWarning, SharedEmbedder, Workspace, WorkspaceLayout,
32 WorkspaceLimits, settings_help::settings_help,
33};
34
35const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
37const ENV_EMBEDDER_ENABLED: &str = "PLUGMEM_EMBEDDER_ENABLED";
39pub(crate) const ENGINE_SETTING_KEYS: &[&str] = &["dim", "max_bytes", "max_text", "max_blob"];
43pub(crate) const RECALL_SETTING_KEYS: &[&str] = &[
51 "bm25_k1",
52 "bm25_b",
53 "rrf_k",
54 "w_bm25",
55 "w_vec",
56 "w_graph",
57 "w_time",
58 "w_recency",
59 "half_life_days",
60 "graph_depth",
61 "graph_decay",
62 "hnsw_ef_search",
63 "similar_cos",
64 "similar_jaccard",
65];
66pub(crate) const INDEX_SETTING_KEYS: &[&str] = &["hnsw_ef_construction", "flat_to_hnsw"];
68pub(crate) const DATABASE_SETTING_KEYS: &[&str] = &["path"];
69pub(crate) const WORKSPACE_SETTING_KEYS: &[&str] = &["dir", "max_open", "idle_timeout_ms"];
70pub(crate) const EMBEDDER_SETTING_KEYS: &[&str] =
71 &["enabled", "url", "model", "space_id", "api_key_env"];
72pub(crate) const MAINTENANCE_SETTING_KEYS: &[&str] = &[
73 "snapshot_every_ops",
74 "snapshot_journal_bytes",
75 "maintain_every_forgets",
76 "fsync",
77];
78
79#[derive(Debug, thiserror::Error)]
83#[non_exhaustive]
84pub enum SettingsError {
85 #[error("{0}")]
87 Config(String),
88}
89
90impl SettingsError {
91 fn config(msg: impl Into<String>) -> Self {
92 SettingsError::Config(msg.into())
93 }
94}
95
96pub struct Settings {
100 pub database_path: Option<PathBuf>,
103 pub config: Config,
105 pub embedder: Option<Box<dyn Embedder>>,
108 pub snapshot_every_ops: Option<u64>,
110 pub snapshot_journal_bytes: Option<u64>,
112 pub maintain_every_forgets: Option<u64>,
114 pub fsync: Option<FsyncPolicy>,
119 pub workspace: WorkspaceSettings,
123 pub warnings: Vec<SettingWarning>,
130}
131
132#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct WorkspaceSettings {
136 pub dir: Option<PathBuf>,
139 pub limits: WorkspaceLimits,
141}
142
143impl Settings {
144 pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
148 let table = read_config(flag)?;
149 Settings::from_table(table.as_ref())
150 }
151
152 pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
157 let mut config = Config::default();
158 let mut database_path = None;
159 let mut embedder = EmbedderCfg::default();
160 let mut snapshot_every_ops = None;
161 let mut snapshot_journal_bytes = None;
162 let mut maintain_every_forgets = None;
163 let mut fsync = None;
164 let mut workspace = WorkspaceSettings {
165 dir: None,
166 limits: WorkspaceLimits::default(),
167 };
168 let warnings = table
169 .map(|t| settings_help().unknown_in(t))
170 .unwrap_or_default();
171
172 if let Some(table) = table {
173 if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
174 database_path = t
175 .get(DATABASE_SETTING_KEYS[0])
176 .map(|value| {
177 let path = value.as_str().ok_or_else(|| {
178 SettingsError::config("[database].path must be a string")
179 })?;
180 if path.is_empty() {
181 return Err(SettingsError::config("[database].path must not be empty"));
182 }
183 Ok(PathBuf::from(path))
184 })
185 .transpose()?;
186 }
187 if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
188 apply_engine(&mut config, t)?;
189 }
190 if let Some(t) = table.get("recall").and_then(toml::Value::as_table) {
191 apply_recall(&mut config, t)?;
192 }
193 if let Some(t) = table.get("index").and_then(toml::Value::as_table) {
194 apply_index(&mut config, t)?;
195 }
196 config
201 .validate()
202 .map_err(|e| SettingsError::config(format!("config.toml: {e}")))?;
203 if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
204 embedder.merge(t)?;
205 }
206 if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
207 snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
208 snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
209 maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
210 fsync = parse_fsync(t)?;
211 }
212 if let Some(t) = table.get("workspace").and_then(toml::Value::as_table) {
213 workspace = parse_workspace(t)?;
214 }
215 }
216
217 if let Some(enabled) = std::env::var_os(ENV_EMBEDDER_ENABLED) {
218 embedder.enabled = Some(parse_embedder_enabled(&enabled.to_string_lossy())?);
219 }
220
221 let embedder = embedder.build(config.dim)?;
222 Ok(Settings {
223 database_path,
224 config,
225 embedder,
226 snapshot_every_ops,
227 snapshot_journal_bytes,
228 maintain_every_forgets,
229 fsync,
230 workspace,
231 warnings,
232 })
233 }
234
235 pub fn open(self, path: &Path) -> Result<Database, HostError> {
240 let mut b: DatabaseBuilder = Database::builder(self.config);
241 if let Some(v) = self.snapshot_every_ops {
242 b = b.snapshot_every_ops(v);
243 }
244 if let Some(v) = self.snapshot_journal_bytes {
245 b = b.snapshot_journal_bytes(v);
246 }
247 if let Some(v) = self.maintain_every_forgets {
248 b = b.maintain_every_forgets(v);
249 }
250 if let Some(v) = self.fsync {
251 b = b.fsync(v);
252 }
253 if let Some(e) = self.embedder {
254 b = b.embedder(e);
255 }
256 Ok(b.open(path)?.0)
257 }
258
259 pub fn open_workspace(self, root: &Path) -> Result<Workspace, crate::WorkspaceError> {
276 let Settings {
277 config,
278 embedder,
279 snapshot_every_ops,
280 snapshot_journal_bytes,
281 maintain_every_forgets,
282 workspace,
283 ..
284 } = self;
285 let shared = embedder.map(SharedEmbedder::new);
286
287 let open: Opener = Box::new(move |path: &Path| {
288 let mut b = Database::builder(config.clone());
289 if let Some(v) = snapshot_every_ops {
290 b = b.snapshot_every_ops(v);
291 }
292 if let Some(v) = snapshot_journal_bytes {
293 b = b.snapshot_journal_bytes(v);
294 }
295 if let Some(v) = maintain_every_forgets {
296 b = b.maintain_every_forgets(v);
297 }
298 if let Some(e) = &shared {
299 b = b.embedder(Box::new(e.clone()));
300 }
301 Ok(b.open(path)?.0)
302 });
303 Ok(Workspace::new(
304 WorkspaceLayout::new(root),
305 open,
306 workspace.limits,
307 ))
308 }
309}
310
311fn parse_workspace(t: &toml::Table) -> Result<WorkspaceSettings, SettingsError> {
315 let mut out = WorkspaceSettings {
316 dir: None,
317 limits: WorkspaceLimits::default(),
318 };
319 if let Some(value) = t.get(WORKSPACE_SETTING_KEYS[0]) {
320 let dir = value
321 .as_str()
322 .ok_or_else(|| SettingsError::config("[workspace].dir must be a string"))?;
323 if dir.is_empty() {
324 return Err(SettingsError::config("[workspace].dir must not be empty"));
325 }
326 out.dir = Some(PathBuf::from(dir));
327 }
328 if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[1]) {
329 if n == 0 || n > MAX_OPEN_CEILING as u64 {
330 return Err(SettingsError::config(format!(
331 "[workspace].max_open must be between 1 and {MAX_OPEN_CEILING} \
332 (one open database costs several file descriptors)"
333 )));
334 }
335 out.limits.max_open = n as usize;
339 }
340 if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[2]) {
341 out.limits.idle_timeout_ms = n;
342 }
343 Ok(out)
344}
345
346pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
353 let text = match read_config_text(flag)? {
354 Some(t) => t,
355 None => return Ok(None),
356 };
357 let table: toml::Table = text
358 .parse()
359 .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
360 Ok(Some(table))
361}
362
363fn parse_fsync(t: &toml::Table) -> Result<Option<FsyncPolicy>, SettingsError> {
372 let Some(value) = t.get(MAINTENANCE_SETTING_KEYS[3]) else {
373 return Ok(None);
374 };
375 let name = value.as_str().ok_or_else(|| {
376 SettingsError::config("[maintenance].fsync must be \"each_op\" or \"on_snapshot\"")
377 })?;
378 match name {
379 "each_op" => Ok(Some(FsyncPolicy::EachOp)),
380 "on_snapshot" => Ok(Some(FsyncPolicy::OnSnapshot)),
381 other => Err(SettingsError::config(format!(
382 "[maintenance].fsync must be \"each_op\" or \"on_snapshot\", got \"{other}\""
383 ))),
384 }
385}
386
387pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
388 t.get(key)
389 .and_then(toml::Value::as_integer)
390 .filter(|n| *n >= 0)
391 .map(|n| n as u64)
392}
393
394fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
396 if let Some(p) = flag {
397 return std::fs::read_to_string(p)
398 .map(Some)
399 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
400 }
401 let candidate = std::env::var_os(ENV_CONFIG)
402 .map(PathBuf::from)
403 .or_else(crate::default_config_path);
404 match candidate {
405 Some(p) if p.exists() => std::fs::read_to_string(&p)
406 .map(Some)
407 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
408 _ => Ok(None),
409 }
410}
411
412fn setting_uint(t: &toml::Table, section: &str, key: &str) -> Result<Option<i64>, SettingsError> {
414 let Some(v) = t.get(key) else {
415 return Ok(None);
416 };
417 v.as_integer().filter(|n| *n >= 0).map(Some).ok_or_else(|| {
418 SettingsError::config(format!("[{section}].{key} must be a non-negative integer"))
419 })
420}
421
422fn setting_f32(t: &toml::Table, section: &str, key: &str) -> Result<Option<f32>, SettingsError> {
427 let Some(v) = t.get(key) else {
428 return Ok(None);
429 };
430 v.as_float()
431 .or_else(|| v.as_integer().map(|n| n as f64))
432 .map(|n| Some(n as f32))
433 .ok_or_else(|| SettingsError::config(format!("[{section}].{key} must be a number")))
434}
435
436fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
439 let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
440 (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
441 (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
442 (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
443 (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
444 ];
445 for (key, slot) in fields {
446 if let Some(n) = setting_uint(t, "engine", key)? {
447 *slot = n as usize;
448 }
449 }
450 Ok(())
451}
452
453fn apply_recall(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
456 let floats: [(&str, &mut f32); 10] = [
457 (RECALL_SETTING_KEYS[0], &mut cfg.bm25_k1),
458 (RECALL_SETTING_KEYS[1], &mut cfg.bm25_b),
459 (RECALL_SETTING_KEYS[3], &mut cfg.w_bm25),
460 (RECALL_SETTING_KEYS[4], &mut cfg.w_vec),
461 (RECALL_SETTING_KEYS[5], &mut cfg.w_graph),
462 (RECALL_SETTING_KEYS[6], &mut cfg.w_time),
463 (RECALL_SETTING_KEYS[7], &mut cfg.w_recency),
464 (RECALL_SETTING_KEYS[10], &mut cfg.graph_decay),
465 (RECALL_SETTING_KEYS[12], &mut cfg.similar_cos),
466 (RECALL_SETTING_KEYS[13], &mut cfg.similar_jaccard),
467 ];
468 for (key, slot) in floats {
469 if let Some(v) = setting_f32(t, "recall", key)? {
470 *slot = v;
471 }
472 }
473 let uints: [(&str, &mut u32); 3] = [
474 (RECALL_SETTING_KEYS[2], &mut cfg.rrf_k),
475 (RECALL_SETTING_KEYS[8], &mut cfg.half_life_days),
476 (RECALL_SETTING_KEYS[9], &mut cfg.graph_depth),
477 ];
478 for (key, slot) in uints {
479 if let Some(n) = setting_uint(t, "recall", key)? {
480 *slot = n as u32;
481 }
482 }
483 if let Some(n) = setting_uint(t, "recall", RECALL_SETTING_KEYS[11])? {
484 cfg.hnsw_ef_search = n as usize;
485 }
486 Ok(())
487}
488
489fn apply_index(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
491 let fields: [(&str, &mut usize); INDEX_SETTING_KEYS.len()] = [
492 (INDEX_SETTING_KEYS[0], &mut cfg.hnsw_ef_construction),
493 (INDEX_SETTING_KEYS[1], &mut cfg.flat_to_hnsw),
494 ];
495 for (key, slot) in fields {
496 if let Some(n) = setting_uint(t, "index", key)? {
497 *slot = n as usize;
498 }
499 }
500 Ok(())
501}
502
503#[derive(Default)]
505struct EmbedderCfg {
506 enabled: Option<bool>,
507 url: Option<String>,
508 model: Option<String>,
509 space_id: Option<String>,
510 api_key_env: Option<String>,
511}
512
513impl EmbedderCfg {
514 fn merge(&mut self, t: &toml::Table) -> Result<(), SettingsError> {
515 let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
516 if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[0]) {
517 self.enabled =
518 Some(value.as_bool().ok_or_else(|| {
519 SettingsError::config("[embedder].enabled must be a boolean")
520 })?);
521 }
522 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
523 self.url = Some(v);
524 }
525 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
526 self.model = Some(v);
527 }
528 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
529 self.space_id = Some(v);
530 }
531 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[4]) {
532 self.api_key_env = Some(v);
533 }
534 Ok(())
535 }
536
537 fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
543 let enabled = self
544 .enabled
545 .unwrap_or(self.url.is_some() || self.model.is_some());
546 if !enabled {
547 return Ok(None);
548 }
549 let url = self
550 .url
551 .clone()
552 .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a URL"))?;
553 let model = self
554 .model
555 .clone()
556 .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a model"))?;
557 if dim == 0 {
558 return Err(SettingsError::config(
559 "[embedder] requires [engine].dim > 0 (the embedding size)",
560 ));
561 }
562 let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
563 if let Some(space_id) = &self.space_id {
564 e = e.with_space_id(space_id);
565 }
566 if let Some(env) = &self.api_key_env
567 && let Some(key) = std::env::var_os(env)
568 {
569 e = e.with_api_key(key.to_string_lossy().into_owned());
570 }
571 Ok(Some(Box::new(e)))
572 }
573}
574
575fn parse_embedder_enabled(value: &str) -> Result<bool, SettingsError> {
576 match value {
577 "true" => Ok(true),
578 "false" => Ok(false),
579 other => Err(SettingsError::config(format!(
580 "{ENV_EMBEDDER_ENABLED} must be true or false, got \"{other}\""
581 ))),
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 fn toml_of(lines: &[&str]) -> toml::Table {
592 lines.join("\n").parse().expect("valid TOML fixture")
593 }
594
595 struct TempDir(PathBuf);
597 impl TempDir {
598 fn new(tag: &str) -> Self {
599 let dir = std::env::temp_dir().join(format!(
600 "plugmem-settings-{tag}-{}-{}",
601 std::process::id(),
602 std::time::SystemTime::now()
603 .duration_since(std::time::UNIX_EPOCH)
604 .unwrap()
605 .as_nanos()
606 ));
607 std::fs::create_dir_all(&dir).unwrap();
608 TempDir(dir)
609 }
610 }
611 impl Drop for TempDir {
612 fn drop(&mut self) {
613 let _ = std::fs::remove_dir_all(&self.0);
614 }
615 }
616
617 #[test]
618 fn engine_and_maintenance_parse() {
619 let table = toml_of(&[
620 "[engine]",
621 "dim = 384",
622 "max_text = 2048",
623 "[maintenance]",
624 "snapshot_every_ops = 50",
625 "snapshot_journal_bytes = 8192",
626 "maintain_every_forgets = 3",
627 ]);
628 let s = Settings::from_table(Some(&table)).unwrap();
629 assert_eq!(s.config.dim, 384);
630 assert_eq!(s.config.max_text, 2048);
631 assert_eq!(s.snapshot_every_ops, Some(50));
632 assert_eq!(s.snapshot_journal_bytes, Some(8192));
633 assert_eq!(s.maintain_every_forgets, Some(3));
634
635 let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
636 assert!(matches!(
637 Settings::from_table(Some(&bad)),
638 Err(SettingsError::Config(_))
639 ));
640 }
641
642 #[test]
643 fn defaults_when_no_table() {
644 let s = Settings::from_table(None).unwrap();
645 assert!(s.database_path.is_none());
646 assert_eq!(s.config.dim, Config::default().dim);
647 assert!(s.embedder.is_none());
648 assert_eq!(s.snapshot_every_ops, None);
649 }
650
651 #[test]
652 fn embedder_merge_reads_every_field() {
653 let table = toml_of(&[
654 "[embedder]",
655 "enabled = true",
656 r#"url = "http://localhost:11434/v1/embeddings""#,
657 r#"model = "nomic-embed-text""#,
658 r#"space_id = "nomic-embed-text@v1""#,
659 r#"api_key_env = "SOME_ENV""#,
660 "[engine]",
661 "dim = 8",
662 ]);
663 let s = Settings::from_table(Some(&table)).unwrap();
666 let embedder = s.embedder.unwrap();
667 assert_eq!(embedder.space_id(), "nomic-embed-text@v1");
668 assert_eq!(embedder.dim(), 8);
669 }
670
671 #[test]
672 fn database_path_reads_and_validates_from_config() {
673 let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
674 .parse()
675 .unwrap();
676 let settings = Settings::from_table(Some(&table)).unwrap();
677 assert_eq!(
678 settings.database_path.as_deref(),
679 Some(std::path::Path::new("/tmp/memory.plugmem"))
680 );
681
682 let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
683 assert!(matches!(
684 Settings::from_table(Some(&bad)),
685 Err(SettingsError::Config(message)) if message == "[database].path must be a string"
686 ));
687 }
688
689 #[test]
690 fn settings_open_applies_maintenance_and_embedder() {
691 let tmp = TempDir::new("open");
695 let mut config = Config::default();
696 config.dim = 8;
697 let embedder = EmbedderCfg {
698 enabled: Some(true),
699 url: Some("http://127.0.0.1:0/v1/embeddings".into()),
700 model: Some("m".into()),
701 space_id: None,
702 api_key_env: None,
703 }
704 .build(8)
705 .unwrap();
706 assert!(embedder.is_some());
707 let settings = Settings {
708 database_path: None,
709 config,
710 embedder,
711 snapshot_every_ops: Some(4),
712 snapshot_journal_bytes: Some(4096),
713 maintain_every_forgets: Some(2),
714 fsync: Some(FsyncPolicy::OnSnapshot),
715 workspace: WorkspaceSettings {
716 dir: None,
717 limits: WorkspaceLimits::default(),
718 },
719 warnings: Vec::new(),
720 };
721 let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
722 assert_eq!(db.stats().facts, 0);
723 }
724
725 #[test]
726 fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
727 let bare = Settings::from_table(None).unwrap();
730 assert_eq!(bare.workspace.dir, None);
731 assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
732
733 let table: toml::Table =
734 "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
735 .parse()
736 .unwrap();
737 let s = Settings::from_table(Some(&table)).unwrap();
738 assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
739 assert_eq!(s.workspace.limits.max_open, 4);
740 assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
741
742 let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
744 let s = Settings::from_table(Some(&only_dir)).unwrap();
745 assert_eq!(s.workspace.limits, WorkspaceLimits::default());
746 }
747
748 #[test]
749 fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
750 for bad in [
753 "[workspace]\nmax_open = 0\n".to_string(),
754 format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
755 "[workspace]\nmax_open = 9999999999\n".to_string(),
758 ] {
759 let table: toml::Table = bad.parse().unwrap();
760 assert!(
761 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
762 "{bad}"
763 );
764 }
765
766 for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
767 let table: toml::Table = bad.parse().unwrap();
768 assert!(
769 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
770 "{bad}"
771 );
772 }
773
774 let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
776 .parse()
777 .unwrap();
778 let s = Settings::from_table(Some(&table)).unwrap();
779 assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
780 }
781
782 #[test]
783 fn open_workspace_builds_databases_from_the_same_settings() {
784 let tmp = TempDir::new("open-workspace");
785 let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
786 snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
787 .parse()
788 .unwrap();
789 let settings = Settings::from_table(Some(&table)).unwrap();
790 let ws = settings.open_workspace(&tmp.0).unwrap();
791
792 let name = crate::DbName::parse("chat-42").unwrap();
793 let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
794 db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
795 .unwrap();
796 assert_eq!(db.stats().facts, 1);
797 assert!(ws.layout().exists(&name));
798 }
799
800 #[test]
801 fn fsync_policy_is_named_and_a_misspelling_is_refused() {
802 let parse = |body: &str| {
803 let table: toml::Table = body.parse().unwrap();
804 let t = table.get("maintenance").unwrap().as_table().unwrap();
805 parse_fsync(t)
806 };
807
808 assert_eq!(
809 parse("[maintenance]\n").unwrap(),
810 None,
811 "absent stays default"
812 );
813 assert_eq!(
814 parse("[maintenance]\nfsync = \"each_op\"\n").unwrap(),
815 Some(FsyncPolicy::EachOp)
816 );
817 assert_eq!(
818 parse("[maintenance]\nfsync = \"on_snapshot\"\n").unwrap(),
819 Some(FsyncPolicy::OnSnapshot)
820 );
821
822 for bad in [
825 "[maintenance]\nfsync = \"on-snapshot\"\n",
826 "[maintenance]\nfsync = \"none\"\n",
827 "[maintenance]\nfsync = true\n",
828 "[maintenance]\nfsync = 1\n",
829 ] {
830 let Err(err) = parse(bad) else {
831 panic!("{bad:?} must be refused");
832 };
833 assert!(
834 err.to_string().contains("each_op"),
835 "the message names the legal values: {err}"
836 );
837 }
838 }
839
840 #[test]
841 fn fsync_reaches_settings_from_the_config_file() {
842 let table: toml::Table = "[maintenance]\nfsync = \"on_snapshot\"\n".parse().unwrap();
846 let settings = Settings::from_table(Some(&table)).unwrap();
847 assert_eq!(settings.fsync, Some(FsyncPolicy::OnSnapshot));
848
849 let plain = Settings::from_table(None).unwrap();
850 assert_eq!(plain.fsync, None, "no config means the engine default");
851 }
852
853 #[test]
854 fn embedder_build_rules() {
855 assert!(EmbedderCfg::default().build(0).unwrap().is_none());
856 let no_url = EmbedderCfg {
857 enabled: Some(true),
858 ..Default::default()
859 };
860 assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
861 let no_model = EmbedderCfg {
862 enabled: Some(true),
863 url: Some("http://x/v1/embeddings".into()),
864 ..Default::default()
865 };
866 assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
867 let zero_dim = EmbedderCfg {
868 enabled: Some(true),
869 url: Some("http://x/v1/embeddings".into()),
870 model: Some("m".into()),
871 space_id: None,
872 api_key_env: None,
873 };
874 assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
875 let ok = EmbedderCfg {
876 enabled: None,
877 url: Some("http://x/v1/embeddings".into()),
878 model: Some("m".into()),
879 space_id: None,
880 api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
881 };
882 assert!(ok.build(384).unwrap().is_some());
883 let disabled = EmbedderCfg {
884 enabled: Some(false),
885 url: Some("http://x/v1/embeddings".into()),
886 model: Some("m".into()),
887 ..Default::default()
888 };
889 assert!(disabled.build(0).unwrap().is_none());
890 assert!(parse_embedder_enabled("true").unwrap());
891 assert!(!parse_embedder_enabled("false").unwrap());
892 assert!(parse_embedder_enabled("ollama").is_err());
893 }
894
895 #[test]
896 fn load_reads_the_config_file() {
897 let tmp = TempDir::new("load");
898 let cfgfile = tmp.0.join("config.toml");
899 std::fs::write(
900 &cfgfile,
901 "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nenabled = false\n[maintenance]\nsnapshot_every_ops = 64\n",
902 )
903 .unwrap();
904 let s = Settings::load(Some(&cfgfile)).unwrap();
905 assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
906 assert_eq!(s.config.dim, 512);
907 assert!(s.embedder.is_none());
908 assert_eq!(s.snapshot_every_ops, Some(64));
909
910 assert!(matches!(
912 Settings::load(Some(&tmp.0.join("nope.toml"))),
913 Err(SettingsError::Config(_))
914 ));
915 }
916
917 #[test]
918 fn read_config_none_and_batch_extra() {
919 let tmp = TempDir::new("extra");
921 let missing = tmp.0.join("absent.toml");
922 assert!(read_config(Some(&missing)).is_err());
925
926 let cfgfile = tmp.0.join("config.toml");
927 std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
928 let table = read_config(Some(&cfgfile)).unwrap().unwrap();
929 let batch = table
930 .get("maintenance")
931 .and_then(toml::Value::as_table)
932 .and_then(|m| table_u64(m, "batch_size"));
933 assert_eq!(batch, Some(256));
934 }
935
936 #[test]
937 fn every_tuning_key_actually_reaches_the_config() {
938 let cfg = Config::default();
945 let table = toml_of(&[
946 "[recall]",
947 "bm25_k1 = 2.5",
948 "bm25_b = 0.25",
949 "rrf_k = 17",
950 "w_bm25 = 3.0",
951 "w_vec = 4.0",
952 "w_graph = 5.0",
953 "w_time = 6.0",
954 "w_recency = 0.75",
955 "half_life_days = 7",
956 "graph_depth = 4",
957 "graph_decay = 0.125",
958 "hnsw_ef_search = 111",
959 "similar_cos = 0.31",
960 "similar_jaccard = 0.32",
961 "[index]",
962 "hnsw_ef_construction = 222",
963 "flat_to_hnsw = 333",
964 ]);
965 let s = Settings::from_table(Some(&table)).unwrap();
966
967 assert_eq!(s.config.bm25_k1, 2.5);
968 assert_eq!(s.config.bm25_b, 0.25);
969 assert_eq!(s.config.rrf_k, 17);
970 assert_eq!(s.config.w_bm25, 3.0);
971 assert_eq!(s.config.w_vec, 4.0);
972 assert_eq!(s.config.w_graph, 5.0);
973 assert_eq!(s.config.w_time, 6.0);
974 assert_eq!(s.config.w_recency, 0.75);
975 assert_eq!(s.config.half_life_days, 7);
976 assert_eq!(s.config.graph_depth, 4);
977 assert_eq!(s.config.graph_decay, 0.125);
978 assert_eq!(s.config.hnsw_ef_search, 111);
979 assert_eq!(s.config.similar_cos, 0.31);
980 assert_eq!(s.config.similar_jaccard, 0.32);
981 assert_eq!(s.config.hnsw_ef_construction, 222);
982 assert_eq!(s.config.flat_to_hnsw, 333);
983
984 assert_ne!(s.config.bm25_k1, cfg.bm25_k1);
987 assert_ne!(s.config.flat_to_hnsw, cfg.flat_to_hnsw);
988 assert!(s.warnings.is_empty(), "{:?}", s.warnings);
989 }
990
991 #[test]
992 fn an_integer_is_accepted_where_a_float_is_meant() {
993 let table = toml_of(&["[recall]", "w_vec = 2", "graph_decay = 1"]);
997 let s = Settings::from_table(Some(&table)).unwrap();
998 assert_eq!(s.config.w_vec, 2.0);
999 assert_eq!(s.config.graph_decay, 1.0);
1000 }
1001
1002 #[test]
1003 fn a_tuning_value_out_of_range_is_refused_by_name() {
1004 for line in ["graph_decay = 2.0", "similar_cos = -1.0", "w_vec = -0.5"] {
1008 let table = toml_of(&["[recall]", line]);
1009 let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
1010 panic!("{line} must be refused");
1011 };
1012 let field = line.split(' ').next().unwrap();
1013 assert!(
1014 message.contains(field),
1015 "the message must name the offending field: {message}"
1016 );
1017 }
1018
1019 let table = toml_of(&["[recall]", r#"w_vec = "lots""#]);
1022 let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
1023 panic!("a string weight must be refused");
1024 };
1025 assert!(message.contains("[recall].w_vec"), "{message}");
1026 }
1027
1028 #[test]
1029 fn every_host_setting_is_documented() {
1030 let docs = crate::settings_help::settings_help().docs();
1031 for (section, keys) in [
1032 ("database", DATABASE_SETTING_KEYS),
1033 ("workspace", WORKSPACE_SETTING_KEYS),
1034 ("engine", ENGINE_SETTING_KEYS),
1035 ("recall", RECALL_SETTING_KEYS),
1036 ("index", INDEX_SETTING_KEYS),
1037 ("embedder", EMBEDDER_SETTING_KEYS),
1038 ("maintenance", MAINTENANCE_SETTING_KEYS),
1039 ] {
1040 let documented: Vec<_> = docs
1041 .iter()
1042 .filter(|doc| {
1043 doc.section == section
1044 && doc.scope == crate::settings_help::SettingScope::Shared
1045 })
1046 .map(|doc| doc.key)
1047 .collect();
1048 assert_eq!(
1049 documented.as_slice(),
1050 keys,
1051 "undocumented {section} setting"
1052 );
1053 }
1054 }
1055}