1use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use crate::{
31 Config, Database, DatabaseBuilder, EmbedErrorPolicy, EmbedRetry, Embedder, FsyncPolicy,
32 HostError, MAX_OPEN_CEILING, OpenAiCompatEmbedder, Opener, SettingWarning, SharedEmbedder,
33 Workspace, WorkspaceLayout, WorkspaceLimits, settings_help::settings_help,
34};
35
36const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
38const ENV_EMBEDDER_ENABLED: &str = "PLUGMEM_EMBEDDER_ENABLED";
40const ENV_EMBEDDER_ON_ERROR: &str = "PLUGMEM_EMBEDDER_ON_ERROR";
42const ENV_EMBEDDER_TIMEOUT_MS: &str = "PLUGMEM_EMBEDDER_TIMEOUT_MS";
44const ENV_EMBEDDER_RETRY_AFTER_MS: &str = "PLUGMEM_EMBEDDER_RETRY_AFTER_MS";
46const ENV_EMBEDDER_RETRY_MAX_MS: &str = "PLUGMEM_EMBEDDER_RETRY_MAX_MS";
48pub(crate) const ENGINE_SETTING_KEYS: &[&str] = &["dim", "max_bytes", "max_text", "max_blob"];
52pub(crate) const RECALL_SETTING_KEYS: &[&str] = &[
60 "bm25_k1",
61 "bm25_b",
62 "rrf_k",
63 "w_bm25",
64 "w_vec",
65 "w_graph",
66 "w_time",
67 "w_recency",
68 "half_life_days",
69 "graph_depth",
70 "graph_decay",
71 "hnsw_ef_search",
72 "similar_cos",
73 "similar_jaccard",
74];
75pub(crate) const INDEX_SETTING_KEYS: &[&str] = &["hnsw_ef_construction", "flat_to_hnsw"];
77pub(crate) const DATABASE_SETTING_KEYS: &[&str] = &["path"];
78pub(crate) const WORKSPACE_SETTING_KEYS: &[&str] = &["dir", "max_open", "idle_timeout_ms"];
79pub(crate) const EMBEDDER_SETTING_KEYS: &[&str] = &[
80 "enabled",
81 "url",
82 "model",
83 "space_id",
84 "api_key_env",
85 "on_error",
86 "timeout_ms",
87 "retry_after_ms",
88 "retry_max_ms",
89];
90pub(crate) const MAINTENANCE_SETTING_KEYS: &[&str] = &[
91 "snapshot_every_ops",
92 "snapshot_journal_bytes",
93 "maintain_every_forgets",
94 "fsync",
95];
96
97#[derive(Debug, thiserror::Error)]
101#[non_exhaustive]
102pub enum SettingsError {
103 #[error("{0}")]
105 Config(String),
106}
107
108impl SettingsError {
109 fn config(msg: impl Into<String>) -> Self {
110 SettingsError::Config(msg.into())
111 }
112}
113
114pub struct Settings {
118 pub database_path: Option<PathBuf>,
121 pub config: Config,
123 pub embedder: Option<Box<dyn Embedder>>,
126 pub embed_error_policy: EmbedErrorPolicy,
130 pub embed_retry: EmbedRetry,
134 pub snapshot_every_ops: Option<u64>,
136 pub snapshot_journal_bytes: Option<u64>,
138 pub maintain_every_forgets: Option<u64>,
140 pub fsync: Option<FsyncPolicy>,
145 pub workspace: WorkspaceSettings,
149 pub warnings: Vec<SettingWarning>,
156}
157
158#[derive(Clone, Debug, PartialEq, Eq)]
161pub struct WorkspaceSettings {
162 pub dir: Option<PathBuf>,
165 pub limits: WorkspaceLimits,
167}
168
169impl Settings {
170 pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
174 let table = read_config(flag)?;
175 Settings::from_table(table.as_ref())
176 }
177
178 pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
183 let mut config = Config::default();
184 let mut database_path = None;
185 let mut embedder = EmbedderCfg::default();
186 let mut snapshot_every_ops = None;
187 let mut snapshot_journal_bytes = None;
188 let mut maintain_every_forgets = None;
189 let mut fsync = None;
190 let mut workspace = WorkspaceSettings {
191 dir: None,
192 limits: WorkspaceLimits::default(),
193 };
194 let warnings = table
195 .map(|t| settings_help().unknown_in(t))
196 .unwrap_or_default();
197
198 if let Some(table) = table {
199 if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
200 database_path = t
201 .get(DATABASE_SETTING_KEYS[0])
202 .map(|value| {
203 let path = value.as_str().ok_or_else(|| {
204 SettingsError::config("[database].path must be a string")
205 })?;
206 if path.is_empty() {
207 return Err(SettingsError::config("[database].path must not be empty"));
208 }
209 Ok(PathBuf::from(path))
210 })
211 .transpose()?;
212 }
213 if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
214 apply_engine(&mut config, t)?;
215 }
216 if let Some(t) = table.get("recall").and_then(toml::Value::as_table) {
217 apply_recall(&mut config, t)?;
218 }
219 if let Some(t) = table.get("index").and_then(toml::Value::as_table) {
220 apply_index(&mut config, t)?;
221 }
222 config
227 .validate()
228 .map_err(|e| SettingsError::config(format!("config.toml: {e}")))?;
229 if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
230 embedder.merge(t)?;
231 }
232 if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
233 snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
234 snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
235 maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
236 fsync = parse_fsync(t)?;
237 }
238 if let Some(t) = table.get("workspace").and_then(toml::Value::as_table) {
239 workspace = parse_workspace(t)?;
240 }
241 }
242
243 if let Some(enabled) = std::env::var_os(ENV_EMBEDDER_ENABLED) {
248 embedder.enabled = Some(parse_embedder_enabled(&enabled.to_string_lossy())?);
249 }
250 if let Some(policy) = std::env::var_os(ENV_EMBEDDER_ON_ERROR) {
251 embedder.on_error = Some(parse_on_error(&policy.to_string_lossy())?);
252 }
253 if let Some(ms) = std::env::var_os(ENV_EMBEDDER_TIMEOUT_MS) {
254 embedder.timeout = Some(parse_timeout_ms(&env_number(
255 &ms.to_string_lossy(),
256 ENV_EMBEDDER_TIMEOUT_MS,
257 )?));
258 }
259 if let Some(ms) = std::env::var_os(ENV_EMBEDDER_RETRY_AFTER_MS) {
260 embedder.retry_after_ms = Some(env_number(
261 &ms.to_string_lossy(),
262 ENV_EMBEDDER_RETRY_AFTER_MS,
263 )?);
264 }
265 if let Some(ms) = std::env::var_os(ENV_EMBEDDER_RETRY_MAX_MS) {
266 embedder.retry_max_ms = Some(env_number(
267 &ms.to_string_lossy(),
268 ENV_EMBEDDER_RETRY_MAX_MS,
269 )?);
270 }
271
272 let embed_error_policy = embedder.on_error.unwrap_or_default();
273 let embed_retry = embedder.retry();
274 let embedder = embedder.build(config.dim)?;
275 Ok(Settings {
276 database_path,
277 config,
278 embedder,
279 embed_error_policy,
280 embed_retry,
281 snapshot_every_ops,
282 snapshot_journal_bytes,
283 maintain_every_forgets,
284 fsync,
285 workspace,
286 warnings,
287 })
288 }
289
290 pub fn open(self, path: &Path) -> Result<Database, HostError> {
295 let mut b: DatabaseBuilder = Database::builder(self.config);
296 if let Some(v) = self.snapshot_every_ops {
297 b = b.snapshot_every_ops(v);
298 }
299 if let Some(v) = self.snapshot_journal_bytes {
300 b = b.snapshot_journal_bytes(v);
301 }
302 if let Some(v) = self.maintain_every_forgets {
303 b = b.maintain_every_forgets(v);
304 }
305 if let Some(v) = self.fsync {
306 b = b.fsync(v);
307 }
308 if let Some(e) = self.embedder {
309 b = b.embedder(e);
310 }
311 b = b
312 .on_embed_error(self.embed_error_policy)
313 .embed_retry(self.embed_retry);
314 Ok(b.open(path)?.0)
315 }
316
317 pub fn open_workspace(self, root: &Path) -> Result<Workspace, crate::WorkspaceError> {
334 let Settings {
335 config,
336 embedder,
337 embed_error_policy,
338 embed_retry,
339 snapshot_every_ops,
340 snapshot_journal_bytes,
341 maintain_every_forgets,
342 workspace,
343 ..
344 } = self;
345 let shared = embedder.map(SharedEmbedder::new);
346
347 let open: Opener = Box::new(move |path: &Path| {
348 let mut b = Database::builder(config.clone());
349 if let Some(v) = snapshot_every_ops {
350 b = b.snapshot_every_ops(v);
351 }
352 if let Some(v) = snapshot_journal_bytes {
353 b = b.snapshot_journal_bytes(v);
354 }
355 if let Some(v) = maintain_every_forgets {
356 b = b.maintain_every_forgets(v);
357 }
358 if let Some(e) = &shared {
359 b = b.embedder(Box::new(e.clone()));
360 }
361 b = b
366 .on_embed_error(embed_error_policy)
367 .embed_retry(embed_retry);
368 Ok(b.open(path)?.0)
369 });
370 Ok(Workspace::new(
371 WorkspaceLayout::new(root),
372 open,
373 workspace.limits,
374 ))
375 }
376}
377
378fn parse_workspace(t: &toml::Table) -> Result<WorkspaceSettings, SettingsError> {
382 let mut out = WorkspaceSettings {
383 dir: None,
384 limits: WorkspaceLimits::default(),
385 };
386 if let Some(value) = t.get(WORKSPACE_SETTING_KEYS[0]) {
387 let dir = value
388 .as_str()
389 .ok_or_else(|| SettingsError::config("[workspace].dir must be a string"))?;
390 if dir.is_empty() {
391 return Err(SettingsError::config("[workspace].dir must not be empty"));
392 }
393 out.dir = Some(PathBuf::from(dir));
394 }
395 if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[1]) {
396 if n == 0 || n > MAX_OPEN_CEILING as u64 {
397 return Err(SettingsError::config(format!(
398 "[workspace].max_open must be between 1 and {MAX_OPEN_CEILING} \
399 (one open database costs several file descriptors)"
400 )));
401 }
402 out.limits.max_open = n as usize;
406 }
407 if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[2]) {
408 out.limits.idle_timeout_ms = n;
409 }
410 Ok(out)
411}
412
413pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
420 let text = match read_config_text(flag)? {
421 Some(t) => t,
422 None => return Ok(None),
423 };
424 let table: toml::Table = text
425 .parse()
426 .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
427 Ok(Some(table))
428}
429
430fn parse_fsync(t: &toml::Table) -> Result<Option<FsyncPolicy>, SettingsError> {
439 let Some(value) = t.get(MAINTENANCE_SETTING_KEYS[3]) else {
440 return Ok(None);
441 };
442 let name = value.as_str().ok_or_else(|| {
443 SettingsError::config("[maintenance].fsync must be \"each_op\" or \"on_snapshot\"")
444 })?;
445 match name {
446 "each_op" => Ok(Some(FsyncPolicy::EachOp)),
447 "on_snapshot" => Ok(Some(FsyncPolicy::OnSnapshot)),
448 other => Err(SettingsError::config(format!(
449 "[maintenance].fsync must be \"each_op\" or \"on_snapshot\", got \"{other}\""
450 ))),
451 }
452}
453
454fn parse_on_error(value: &str) -> Result<EmbedErrorPolicy, SettingsError> {
456 match value {
457 "fail" => Ok(EmbedErrorPolicy::Fail),
458 "degrade" => Ok(EmbedErrorPolicy::Degrade),
459 other => Err(SettingsError::config(format!(
460 "[embedder].on_error must be \"fail\" or \"degrade\", got \"{other}\""
461 ))),
462 }
463}
464
465fn table_number(value: &toml::Value, key: &str) -> Result<u64, SettingsError> {
472 value
473 .as_integer()
474 .filter(|n| *n >= 0)
475 .map(|n| n as u64)
476 .ok_or_else(|| {
477 SettingsError::config(format!(
478 "[embedder].{key} must be a non-negative integer number of milliseconds"
479 ))
480 })
481}
482
483fn env_number(value: &str, var: &str) -> Result<u64, SettingsError> {
485 value.trim().parse::<u64>().map_err(|_| {
486 SettingsError::config(format!(
487 "{var} must be a non-negative integer number of milliseconds, got \"{value}\""
488 ))
489 })
490}
491
492fn parse_timeout_ms(ms: &u64) -> Option<Duration> {
495 (*ms > 0).then(|| Duration::from_millis(*ms))
496}
497
498pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
499 t.get(key)
500 .and_then(toml::Value::as_integer)
501 .filter(|n| *n >= 0)
502 .map(|n| n as u64)
503}
504
505fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
507 if let Some(p) = flag {
508 return std::fs::read_to_string(p)
509 .map(Some)
510 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
511 }
512 let candidate = std::env::var_os(ENV_CONFIG)
513 .map(PathBuf::from)
514 .or_else(crate::default_config_path);
515 match candidate {
516 Some(p) if p.exists() => std::fs::read_to_string(&p)
517 .map(Some)
518 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
519 _ => Ok(None),
520 }
521}
522
523fn setting_uint(t: &toml::Table, section: &str, key: &str) -> Result<Option<i64>, SettingsError> {
525 let Some(v) = t.get(key) else {
526 return Ok(None);
527 };
528 v.as_integer().filter(|n| *n >= 0).map(Some).ok_or_else(|| {
529 SettingsError::config(format!("[{section}].{key} must be a non-negative integer"))
530 })
531}
532
533fn setting_f32(t: &toml::Table, section: &str, key: &str) -> Result<Option<f32>, SettingsError> {
538 let Some(v) = t.get(key) else {
539 return Ok(None);
540 };
541 v.as_float()
542 .or_else(|| v.as_integer().map(|n| n as f64))
543 .map(|n| Some(n as f32))
544 .ok_or_else(|| SettingsError::config(format!("[{section}].{key} must be a number")))
545}
546
547fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
550 let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
551 (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
552 (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
553 (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
554 (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
555 ];
556 for (key, slot) in fields {
557 if let Some(n) = setting_uint(t, "engine", key)? {
558 *slot = n as usize;
559 }
560 }
561 Ok(())
562}
563
564fn apply_recall(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
567 let floats: [(&str, &mut f32); 10] = [
568 (RECALL_SETTING_KEYS[0], &mut cfg.bm25_k1),
569 (RECALL_SETTING_KEYS[1], &mut cfg.bm25_b),
570 (RECALL_SETTING_KEYS[3], &mut cfg.w_bm25),
571 (RECALL_SETTING_KEYS[4], &mut cfg.w_vec),
572 (RECALL_SETTING_KEYS[5], &mut cfg.w_graph),
573 (RECALL_SETTING_KEYS[6], &mut cfg.w_time),
574 (RECALL_SETTING_KEYS[7], &mut cfg.w_recency),
575 (RECALL_SETTING_KEYS[10], &mut cfg.graph_decay),
576 (RECALL_SETTING_KEYS[12], &mut cfg.similar_cos),
577 (RECALL_SETTING_KEYS[13], &mut cfg.similar_jaccard),
578 ];
579 for (key, slot) in floats {
580 if let Some(v) = setting_f32(t, "recall", key)? {
581 *slot = v;
582 }
583 }
584 let uints: [(&str, &mut u32); 3] = [
585 (RECALL_SETTING_KEYS[2], &mut cfg.rrf_k),
586 (RECALL_SETTING_KEYS[8], &mut cfg.half_life_days),
587 (RECALL_SETTING_KEYS[9], &mut cfg.graph_depth),
588 ];
589 for (key, slot) in uints {
590 if let Some(n) = setting_uint(t, "recall", key)? {
591 *slot = n as u32;
592 }
593 }
594 if let Some(n) = setting_uint(t, "recall", RECALL_SETTING_KEYS[11])? {
595 cfg.hnsw_ef_search = n as usize;
596 }
597 Ok(())
598}
599
600fn apply_index(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
602 let fields: [(&str, &mut usize); INDEX_SETTING_KEYS.len()] = [
603 (INDEX_SETTING_KEYS[0], &mut cfg.hnsw_ef_construction),
604 (INDEX_SETTING_KEYS[1], &mut cfg.flat_to_hnsw),
605 ];
606 for (key, slot) in fields {
607 if let Some(n) = setting_uint(t, "index", key)? {
608 *slot = n as usize;
609 }
610 }
611 Ok(())
612}
613
614#[derive(Default)]
616struct EmbedderCfg {
617 enabled: Option<bool>,
618 url: Option<String>,
619 model: Option<String>,
620 space_id: Option<String>,
621 api_key_env: Option<String>,
622 on_error: Option<EmbedErrorPolicy>,
623 timeout: Option<Option<Duration>>,
625 retry_after_ms: Option<u64>,
629 retry_max_ms: Option<u64>,
630}
631
632impl EmbedderCfg {
633 fn merge(&mut self, t: &toml::Table) -> Result<(), SettingsError> {
634 let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
635 if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[0]) {
636 self.enabled =
637 Some(value.as_bool().ok_or_else(|| {
638 SettingsError::config("[embedder].enabled must be a boolean")
639 })?);
640 }
641 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
642 self.url = Some(v);
643 }
644 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
645 self.model = Some(v);
646 }
647 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
648 self.space_id = Some(v);
649 }
650 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[4]) {
651 self.api_key_env = Some(v);
652 }
653 if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[5]) {
654 let text = value
655 .as_str()
656 .ok_or_else(|| SettingsError::config("[embedder].on_error must be a string"))?;
657 self.on_error = Some(parse_on_error(text)?);
658 }
659 if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[6]) {
660 self.timeout = Some(parse_timeout_ms(&table_number(
661 value,
662 EMBEDDER_SETTING_KEYS[6],
663 )?));
664 }
665 if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[7]) {
666 self.retry_after_ms = Some(table_number(value, EMBEDDER_SETTING_KEYS[7])?);
667 }
668 if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[8]) {
669 self.retry_max_ms = Some(table_number(value, EMBEDDER_SETTING_KEYS[8])?);
670 }
671 Ok(())
672 }
673
674 fn retry(&self) -> EmbedRetry {
684 match self.retry_after_ms {
685 None => EmbedRetry::Backoff {
686 first: crate::DEFAULT_EMBED_RETRY_FIRST,
687 max: self
688 .retry_max_ms
689 .map_or(crate::DEFAULT_EMBED_RETRY_MAX, Duration::from_millis),
690 },
691 Some(0) => EmbedRetry::Manual,
692 Some(ms) => EmbedRetry::Fixed(Duration::from_millis(ms)),
693 }
694 }
695
696 fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
702 let enabled = self
703 .enabled
704 .unwrap_or(self.url.is_some() || self.model.is_some());
705 if !enabled {
706 return Ok(None);
707 }
708 let url = self
709 .url
710 .clone()
711 .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a URL"))?;
712 let model = self
713 .model
714 .clone()
715 .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a model"))?;
716 if dim == 0 {
717 return Err(SettingsError::config(
718 "[embedder] requires [engine].dim > 0 (the embedding size)",
719 ));
720 }
721 let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
722 if let Some(timeout) = self.timeout {
723 e = e.with_timeout(timeout);
724 }
725 if let Some(space_id) = &self.space_id {
726 e = e.with_space_id(space_id);
727 }
728 if let Some(env) = &self.api_key_env
729 && let Some(key) = std::env::var_os(env)
730 {
731 e = e.with_api_key(key.to_string_lossy().into_owned());
732 }
733 Ok(Some(Box::new(e)))
734 }
735}
736
737fn parse_embedder_enabled(value: &str) -> Result<bool, SettingsError> {
738 match value {
739 "true" => Ok(true),
740 "false" => Ok(false),
741 other => Err(SettingsError::config(format!(
742 "{ENV_EMBEDDER_ENABLED} must be true or false, got \"{other}\""
743 ))),
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 fn toml_of(lines: &[&str]) -> toml::Table {
754 lines.join("\n").parse().expect("valid TOML fixture")
755 }
756
757 struct TempDir(PathBuf);
759 impl TempDir {
760 fn new(tag: &str) -> Self {
761 let dir = std::env::temp_dir().join(format!(
762 "plugmem-settings-{tag}-{}-{}",
763 std::process::id(),
764 std::time::SystemTime::now()
765 .duration_since(std::time::UNIX_EPOCH)
766 .unwrap()
767 .as_nanos()
768 ));
769 std::fs::create_dir_all(&dir).unwrap();
770 TempDir(dir)
771 }
772 }
773 impl Drop for TempDir {
774 fn drop(&mut self) {
775 let _ = std::fs::remove_dir_all(&self.0);
776 }
777 }
778
779 #[test]
780 fn engine_and_maintenance_parse() {
781 let table = toml_of(&[
782 "[engine]",
783 "dim = 384",
784 "max_text = 2048",
785 "[maintenance]",
786 "snapshot_every_ops = 50",
787 "snapshot_journal_bytes = 8192",
788 "maintain_every_forgets = 3",
789 ]);
790 let s = Settings::from_table(Some(&table)).unwrap();
791 assert_eq!(s.config.dim, 384);
792 assert_eq!(s.config.max_text, 2048);
793 assert_eq!(s.snapshot_every_ops, Some(50));
794 assert_eq!(s.snapshot_journal_bytes, Some(8192));
795 assert_eq!(s.maintain_every_forgets, Some(3));
796
797 let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
798 assert!(matches!(
799 Settings::from_table(Some(&bad)),
800 Err(SettingsError::Config(_))
801 ));
802 }
803
804 #[test]
805 fn defaults_when_no_table() {
806 let s = Settings::from_table(None).unwrap();
807 assert!(s.database_path.is_none());
808 assert_eq!(s.config.dim, Config::default().dim);
809 assert!(s.embedder.is_none());
810 assert_eq!(s.snapshot_every_ops, None);
811 }
812
813 #[test]
814 fn embedder_merge_reads_every_field() {
815 let table = toml_of(&[
816 "[embedder]",
817 "enabled = true",
818 r#"url = "http://localhost:11434/v1/embeddings""#,
819 r#"model = "nomic-embed-text""#,
820 r#"space_id = "nomic-embed-text@v1""#,
821 r#"api_key_env = "SOME_ENV""#,
822 "[engine]",
823 "dim = 8",
824 ]);
825 let s = Settings::from_table(Some(&table)).unwrap();
828 let embedder = s.embedder.unwrap();
829 assert_eq!(embedder.space_id(), "nomic-embed-text@v1");
830 assert_eq!(embedder.dim(), 8);
831 }
832
833 #[test]
834 fn embedder_failure_policy_reads_every_key() {
835 let table = toml_of(&[
836 "[embedder]",
837 "enabled = true",
838 r#"url = "http://localhost:11434/v1/embeddings""#,
839 r#"model = "nomic-embed-text""#,
840 r#"on_error = "degrade""#,
841 "timeout_ms = 2500",
842 "retry_after_ms = 750",
843 "[engine]",
844 "dim = 8",
845 ]);
846 let s = Settings::from_table(Some(&table)).unwrap();
847 assert_eq!(s.embed_error_policy, EmbedErrorPolicy::Degrade);
848 assert_eq!(s.embed_retry, EmbedRetry::Fixed(Duration::from_millis(750)));
849 }
850
851 #[test]
852 fn an_unconfigured_embedder_section_keeps_the_old_behaviour() {
853 let table = toml_of(&["[engine]", "dim = 8"]);
856 let s = Settings::from_table(Some(&table)).unwrap();
857 assert_eq!(s.embed_error_policy, EmbedErrorPolicy::Fail);
858 assert_eq!(
859 s.embed_retry,
860 EmbedRetry::Backoff {
861 first: crate::DEFAULT_EMBED_RETRY_FIRST,
862 max: crate::DEFAULT_EMBED_RETRY_MAX,
863 }
864 );
865 }
866
867 #[test]
868 fn retry_keys_map_onto_the_three_shapes() {
869 let cfg = |lines: &[&str]| {
872 let mut lines = lines.to_vec();
873 lines.insert(0, "[embedder]");
874 Settings::from_table(Some(&toml_of(&lines)))
875 .unwrap()
876 .embed_retry
877 };
878 assert_eq!(cfg(&["retry_after_ms = 0"]), EmbedRetry::Manual);
879 assert_eq!(
880 cfg(&["retry_after_ms = 250"]),
881 EmbedRetry::Fixed(Duration::from_millis(250))
882 );
883 assert_eq!(
884 cfg(&["retry_max_ms = 5000"]),
885 EmbedRetry::Backoff {
886 first: crate::DEFAULT_EMBED_RETRY_FIRST,
887 max: Duration::from_millis(5000),
888 }
889 );
890 assert_eq!(
893 cfg(&["retry_after_ms = 100", "retry_max_ms = 5000"]),
894 EmbedRetry::Fixed(Duration::from_millis(100))
895 );
896 }
897
898 #[test]
899 fn a_malformed_failure_key_is_refused_rather_than_ignored() {
900 for lines in [
904 vec!["[embedder]", r#"on_error = "sometimes""#],
905 vec!["[embedder]", "on_error = true"],
906 vec!["[embedder]", r#"timeout_ms = "5s""#],
907 vec!["[embedder]", "timeout_ms = -1"],
908 vec!["[embedder]", r#"retry_after_ms = "soon""#],
909 vec!["[embedder]", "retry_max_ms = -5"],
910 ] {
911 let table = toml_of(&lines);
912 assert!(
913 matches!(
914 Settings::from_table(Some(&table)),
915 Err(SettingsError::Config(_))
916 ),
917 "accepted {lines:?}"
918 );
919 }
920 }
921
922 #[test]
923 fn a_zero_timeout_means_wait_indefinitely() {
924 assert_eq!(parse_timeout_ms(&0), None);
927 assert_eq!(parse_timeout_ms(&1500), Some(Duration::from_millis(1500)));
928 }
929
930 #[test]
931 fn the_environment_parsers_answer_the_same_way_the_file_does() {
932 assert_eq!(
936 parse_on_error("degrade").unwrap(),
937 EmbedErrorPolicy::Degrade
938 );
939 assert_eq!(parse_on_error("fail").unwrap(), EmbedErrorPolicy::Fail);
940 assert!(parse_on_error("Degrade").is_err());
941 assert_eq!(
942 env_number("2500", "PLUGMEM_EMBEDDER_TIMEOUT_MS").unwrap(),
943 2500
944 );
945 assert!(env_number("-1", "PLUGMEM_EMBEDDER_TIMEOUT_MS").is_err());
946 assert!(env_number("2.5", "PLUGMEM_EMBEDDER_TIMEOUT_MS").is_err());
947 }
948
949 #[test]
950 fn database_path_reads_and_validates_from_config() {
951 let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
952 .parse()
953 .unwrap();
954 let settings = Settings::from_table(Some(&table)).unwrap();
955 assert_eq!(
956 settings.database_path.as_deref(),
957 Some(std::path::Path::new("/tmp/memory.plugmem"))
958 );
959
960 let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
961 assert!(matches!(
962 Settings::from_table(Some(&bad)),
963 Err(SettingsError::Config(message)) if message == "[database].path must be a string"
964 ));
965 }
966
967 #[test]
968 fn settings_open_applies_maintenance_and_embedder() {
969 let tmp = TempDir::new("open");
973 let mut config = Config::default();
974 config.dim = 8;
975 let embedder = EmbedderCfg {
976 enabled: Some(true),
977 url: Some("http://127.0.0.1:0/v1/embeddings".into()),
978 model: Some("m".into()),
979 ..Default::default()
980 }
981 .build(8)
982 .unwrap();
983 assert!(embedder.is_some());
984 let settings = Settings {
985 database_path: None,
986 config,
987 embedder,
988 embed_error_policy: EmbedErrorPolicy::default(),
989 embed_retry: EmbedRetry::default(),
990 snapshot_every_ops: Some(4),
991 snapshot_journal_bytes: Some(4096),
992 maintain_every_forgets: Some(2),
993 fsync: Some(FsyncPolicy::OnSnapshot),
994 workspace: WorkspaceSettings {
995 dir: None,
996 limits: WorkspaceLimits::default(),
997 },
998 warnings: Vec::new(),
999 };
1000 let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
1001 assert_eq!(db.stats().facts, 0);
1002 }
1003
1004 #[test]
1005 fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
1006 let bare = Settings::from_table(None).unwrap();
1009 assert_eq!(bare.workspace.dir, None);
1010 assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
1011
1012 let table: toml::Table =
1013 "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
1014 .parse()
1015 .unwrap();
1016 let s = Settings::from_table(Some(&table)).unwrap();
1017 assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
1018 assert_eq!(s.workspace.limits.max_open, 4);
1019 assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
1020
1021 let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
1023 let s = Settings::from_table(Some(&only_dir)).unwrap();
1024 assert_eq!(s.workspace.limits, WorkspaceLimits::default());
1025 }
1026
1027 #[test]
1028 fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
1029 for bad in [
1032 "[workspace]\nmax_open = 0\n".to_string(),
1033 format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
1034 "[workspace]\nmax_open = 9999999999\n".to_string(),
1037 ] {
1038 let table: toml::Table = bad.parse().unwrap();
1039 assert!(
1040 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
1041 "{bad}"
1042 );
1043 }
1044
1045 for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
1046 let table: toml::Table = bad.parse().unwrap();
1047 assert!(
1048 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
1049 "{bad}"
1050 );
1051 }
1052
1053 let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
1055 .parse()
1056 .unwrap();
1057 let s = Settings::from_table(Some(&table)).unwrap();
1058 assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
1059 }
1060
1061 #[test]
1062 fn open_workspace_builds_databases_from_the_same_settings() {
1063 let tmp = TempDir::new("open-workspace");
1064 let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
1065 snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
1066 .parse()
1067 .unwrap();
1068 let settings = Settings::from_table(Some(&table)).unwrap();
1069 let ws = settings.open_workspace(&tmp.0).unwrap();
1070
1071 let name = crate::DbName::parse("chat-42").unwrap();
1072 let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
1073 db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
1074 .unwrap();
1075 assert_eq!(db.stats().facts, 1);
1076 assert!(ws.layout().exists(&name));
1077 }
1078
1079 #[test]
1080 fn fsync_policy_is_named_and_a_misspelling_is_refused() {
1081 let parse = |body: &str| {
1082 let table: toml::Table = body.parse().unwrap();
1083 let t = table.get("maintenance").unwrap().as_table().unwrap();
1084 parse_fsync(t)
1085 };
1086
1087 assert_eq!(
1088 parse("[maintenance]\n").unwrap(),
1089 None,
1090 "absent stays default"
1091 );
1092 assert_eq!(
1093 parse("[maintenance]\nfsync = \"each_op\"\n").unwrap(),
1094 Some(FsyncPolicy::EachOp)
1095 );
1096 assert_eq!(
1097 parse("[maintenance]\nfsync = \"on_snapshot\"\n").unwrap(),
1098 Some(FsyncPolicy::OnSnapshot)
1099 );
1100
1101 for bad in [
1104 "[maintenance]\nfsync = \"on-snapshot\"\n",
1105 "[maintenance]\nfsync = \"none\"\n",
1106 "[maintenance]\nfsync = true\n",
1107 "[maintenance]\nfsync = 1\n",
1108 ] {
1109 let Err(err) = parse(bad) else {
1110 panic!("{bad:?} must be refused");
1111 };
1112 assert!(
1113 err.to_string().contains("each_op"),
1114 "the message names the legal values: {err}"
1115 );
1116 }
1117 }
1118
1119 #[test]
1120 fn fsync_reaches_settings_from_the_config_file() {
1121 let table: toml::Table = "[maintenance]\nfsync = \"on_snapshot\"\n".parse().unwrap();
1125 let settings = Settings::from_table(Some(&table)).unwrap();
1126 assert_eq!(settings.fsync, Some(FsyncPolicy::OnSnapshot));
1127
1128 let plain = Settings::from_table(None).unwrap();
1129 assert_eq!(plain.fsync, None, "no config means the engine default");
1130 }
1131
1132 #[test]
1133 fn embedder_build_rules() {
1134 assert!(EmbedderCfg::default().build(0).unwrap().is_none());
1135 let no_url = EmbedderCfg {
1136 enabled: Some(true),
1137 ..Default::default()
1138 };
1139 assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
1140 let no_model = EmbedderCfg {
1141 enabled: Some(true),
1142 url: Some("http://x/v1/embeddings".into()),
1143 ..Default::default()
1144 };
1145 assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
1146 let zero_dim = EmbedderCfg {
1147 enabled: Some(true),
1148 url: Some("http://x/v1/embeddings".into()),
1149 model: Some("m".into()),
1150 ..Default::default()
1151 };
1152 assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
1153 let ok = EmbedderCfg {
1154 enabled: None,
1155 url: Some("http://x/v1/embeddings".into()),
1156 model: Some("m".into()),
1157 api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
1158 ..Default::default()
1159 };
1160 assert!(ok.build(384).unwrap().is_some());
1161 let disabled = EmbedderCfg {
1162 enabled: Some(false),
1163 url: Some("http://x/v1/embeddings".into()),
1164 model: Some("m".into()),
1165 ..Default::default()
1166 };
1167 assert!(disabled.build(0).unwrap().is_none());
1168 assert!(parse_embedder_enabled("true").unwrap());
1169 assert!(!parse_embedder_enabled("false").unwrap());
1170 assert!(parse_embedder_enabled("ollama").is_err());
1171 }
1172
1173 #[test]
1174 fn load_reads_the_config_file() {
1175 let tmp = TempDir::new("load");
1176 let cfgfile = tmp.0.join("config.toml");
1177 std::fs::write(
1178 &cfgfile,
1179 "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nenabled = false\n[maintenance]\nsnapshot_every_ops = 64\n",
1180 )
1181 .unwrap();
1182 let s = Settings::load(Some(&cfgfile)).unwrap();
1183 assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
1184 assert_eq!(s.config.dim, 512);
1185 assert!(s.embedder.is_none());
1186 assert_eq!(s.snapshot_every_ops, Some(64));
1187
1188 assert!(matches!(
1190 Settings::load(Some(&tmp.0.join("nope.toml"))),
1191 Err(SettingsError::Config(_))
1192 ));
1193 }
1194
1195 #[test]
1196 fn read_config_none_and_batch_extra() {
1197 let tmp = TempDir::new("extra");
1199 let missing = tmp.0.join("absent.toml");
1200 assert!(read_config(Some(&missing)).is_err());
1203
1204 let cfgfile = tmp.0.join("config.toml");
1205 std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
1206 let table = read_config(Some(&cfgfile)).unwrap().unwrap();
1207 let batch = table
1208 .get("maintenance")
1209 .and_then(toml::Value::as_table)
1210 .and_then(|m| table_u64(m, "batch_size"));
1211 assert_eq!(batch, Some(256));
1212 }
1213
1214 #[test]
1215 fn every_tuning_key_actually_reaches_the_config() {
1216 let cfg = Config::default();
1223 let table = toml_of(&[
1224 "[recall]",
1225 "bm25_k1 = 2.5",
1226 "bm25_b = 0.25",
1227 "rrf_k = 17",
1228 "w_bm25 = 3.0",
1229 "w_vec = 4.0",
1230 "w_graph = 5.0",
1231 "w_time = 6.0",
1232 "w_recency = 0.75",
1233 "half_life_days = 7",
1234 "graph_depth = 4",
1235 "graph_decay = 0.125",
1236 "hnsw_ef_search = 111",
1237 "similar_cos = 0.31",
1238 "similar_jaccard = 0.32",
1239 "[index]",
1240 "hnsw_ef_construction = 222",
1241 "flat_to_hnsw = 333",
1242 ]);
1243 let s = Settings::from_table(Some(&table)).unwrap();
1244
1245 assert_eq!(s.config.bm25_k1, 2.5);
1246 assert_eq!(s.config.bm25_b, 0.25);
1247 assert_eq!(s.config.rrf_k, 17);
1248 assert_eq!(s.config.w_bm25, 3.0);
1249 assert_eq!(s.config.w_vec, 4.0);
1250 assert_eq!(s.config.w_graph, 5.0);
1251 assert_eq!(s.config.w_time, 6.0);
1252 assert_eq!(s.config.w_recency, 0.75);
1253 assert_eq!(s.config.half_life_days, 7);
1254 assert_eq!(s.config.graph_depth, 4);
1255 assert_eq!(s.config.graph_decay, 0.125);
1256 assert_eq!(s.config.hnsw_ef_search, 111);
1257 assert_eq!(s.config.similar_cos, 0.31);
1258 assert_eq!(s.config.similar_jaccard, 0.32);
1259 assert_eq!(s.config.hnsw_ef_construction, 222);
1260 assert_eq!(s.config.flat_to_hnsw, 333);
1261
1262 assert_ne!(s.config.bm25_k1, cfg.bm25_k1);
1265 assert_ne!(s.config.flat_to_hnsw, cfg.flat_to_hnsw);
1266 assert!(s.warnings.is_empty(), "{:?}", s.warnings);
1267 }
1268
1269 #[test]
1270 fn an_integer_is_accepted_where_a_float_is_meant() {
1271 let table = toml_of(&["[recall]", "w_vec = 2", "graph_decay = 1"]);
1275 let s = Settings::from_table(Some(&table)).unwrap();
1276 assert_eq!(s.config.w_vec, 2.0);
1277 assert_eq!(s.config.graph_decay, 1.0);
1278 }
1279
1280 #[test]
1281 fn a_tuning_value_out_of_range_is_refused_by_name() {
1282 for line in ["graph_decay = 2.0", "similar_cos = -1.0", "w_vec = -0.5"] {
1286 let table = toml_of(&["[recall]", line]);
1287 let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
1288 panic!("{line} must be refused");
1289 };
1290 let field = line.split(' ').next().unwrap();
1291 assert!(
1292 message.contains(field),
1293 "the message must name the offending field: {message}"
1294 );
1295 }
1296
1297 let table = toml_of(&["[recall]", r#"w_vec = "lots""#]);
1300 let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
1301 panic!("a string weight must be refused");
1302 };
1303 assert!(message.contains("[recall].w_vec"), "{message}");
1304 }
1305
1306 #[test]
1307 fn every_host_setting_is_documented() {
1308 let docs = crate::settings_help::settings_help().docs();
1309 for (section, keys) in [
1310 ("database", DATABASE_SETTING_KEYS),
1311 ("workspace", WORKSPACE_SETTING_KEYS),
1312 ("engine", ENGINE_SETTING_KEYS),
1313 ("recall", RECALL_SETTING_KEYS),
1314 ("index", INDEX_SETTING_KEYS),
1315 ("embedder", EMBEDDER_SETTING_KEYS),
1316 ("maintenance", MAINTENANCE_SETTING_KEYS),
1317 ] {
1318 let documented: Vec<_> = docs
1319 .iter()
1320 .filter(|doc| {
1321 doc.section == section
1322 && doc.scope == crate::settings_help::SettingScope::Shared
1323 })
1324 .map(|doc| doc.key)
1325 .collect();
1326 assert_eq!(
1327 documented.as_slice(),
1328 keys,
1329 "undocumented {section} setting"
1330 );
1331 }
1332 }
1333}