1use std::path::{Path, PathBuf};
19
20use crate::{
21 Config, Database, DatabaseBuilder, Embedder, FsyncPolicy, HostError, MAX_OPEN_CEILING,
22 OpenAiCompatEmbedder, Opener, SharedEmbedder, Workspace, WorkspaceLayout, WorkspaceLimits,
23};
24
25const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
27const ENV_EMBEDDER: &str = "PLUGMEM_EMBEDDER";
29pub(crate) const ENGINE_SETTING_KEYS: &[&str] = &["dim", "max_bytes", "max_text", "max_blob"];
33pub(crate) const DATABASE_SETTING_KEYS: &[&str] = &["path"];
34pub(crate) const WORKSPACE_SETTING_KEYS: &[&str] = &["dir", "max_open", "idle_timeout_ms"];
35pub(crate) const EMBEDDER_SETTING_KEYS: &[&str] = &["kind", "url", "model", "api_key_env"];
36pub(crate) const MAINTENANCE_SETTING_KEYS: &[&str] = &[
37 "snapshot_every_ops",
38 "snapshot_journal_bytes",
39 "maintain_every_forgets",
40 "fsync",
41];
42
43#[derive(Debug, thiserror::Error)]
47#[non_exhaustive]
48pub enum SettingsError {
49 #[error("{0}")]
51 Config(String),
52}
53
54impl SettingsError {
55 fn config(msg: impl Into<String>) -> Self {
56 SettingsError::Config(msg.into())
57 }
58}
59
60pub struct Settings {
64 pub database_path: Option<PathBuf>,
67 pub config: Config,
69 pub embedder: Option<Box<dyn Embedder>>,
72 pub snapshot_every_ops: Option<u64>,
74 pub snapshot_journal_bytes: Option<u64>,
76 pub maintain_every_forgets: Option<u64>,
78 pub fsync: Option<FsyncPolicy>,
83 pub workspace: WorkspaceSettings,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct WorkspaceSettings {
93 pub dir: Option<PathBuf>,
96 pub limits: WorkspaceLimits,
98}
99
100impl Settings {
101 pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
105 let table = read_config(flag)?;
106 Settings::from_table(table.as_ref())
107 }
108
109 pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
114 let mut config = Config::default();
115 let mut database_path = None;
116 let mut embedder = EmbedderCfg::default();
117 let mut snapshot_every_ops = None;
118 let mut snapshot_journal_bytes = None;
119 let mut maintain_every_forgets = None;
120 let mut fsync = None;
121 let mut workspace = WorkspaceSettings {
122 dir: None,
123 limits: WorkspaceLimits::default(),
124 };
125
126 if let Some(table) = table {
127 if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
128 database_path = t
129 .get(DATABASE_SETTING_KEYS[0])
130 .map(|value| {
131 let path = value.as_str().ok_or_else(|| {
132 SettingsError::config("[database].path must be a string")
133 })?;
134 if path.is_empty() {
135 return Err(SettingsError::config("[database].path must not be empty"));
136 }
137 Ok(PathBuf::from(path))
138 })
139 .transpose()?;
140 }
141 if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
142 apply_engine(&mut config, t)?;
143 }
144 if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
145 embedder.merge(t);
146 }
147 if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
148 snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
149 snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
150 maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
151 fsync = parse_fsync(t)?;
152 }
153 if let Some(t) = table.get("workspace").and_then(toml::Value::as_table) {
154 workspace = parse_workspace(t)?;
155 }
156 }
157
158 if let Some(kind) = std::env::var_os(ENV_EMBEDDER) {
159 embedder.kind = Some(kind.to_string_lossy().into_owned());
160 }
161
162 let embedder = embedder.build(config.dim)?;
163 Ok(Settings {
164 database_path,
165 config,
166 embedder,
167 snapshot_every_ops,
168 snapshot_journal_bytes,
169 maintain_every_forgets,
170 fsync,
171 workspace,
172 })
173 }
174
175 pub fn open(self, path: &Path) -> Result<Database, HostError> {
180 let mut b: DatabaseBuilder = Database::builder(self.config);
181 if let Some(v) = self.snapshot_every_ops {
182 b = b.snapshot_every_ops(v);
183 }
184 if let Some(v) = self.snapshot_journal_bytes {
185 b = b.snapshot_journal_bytes(v);
186 }
187 if let Some(v) = self.maintain_every_forgets {
188 b = b.maintain_every_forgets(v);
189 }
190 if let Some(v) = self.fsync {
191 b = b.fsync(v);
192 }
193 if let Some(e) = self.embedder {
194 b = b.embedder(e);
195 }
196 Ok(b.open(path)?.0)
197 }
198
199 pub fn open_workspace(self, root: &Path) -> Result<Workspace, crate::WorkspaceError> {
216 let Settings {
217 config,
218 embedder,
219 snapshot_every_ops,
220 snapshot_journal_bytes,
221 maintain_every_forgets,
222 workspace,
223 ..
224 } = self;
225 let shared = embedder.map(SharedEmbedder::new);
226
227 let open: Opener = Box::new(move |path: &Path| {
228 let mut b = Database::builder(config.clone());
229 if let Some(v) = snapshot_every_ops {
230 b = b.snapshot_every_ops(v);
231 }
232 if let Some(v) = snapshot_journal_bytes {
233 b = b.snapshot_journal_bytes(v);
234 }
235 if let Some(v) = maintain_every_forgets {
236 b = b.maintain_every_forgets(v);
237 }
238 if let Some(e) = &shared {
239 b = b.embedder(Box::new(e.clone()));
240 }
241 Ok(b.open(path)?.0)
242 });
243 Ok(Workspace::new(
244 WorkspaceLayout::new(root),
245 open,
246 workspace.limits,
247 ))
248 }
249}
250
251fn parse_workspace(t: &toml::Table) -> Result<WorkspaceSettings, SettingsError> {
255 let mut out = WorkspaceSettings {
256 dir: None,
257 limits: WorkspaceLimits::default(),
258 };
259 if let Some(value) = t.get(WORKSPACE_SETTING_KEYS[0]) {
260 let dir = value
261 .as_str()
262 .ok_or_else(|| SettingsError::config("[workspace].dir must be a string"))?;
263 if dir.is_empty() {
264 return Err(SettingsError::config("[workspace].dir must not be empty"));
265 }
266 out.dir = Some(PathBuf::from(dir));
267 }
268 if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[1]) {
269 if n == 0 || n > MAX_OPEN_CEILING as u64 {
270 return Err(SettingsError::config(format!(
271 "[workspace].max_open must be between 1 and {MAX_OPEN_CEILING} \
272 (one open database costs several file descriptors)"
273 )));
274 }
275 out.limits.max_open = n as usize;
279 }
280 if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[2]) {
281 out.limits.idle_timeout_ms = n;
282 }
283 Ok(out)
284}
285
286pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
293 let text = match read_config_text(flag)? {
294 Some(t) => t,
295 None => return Ok(None),
296 };
297 let table: toml::Table = text
298 .parse()
299 .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
300 Ok(Some(table))
301}
302
303fn parse_fsync(t: &toml::Table) -> Result<Option<FsyncPolicy>, SettingsError> {
312 let Some(value) = t.get(MAINTENANCE_SETTING_KEYS[3]) else {
313 return Ok(None);
314 };
315 let name = value.as_str().ok_or_else(|| {
316 SettingsError::config("[maintenance].fsync must be \"each_op\" or \"on_snapshot\"")
317 })?;
318 match name {
319 "each_op" => Ok(Some(FsyncPolicy::EachOp)),
320 "on_snapshot" => Ok(Some(FsyncPolicy::OnSnapshot)),
321 other => Err(SettingsError::config(format!(
322 "[maintenance].fsync must be \"each_op\" or \"on_snapshot\", got \"{other}\""
323 ))),
324 }
325}
326
327pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
328 t.get(key)
329 .and_then(toml::Value::as_integer)
330 .filter(|n| *n >= 0)
331 .map(|n| n as u64)
332}
333
334fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
336 if let Some(p) = flag {
337 return std::fs::read_to_string(p)
338 .map(Some)
339 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
340 }
341 let candidate = std::env::var_os(ENV_CONFIG)
342 .map(PathBuf::from)
343 .or_else(crate::default_config_path);
344 match candidate {
345 Some(p) if p.exists() => std::fs::read_to_string(&p)
346 .map(Some)
347 .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
348 _ => Ok(None),
349 }
350}
351
352fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
356 let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
357 (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
358 (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
359 (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
360 (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
361 ];
362 for (key, slot) in fields {
363 if let Some(v) = t.get(key) {
364 let n = v.as_integer().filter(|n| *n >= 0).ok_or_else(|| {
365 SettingsError::config(format!("[engine].{key} must be a non-negative integer"))
366 })?;
367 *slot = n as usize;
368 }
369 }
370 Ok(())
371}
372
373#[derive(Default)]
375struct EmbedderCfg {
376 kind: Option<String>,
377 url: Option<String>,
378 model: Option<String>,
379 api_key_env: Option<String>,
380}
381
382impl EmbedderCfg {
383 fn merge(&mut self, t: &toml::Table) {
384 let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
385 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[0]) {
386 self.kind = Some(v);
387 }
388 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
389 self.url = Some(v);
390 }
391 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
392 self.model = Some(v);
393 }
394 if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
395 self.api_key_env = Some(v);
396 }
397 }
398
399 fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
404 let kind = self.kind.as_deref().unwrap_or("none");
405 match kind {
406 "none" | "" => Ok(None),
407 "ollama" | "openai" | "openai-compat" | "lmstudio" | "vllm" | "llamacpp" => {
408 let url = self.url.clone().ok_or_else(|| {
409 SettingsError::config(format!("[embedder] kind \"{kind}\" needs a url"))
410 })?;
411 let model = self.model.clone().ok_or_else(|| {
412 SettingsError::config(format!("[embedder] kind \"{kind}\" needs a model"))
413 })?;
414 if dim == 0 {
415 return Err(SettingsError::config(
416 "[embedder] requires [engine].dim > 0 (the embedding size)",
417 ));
418 }
419 let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
420 if let Some(env) = &self.api_key_env
421 && let Some(key) = std::env::var_os(env)
422 {
423 e = e.with_api_key(key.to_string_lossy().into_owned());
424 }
425 Ok(Some(Box::new(e)))
426 }
427 other => Err(SettingsError::config(format!(
428 "unknown [embedder] kind: {other}"
429 ))),
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 struct TempDir(PathBuf);
440 impl TempDir {
441 fn new(tag: &str) -> Self {
442 let dir = std::env::temp_dir().join(format!(
443 "plugmem-settings-{tag}-{}-{}",
444 std::process::id(),
445 std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .unwrap()
448 .as_nanos()
449 ));
450 std::fs::create_dir_all(&dir).unwrap();
451 TempDir(dir)
452 }
453 }
454 impl Drop for TempDir {
455 fn drop(&mut self) {
456 let _ = std::fs::remove_dir_all(&self.0);
457 }
458 }
459
460 #[test]
461 fn engine_and_maintenance_parse() {
462 let text = "\
463[engine]
464dim = 384
465max_text = 2048
466[maintenance]
467snapshot_every_ops = 50
468snapshot_journal_bytes = 8192
469maintain_every_forgets = 3
470";
471 let table: toml::Table = text.parse().unwrap();
472 let s = Settings::from_table(Some(&table)).unwrap();
473 assert_eq!(s.config.dim, 384);
474 assert_eq!(s.config.max_text, 2048);
475 assert_eq!(s.snapshot_every_ops, Some(50));
476 assert_eq!(s.snapshot_journal_bytes, Some(8192));
477 assert_eq!(s.maintain_every_forgets, Some(3));
478
479 let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
480 assert!(matches!(
481 Settings::from_table(Some(&bad)),
482 Err(SettingsError::Config(_))
483 ));
484 }
485
486 #[test]
487 fn defaults_when_no_table() {
488 let s = Settings::from_table(None).unwrap();
489 assert!(s.database_path.is_none());
490 assert_eq!(s.config.dim, Config::default().dim);
491 assert!(s.embedder.is_none());
492 assert_eq!(s.snapshot_every_ops, None);
493 }
494
495 #[test]
496 fn embedder_merge_reads_every_field() {
497 let text = "\
498[embedder]
499kind = \"ollama\"
500url = \"http://localhost:11434/v1\"
501model = \"nomic-embed-text\"
502api_key_env = \"SOME_ENV\"
503[engine]
504dim = 8
505";
506 let table: toml::Table = text.parse().unwrap();
507 let s = Settings::from_table(Some(&table)).unwrap();
509 assert!(s.embedder.is_some());
510 }
511
512 #[test]
513 fn database_path_reads_and_validates_from_config() {
514 let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
515 .parse()
516 .unwrap();
517 let settings = Settings::from_table(Some(&table)).unwrap();
518 assert_eq!(
519 settings.database_path.as_deref(),
520 Some(std::path::Path::new("/tmp/memory.plugmem"))
521 );
522
523 let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
524 assert!(matches!(
525 Settings::from_table(Some(&bad)),
526 Err(SettingsError::Config(message)) if message == "[database].path must be a string"
527 ));
528 }
529
530 #[test]
531 fn settings_open_applies_maintenance_and_embedder() {
532 let tmp = TempDir::new("open");
536 let mut config = Config::default();
537 config.dim = 8;
538 let embedder = EmbedderCfg {
539 kind: Some("ollama".into()),
540 url: Some("http://127.0.0.1:0/v1".into()),
541 model: Some("m".into()),
542 api_key_env: None,
543 }
544 .build(8)
545 .unwrap();
546 assert!(embedder.is_some());
547 let settings = Settings {
548 database_path: None,
549 config,
550 embedder,
551 snapshot_every_ops: Some(4),
552 snapshot_journal_bytes: Some(4096),
553 maintain_every_forgets: Some(2),
554 fsync: Some(FsyncPolicy::OnSnapshot),
555 workspace: WorkspaceSettings {
556 dir: None,
557 limits: WorkspaceLimits::default(),
558 },
559 };
560 let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
561 assert_eq!(db.stats().facts, 0);
562 }
563
564 #[test]
565 fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
566 let bare = Settings::from_table(None).unwrap();
569 assert_eq!(bare.workspace.dir, None);
570 assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
571
572 let table: toml::Table =
573 "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
574 .parse()
575 .unwrap();
576 let s = Settings::from_table(Some(&table)).unwrap();
577 assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
578 assert_eq!(s.workspace.limits.max_open, 4);
579 assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
580
581 let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
583 let s = Settings::from_table(Some(&only_dir)).unwrap();
584 assert_eq!(s.workspace.limits, WorkspaceLimits::default());
585 }
586
587 #[test]
588 fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
589 for bad in [
592 "[workspace]\nmax_open = 0\n".to_string(),
593 format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
594 "[workspace]\nmax_open = 9999999999\n".to_string(),
597 ] {
598 let table: toml::Table = bad.parse().unwrap();
599 assert!(
600 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
601 "{bad}"
602 );
603 }
604
605 for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
606 let table: toml::Table = bad.parse().unwrap();
607 assert!(
608 matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
609 "{bad}"
610 );
611 }
612
613 let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
615 .parse()
616 .unwrap();
617 let s = Settings::from_table(Some(&table)).unwrap();
618 assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
619 }
620
621 #[test]
622 fn open_workspace_builds_databases_from_the_same_settings() {
623 let tmp = TempDir::new("open-workspace");
624 let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
625 snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
626 .parse()
627 .unwrap();
628 let settings = Settings::from_table(Some(&table)).unwrap();
629 let ws = settings.open_workspace(&tmp.0).unwrap();
630
631 let name = crate::DbName::parse("chat-42").unwrap();
632 let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
633 db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
634 .unwrap();
635 assert_eq!(db.stats().facts, 1);
636 assert!(ws.layout().exists(&name));
637 }
638
639 #[test]
640 fn fsync_policy_is_named_and_a_misspelling_is_refused() {
641 let parse = |body: &str| {
642 let table: toml::Table = body.parse().unwrap();
643 let t = table.get("maintenance").unwrap().as_table().unwrap();
644 parse_fsync(t)
645 };
646
647 assert_eq!(
648 parse("[maintenance]\n").unwrap(),
649 None,
650 "absent stays default"
651 );
652 assert_eq!(
653 parse("[maintenance]\nfsync = \"each_op\"\n").unwrap(),
654 Some(FsyncPolicy::EachOp)
655 );
656 assert_eq!(
657 parse("[maintenance]\nfsync = \"on_snapshot\"\n").unwrap(),
658 Some(FsyncPolicy::OnSnapshot)
659 );
660
661 for bad in [
664 "[maintenance]\nfsync = \"on-snapshot\"\n",
665 "[maintenance]\nfsync = \"none\"\n",
666 "[maintenance]\nfsync = true\n",
667 "[maintenance]\nfsync = 1\n",
668 ] {
669 let Err(err) = parse(bad) else {
670 panic!("{bad:?} must be refused");
671 };
672 assert!(
673 err.to_string().contains("each_op"),
674 "the message names the legal values: {err}"
675 );
676 }
677 }
678
679 #[test]
680 fn fsync_reaches_settings_from_the_config_file() {
681 let table: toml::Table = "[maintenance]\nfsync = \"on_snapshot\"\n".parse().unwrap();
685 let settings = Settings::from_table(Some(&table)).unwrap();
686 assert_eq!(settings.fsync, Some(FsyncPolicy::OnSnapshot));
687
688 let plain = Settings::from_table(None).unwrap();
689 assert_eq!(plain.fsync, None, "no config means the engine default");
690 }
691
692 #[test]
693 fn embedder_build_rules() {
694 assert!(EmbedderCfg::default().build(0).unwrap().is_none());
695 let no_url = EmbedderCfg {
696 kind: Some("ollama".into()),
697 ..Default::default()
698 };
699 assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
700 let no_model = EmbedderCfg {
701 kind: Some("ollama".into()),
702 url: Some("http://x/v1".into()),
703 ..Default::default()
704 };
705 assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
706 let zero_dim = EmbedderCfg {
707 kind: Some("ollama".into()),
708 url: Some("http://x/v1".into()),
709 model: Some("m".into()),
710 api_key_env: None,
711 };
712 assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
713 let ok = EmbedderCfg {
714 kind: Some("openai".into()),
715 url: Some("http://x/v1".into()),
716 model: Some("m".into()),
717 api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
718 };
719 assert!(ok.build(384).unwrap().is_some());
720 let weird = EmbedderCfg {
721 kind: Some("weird".into()),
722 ..Default::default()
723 };
724 assert!(matches!(weird.build(384), Err(SettingsError::Config(_))));
725 }
726
727 #[test]
728 fn load_reads_the_config_file() {
729 let tmp = TempDir::new("load");
730 let cfgfile = tmp.0.join("config.toml");
731 std::fs::write(
732 &cfgfile,
733 "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n[maintenance]\nsnapshot_every_ops = 64\n",
734 )
735 .unwrap();
736 let s = Settings::load(Some(&cfgfile)).unwrap();
737 assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
738 assert_eq!(s.config.dim, 512);
739 assert!(s.embedder.is_none());
740 assert_eq!(s.snapshot_every_ops, Some(64));
741
742 assert!(matches!(
744 Settings::load(Some(&tmp.0.join("nope.toml"))),
745 Err(SettingsError::Config(_))
746 ));
747 }
748
749 #[test]
750 fn read_config_none_and_batch_extra() {
751 let tmp = TempDir::new("extra");
753 let missing = tmp.0.join("absent.toml");
754 assert!(read_config(Some(&missing)).is_err());
757
758 let cfgfile = tmp.0.join("config.toml");
759 std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
760 let table = read_config(Some(&cfgfile)).unwrap().unwrap();
761 let batch = table
762 .get("maintenance")
763 .and_then(toml::Value::as_table)
764 .and_then(|m| table_u64(m, "batch_size"));
765 assert_eq!(batch, Some(256));
766 }
767
768 #[test]
769 fn every_host_setting_is_documented() {
770 let docs = crate::settings_help::settings_help().docs();
771 for (section, keys) in [
772 ("database", DATABASE_SETTING_KEYS),
773 ("workspace", WORKSPACE_SETTING_KEYS),
774 ("engine", ENGINE_SETTING_KEYS),
775 ("embedder", EMBEDDER_SETTING_KEYS),
776 ("maintenance", MAINTENANCE_SETTING_KEYS),
777 ] {
778 let documented: Vec<_> = docs
779 .iter()
780 .filter(|doc| {
781 doc.section == section
782 && doc.scope == crate::settings_help::SettingScope::Shared
783 })
784 .map(|doc| doc.key)
785 .collect();
786 assert_eq!(
787 documented.as_slice(),
788 keys,
789 "undocumented {section} setting"
790 );
791 }
792 }
793}