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] = &["enabled", "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(enabled) = std::env::var_os(ENV_EMBEDDER_ENABLED) {
217 embedder.enabled = Some(parse_embedder_enabled(&enabled.to_string_lossy())?);
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 enabled: Option<bool>,
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) -> Result<(), SettingsError> {
513 let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
514 if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[0]) {
515 self.enabled =
516 Some(value.as_bool().ok_or_else(|| {
517 SettingsError::config("[embedder].enabled must be a boolean")
518 })?);
519 }
520 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
521 self.url = Some(v);
522 }
523 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
524 self.model = Some(v);
525 }
526 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
527 self.api_key_env = Some(v);
528 }
529 Ok(())
530 }
531
532 fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
538 let enabled = self
539 .enabled
540 .unwrap_or(self.url.is_some() || self.model.is_some());
541 if !enabled {
542 return Ok(None);
543 }
544 let url = self
545 .url
546 .clone()
547 .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a URL"))?;
548 let model = self
549 .model
550 .clone()
551 .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a model"))?;
552 if dim == 0 {
553 return Err(SettingsError::config(
554 "[embedder] requires [engine].dim > 0 (the embedding size)",
555 ));
556 }
557 let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
558 if let Some(env) = &self.api_key_env
559 && let Some(key) = std::env::var_os(env)
560 {
561 e = e.with_api_key(key.to_string_lossy().into_owned());
562 }
563 Ok(Some(Box::new(e)))
564 }
565}
566
567fn parse_embedder_enabled(value: &str) -> Result<bool, SettingsError> {
568 match value {
569 "true" => Ok(true),
570 "false" => Ok(false),
571 other => Err(SettingsError::config(format!(
572 "{ENV_EMBEDDER_ENABLED} must be true or false, got \"{other}\""
573 ))),
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 fn toml_of(lines: &[&str]) -> toml::Table {
584 lines.join("\n").parse().expect("valid TOML fixture")
585 }
586
587 struct TempDir(PathBuf);
589 impl TempDir {
590 fn new(tag: &str) -> Self {
591 let dir = std::env::temp_dir().join(format!(
592 "plugmem-settings-{tag}-{}-{}",
593 std::process::id(),
594 std::time::SystemTime::now()
595 .duration_since(std::time::UNIX_EPOCH)
596 .unwrap()
597 .as_nanos()
598 ));
599 std::fs::create_dir_all(&dir).unwrap();
600 TempDir(dir)
601 }
602 }
603 impl Drop for TempDir {
604 fn drop(&mut self) {
605 let _ = std::fs::remove_dir_all(&self.0);
606 }
607 }
608
609 #[test]
610 fn engine_and_maintenance_parse() {
611 let table = toml_of(&[
612 "[engine]",
613 "dim = 384",
614 "max_text = 2048",
615 "[maintenance]",
616 "snapshot_every_ops = 50",
617 "snapshot_journal_bytes = 8192",
618 "maintain_every_forgets = 3",
619 ]);
620 let s = Settings::from_table(Some(&table)).unwrap();
621 assert_eq!(s.config.dim, 384);
622 assert_eq!(s.config.max_text, 2048);
623 assert_eq!(s.snapshot_every_ops, Some(50));
624 assert_eq!(s.snapshot_journal_bytes, Some(8192));
625 assert_eq!(s.maintain_every_forgets, Some(3));
626
627 let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
628 assert!(matches!(
629 Settings::from_table(Some(&bad)),
630 Err(SettingsError::Config(_))
631 ));
632 }
633
634 #[test]
635 fn defaults_when_no_table() {
636 let s = Settings::from_table(None).unwrap();
637 assert!(s.database_path.is_none());
638 assert_eq!(s.config.dim, Config::default().dim);
639 assert!(s.embedder.is_none());
640 assert_eq!(s.snapshot_every_ops, None);
641 }
642
643 #[test]
644 fn embedder_merge_reads_every_field() {
645 let table = toml_of(&[
646 "[embedder]",
647 "enabled = true",
648 r#"url = "http://localhost:11434/v1/embeddings""#,
649 r#"model = "nomic-embed-text""#,
650 r#"api_key_env = "SOME_ENV""#,
651 "[engine]",
652 "dim = 8",
653 ]);
654 let s = Settings::from_table(Some(&table)).unwrap();
657 assert!(s.embedder.is_some());
658 }
659
660 #[test]
661 fn database_path_reads_and_validates_from_config() {
662 let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
663 .parse()
664 .unwrap();
665 let settings = Settings::from_table(Some(&table)).unwrap();
666 assert_eq!(
667 settings.database_path.as_deref(),
668 Some(std::path::Path::new("/tmp/memory.plugmem"))
669 );
670
671 let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
672 assert!(matches!(
673 Settings::from_table(Some(&bad)),
674 Err(SettingsError::Config(message)) if message == "[database].path must be a string"
675 ));
676 }
677
678 #[test]
679 fn settings_open_applies_maintenance_and_embedder() {
680 let tmp = TempDir::new("open");
684 let mut config = Config::default();
685 config.dim = 8;
686 let embedder = EmbedderCfg {
687 enabled: Some(true),
688 url: Some("http://127.0.0.1:0/v1/embeddings".into()),
689 model: Some("m".into()),
690 api_key_env: None,
691 }
692 .build(8)
693 .unwrap();
694 assert!(embedder.is_some());
695 let settings = Settings {
696 database_path: None,
697 config,
698 embedder,
699 snapshot_every_ops: Some(4),
700 snapshot_journal_bytes: Some(4096),
701 maintain_every_forgets: Some(2),
702 fsync: Some(FsyncPolicy::OnSnapshot),
703 workspace: WorkspaceSettings {
704 dir: None,
705 limits: WorkspaceLimits::default(),
706 },
707 warnings: Vec::new(),
708 };
709 let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
710 assert_eq!(db.stats().facts, 0);
711 }
712
713 #[test]
714 fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
715 let bare = Settings::from_table(None).unwrap();
718 assert_eq!(bare.workspace.dir, None);
719 assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
720
721 let table: toml::Table =
722 "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
723 .parse()
724 .unwrap();
725 let s = Settings::from_table(Some(&table)).unwrap();
726 assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
727 assert_eq!(s.workspace.limits.max_open, 4);
728 assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
729
730 let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
732 let s = Settings::from_table(Some(&only_dir)).unwrap();
733 assert_eq!(s.workspace.limits, WorkspaceLimits::default());
734 }
735
736 #[test]
737 fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
738 for bad in [
741 "[workspace]\nmax_open = 0\n".to_string(),
742 format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
743 "[workspace]\nmax_open = 9999999999\n".to_string(),
746 ] {
747 let table: toml::Table = bad.parse().unwrap();
748 assert!(
749 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
750 "{bad}"
751 );
752 }
753
754 for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
755 let table: toml::Table = bad.parse().unwrap();
756 assert!(
757 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
758 "{bad}"
759 );
760 }
761
762 let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
764 .parse()
765 .unwrap();
766 let s = Settings::from_table(Some(&table)).unwrap();
767 assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
768 }
769
770 #[test]
771 fn open_workspace_builds_databases_from_the_same_settings() {
772 let tmp = TempDir::new("open-workspace");
773 let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
774 snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
775 .parse()
776 .unwrap();
777 let settings = Settings::from_table(Some(&table)).unwrap();
778 let ws = settings.open_workspace(&tmp.0).unwrap();
779
780 let name = crate::DbName::parse("chat-42").unwrap();
781 let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
782 db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
783 .unwrap();
784 assert_eq!(db.stats().facts, 1);
785 assert!(ws.layout().exists(&name));
786 }
787
788 #[test]
789 fn fsync_policy_is_named_and_a_misspelling_is_refused() {
790 let parse = |body: &str| {
791 let table: toml::Table = body.parse().unwrap();
792 let t = table.get("maintenance").unwrap().as_table().unwrap();
793 parse_fsync(t)
794 };
795
796 assert_eq!(
797 parse("[maintenance]\n").unwrap(),
798 None,
799 "absent stays default"
800 );
801 assert_eq!(
802 parse("[maintenance]\nfsync = \"each_op\"\n").unwrap(),
803 Some(FsyncPolicy::EachOp)
804 );
805 assert_eq!(
806 parse("[maintenance]\nfsync = \"on_snapshot\"\n").unwrap(),
807 Some(FsyncPolicy::OnSnapshot)
808 );
809
810 for bad in [
813 "[maintenance]\nfsync = \"on-snapshot\"\n",
814 "[maintenance]\nfsync = \"none\"\n",
815 "[maintenance]\nfsync = true\n",
816 "[maintenance]\nfsync = 1\n",
817 ] {
818 let Err(err) = parse(bad) else {
819 panic!("{bad:?} must be refused");
820 };
821 assert!(
822 err.to_string().contains("each_op"),
823 "the message names the legal values: {err}"
824 );
825 }
826 }
827
828 #[test]
829 fn fsync_reaches_settings_from_the_config_file() {
830 let table: toml::Table = "[maintenance]\nfsync = \"on_snapshot\"\n".parse().unwrap();
834 let settings = Settings::from_table(Some(&table)).unwrap();
835 assert_eq!(settings.fsync, Some(FsyncPolicy::OnSnapshot));
836
837 let plain = Settings::from_table(None).unwrap();
838 assert_eq!(plain.fsync, None, "no config means the engine default");
839 }
840
841 #[test]
842 fn embedder_build_rules() {
843 assert!(EmbedderCfg::default().build(0).unwrap().is_none());
844 let no_url = EmbedderCfg {
845 enabled: Some(true),
846 ..Default::default()
847 };
848 assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
849 let no_model = EmbedderCfg {
850 enabled: Some(true),
851 url: Some("http://x/v1/embeddings".into()),
852 ..Default::default()
853 };
854 assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
855 let zero_dim = EmbedderCfg {
856 enabled: Some(true),
857 url: Some("http://x/v1/embeddings".into()),
858 model: Some("m".into()),
859 api_key_env: None,
860 };
861 assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
862 let ok = EmbedderCfg {
863 enabled: None,
864 url: Some("http://x/v1/embeddings".into()),
865 model: Some("m".into()),
866 api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
867 };
868 assert!(ok.build(384).unwrap().is_some());
869 let disabled = EmbedderCfg {
870 enabled: Some(false),
871 url: Some("http://x/v1/embeddings".into()),
872 model: Some("m".into()),
873 ..Default::default()
874 };
875 assert!(disabled.build(0).unwrap().is_none());
876 assert!(parse_embedder_enabled("true").unwrap());
877 assert!(!parse_embedder_enabled("false").unwrap());
878 assert!(parse_embedder_enabled("ollama").is_err());
879 }
880
881 #[test]
882 fn load_reads_the_config_file() {
883 let tmp = TempDir::new("load");
884 let cfgfile = tmp.0.join("config.toml");
885 std::fs::write(
886 &cfgfile,
887 "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nenabled = false\n[maintenance]\nsnapshot_every_ops = 64\n",
888 )
889 .unwrap();
890 let s = Settings::load(Some(&cfgfile)).unwrap();
891 assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
892 assert_eq!(s.config.dim, 512);
893 assert!(s.embedder.is_none());
894 assert_eq!(s.snapshot_every_ops, Some(64));
895
896 assert!(matches!(
898 Settings::load(Some(&tmp.0.join("nope.toml"))),
899 Err(SettingsError::Config(_))
900 ));
901 }
902
903 #[test]
904 fn read_config_none_and_batch_extra() {
905 let tmp = TempDir::new("extra");
907 let missing = tmp.0.join("absent.toml");
908 assert!(read_config(Some(&missing)).is_err());
911
912 let cfgfile = tmp.0.join("config.toml");
913 std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
914 let table = read_config(Some(&cfgfile)).unwrap().unwrap();
915 let batch = table
916 .get("maintenance")
917 .and_then(toml::Value::as_table)
918 .and_then(|m| table_u64(m, "batch_size"));
919 assert_eq!(batch, Some(256));
920 }
921
922 #[test]
923 fn every_tuning_key_actually_reaches_the_config() {
924 let cfg = Config::default();
931 let table = toml_of(&[
932 "[recall]",
933 "bm25_k1 = 2.5",
934 "bm25_b = 0.25",
935 "rrf_k = 17",
936 "w_bm25 = 3.0",
937 "w_vec = 4.0",
938 "w_graph = 5.0",
939 "w_time = 6.0",
940 "w_recency = 0.75",
941 "half_life_days = 7",
942 "graph_depth = 4",
943 "graph_decay = 0.125",
944 "hnsw_ef_search = 111",
945 "similar_cos = 0.31",
946 "similar_jaccard = 0.32",
947 "[index]",
948 "hnsw_ef_construction = 222",
949 "flat_to_hnsw = 333",
950 ]);
951 let s = Settings::from_table(Some(&table)).unwrap();
952
953 assert_eq!(s.config.bm25_k1, 2.5);
954 assert_eq!(s.config.bm25_b, 0.25);
955 assert_eq!(s.config.rrf_k, 17);
956 assert_eq!(s.config.w_bm25, 3.0);
957 assert_eq!(s.config.w_vec, 4.0);
958 assert_eq!(s.config.w_graph, 5.0);
959 assert_eq!(s.config.w_time, 6.0);
960 assert_eq!(s.config.w_recency, 0.75);
961 assert_eq!(s.config.half_life_days, 7);
962 assert_eq!(s.config.graph_depth, 4);
963 assert_eq!(s.config.graph_decay, 0.125);
964 assert_eq!(s.config.hnsw_ef_search, 111);
965 assert_eq!(s.config.similar_cos, 0.31);
966 assert_eq!(s.config.similar_jaccard, 0.32);
967 assert_eq!(s.config.hnsw_ef_construction, 222);
968 assert_eq!(s.config.flat_to_hnsw, 333);
969
970 assert_ne!(s.config.bm25_k1, cfg.bm25_k1);
973 assert_ne!(s.config.flat_to_hnsw, cfg.flat_to_hnsw);
974 assert!(s.warnings.is_empty(), "{:?}", s.warnings);
975 }
976
977 #[test]
978 fn an_integer_is_accepted_where_a_float_is_meant() {
979 let table = toml_of(&["[recall]", "w_vec = 2", "graph_decay = 1"]);
983 let s = Settings::from_table(Some(&table)).unwrap();
984 assert_eq!(s.config.w_vec, 2.0);
985 assert_eq!(s.config.graph_decay, 1.0);
986 }
987
988 #[test]
989 fn a_tuning_value_out_of_range_is_refused_by_name() {
990 for line in ["graph_decay = 2.0", "similar_cos = -1.0", "w_vec = -0.5"] {
994 let table = toml_of(&["[recall]", line]);
995 let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
996 panic!("{line} must be refused");
997 };
998 let field = line.split(' ').next().unwrap();
999 assert!(
1000 message.contains(field),
1001 "the message must name the offending field: {message}"
1002 );
1003 }
1004
1005 let table = toml_of(&["[recall]", r#"w_vec = "lots""#]);
1008 let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
1009 panic!("a string weight must be refused");
1010 };
1011 assert!(message.contains("[recall].w_vec"), "{message}");
1012 }
1013
1014 #[test]
1015 fn every_host_setting_is_documented() {
1016 let docs = crate::settings_help::settings_help().docs();
1017 for (section, keys) in [
1018 ("database", DATABASE_SETTING_KEYS),
1019 ("workspace", WORKSPACE_SETTING_KEYS),
1020 ("engine", ENGINE_SETTING_KEYS),
1021 ("recall", RECALL_SETTING_KEYS),
1022 ("index", INDEX_SETTING_KEYS),
1023 ("embedder", EMBEDDER_SETTING_KEYS),
1024 ("maintenance", MAINTENANCE_SETTING_KEYS),
1025 ] {
1026 let documented: Vec<_> = docs
1027 .iter()
1028 .filter(|doc| {
1029 doc.section == section
1030 && doc.scope == crate::settings_help::SettingScope::Shared
1031 })
1032 .map(|doc| doc.key)
1033 .collect();
1034 assert_eq!(
1035 documented.as_slice(),
1036 keys,
1037 "undocumented {section} setting"
1038 );
1039 }
1040 }
1041}