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