Skip to main content

plugmem_host/
settings.rs

1//! Shared `config.toml` loader (feature `config`): resolve the engine
2//! [`Config`], an optional [`Embedder`], and the maintenance policy from a
3//! TOML file plus the environment, with precedence **flag/env > config file >
4//! default**.
5//!
6//! This is the loader the CLI and the MCP server share so they agree on config
7//! semantics. It is deliberately small: it reads four shared sections —
8//! `[database]` (the optional database path), `[engine]` (size-bearing
9//! [`Config`] fields), `[embedder]` (an OpenAI-compatible provider), and
10//! `[maintenance]` (snapshot/maintain thresholds). Keys a
11//! specific wrapper owns — the CLI's `[maintenance].batch_size`, the server's
12//! `[server].workers` — are **not** parsed here; a wrapper reads them from the
13//! same table via [`read_config`].
14//!
15//! Library users who build a [`Config`] in code do not need this module (and,
16//! with the feature off, do not pull the `toml` parser).
17
18use std::path::{Path, PathBuf};
19
20use crate::{
21    Config, Database, DatabaseBuilder, Embedder, HostError, MAX_OPEN_CEILING, OpenAiCompatEmbedder,
22    Opener, SharedEmbedder, Workspace, WorkspaceLayout, WorkspaceLimits,
23};
24
25/// Environment variable naming the config file (below an explicit path).
26const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
27/// Environment variable selecting the embedder kind (above the config file).
28const ENV_EMBEDDER: &str = "PLUGMEM_EMBEDDER";
29// Keep these inventories next to the parser. The settings-help tests compare
30// them with the public documentation catalogue, so adding a parser key without
31// adding its help entry fails loudly.
32pub(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];
41
42/// A configuration error: malformed TOML, a bad `[engine]` value, or an
43/// `[embedder]` section missing a required field. Distinct from [`HostError`]
44/// (which covers opening the database once settings are resolved).
45#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum SettingsError {
48    /// A usage error in the configuration (message is human-facing).
49    #[error("{0}")]
50    Config(String),
51}
52
53impl SettingsError {
54    fn config(msg: impl Into<String>) -> Self {
55        SettingsError::Config(msg.into())
56    }
57}
58
59/// Resolved runtime settings: the engine config, an optional embedder, and the
60/// maintenance policy. The wrapper-specific knobs (`import` batch size, server
61/// workers) are read separately from the same [`read_config`] table.
62pub struct Settings {
63    /// `[database].path`, if set. Wrapper-specific explicit paths take
64    /// precedence over this value; otherwise the platform default is used.
65    pub database_path: Option<PathBuf>,
66    /// The engine configuration (size-bearing fields from `[engine]`).
67    pub config: Config,
68    /// The embedder built from `[embedder]`, or `None` (lexical/graph/time
69    /// recall still work without one).
70    pub embedder: Option<Box<dyn Embedder>>,
71    /// `[maintenance].snapshot_every_ops`, if set.
72    pub snapshot_every_ops: Option<u64>,
73    /// `[maintenance].snapshot_journal_bytes`, if set.
74    pub snapshot_journal_bytes: Option<u64>,
75    /// `[maintenance].maintain_every_forgets`, if set.
76    pub maintain_every_forgets: Option<u64>,
77    /// The `[workspace]` section. Its `dir` is `None` unless the file names
78    /// one — **the default is a single database**, and nothing turns a
79    /// workspace on by itself.
80    pub workspace: WorkspaceSettings,
81}
82
83/// The `[workspace]` section: where a directory of named databases lives, and
84/// how many of them to keep open.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct WorkspaceSettings {
87    /// `[workspace].dir`, if set. Unset is the default and means there is no
88    /// workspace: one database, addressed by path, exactly as before.
89    pub dir: Option<PathBuf>,
90    /// Pool limits, defaulted when the section omits them.
91    pub limits: WorkspaceLimits,
92}
93
94impl Settings {
95    /// Loads settings from the config file resolved by [`read_config`] (an
96    /// explicit `flag` path, else `$PLUGMEM_CONFIG`, else the platform config
97    /// path from [`crate::default_config_path`]). Missing config → defaults.
98    pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
99        let table = read_config(flag)?;
100        Settings::from_table(table.as_ref())
101    }
102
103    /// Builds settings from an already-parsed config table (or `None` for
104    /// all defaults). `$PLUGMEM_EMBEDDER` overrides `[embedder].kind`. Use
105    /// this when the caller also needs its own keys from the same table
106    /// (read once via [`read_config`], then passed here).
107    pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
108        let mut config = Config::default();
109        let mut database_path = None;
110        let mut embedder = EmbedderCfg::default();
111        let mut snapshot_every_ops = None;
112        let mut snapshot_journal_bytes = None;
113        let mut maintain_every_forgets = None;
114        let mut workspace = WorkspaceSettings {
115            dir: None,
116            limits: WorkspaceLimits::default(),
117        };
118
119        if let Some(table) = table {
120            if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
121                database_path = t
122                    .get(DATABASE_SETTING_KEYS[0])
123                    .map(|value| {
124                        let path = value.as_str().ok_or_else(|| {
125                            SettingsError::config("[database].path must be a string")
126                        })?;
127                        if path.is_empty() {
128                            return Err(SettingsError::config("[database].path must not be empty"));
129                        }
130                        Ok(PathBuf::from(path))
131                    })
132                    .transpose()?;
133            }
134            if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
135                apply_engine(&mut config, t)?;
136            }
137            if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
138                embedder.merge(t);
139            }
140            if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
141                snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
142                snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
143                maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
144            }
145            if let Some(t) = table.get("workspace").and_then(toml::Value::as_table) {
146                workspace = parse_workspace(t)?;
147            }
148        }
149
150        if let Some(kind) = std::env::var_os(ENV_EMBEDDER) {
151            embedder.kind = Some(kind.to_string_lossy().into_owned());
152        }
153
154        let embedder = embedder.build(config.dim)?;
155        Ok(Settings {
156            database_path,
157            config,
158            embedder,
159            snapshot_every_ops,
160            snapshot_journal_bytes,
161            maintain_every_forgets,
162            workspace,
163        })
164    }
165
166    /// Opens a read-write [`Database`], applying the maintenance policy and
167    /// embedder to the builder. Consumes `self` (the embedder moves into the
168    /// database). For a read-only handle, take [`Settings::embedder`] out
169    /// first, then call [`Database::open_readonly`] with [`Settings::config`].
170    pub fn open(self, path: &Path) -> Result<Database, HostError> {
171        let mut b: DatabaseBuilder = Database::builder(self.config);
172        if let Some(v) = self.snapshot_every_ops {
173            b = b.snapshot_every_ops(v);
174        }
175        if let Some(v) = self.snapshot_journal_bytes {
176            b = b.snapshot_journal_bytes(v);
177        }
178        if let Some(v) = self.maintain_every_forgets {
179            b = b.maintain_every_forgets(v);
180        }
181        if let Some(e) = self.embedder {
182            b = b.embedder(e);
183        }
184        Ok(b.open(path)?.0)
185    }
186
187    /// Opens a [`Workspace`] rooted at `root`: many named databases, each built
188    /// with these same settings.
189    ///
190    /// The embedder is shared rather than duplicated — a hundred chats pointed
191    /// at one endpoint want one client, not a hundred (see [`SharedEmbedder`]).
192    ///
193    /// `root` is passed rather than read from [`WorkspaceSettings::dir`] so a
194    /// wrapper keeps its own precedence (flag, then environment, then config),
195    /// the same way it already does for the database path.
196    ///
197    /// # Errors
198    ///
199    /// Nothing yet — the databases open lazily, so a bad root is reported by
200    /// the first [`Workspace::get`] rather than here. The signature is
201    /// fallible because that is where the failure will move if the root ever
202    /// needs validating up front.
203    pub fn open_workspace(self, root: &Path) -> Result<Workspace, crate::WorkspaceError> {
204        let Settings {
205            config,
206            embedder,
207            snapshot_every_ops,
208            snapshot_journal_bytes,
209            maintain_every_forgets,
210            workspace,
211            ..
212        } = self;
213        let shared = embedder.map(SharedEmbedder::new);
214
215        let open: Opener = Box::new(move |path: &Path| {
216            let mut b = Database::builder(config.clone());
217            if let Some(v) = snapshot_every_ops {
218                b = b.snapshot_every_ops(v);
219            }
220            if let Some(v) = snapshot_journal_bytes {
221                b = b.snapshot_journal_bytes(v);
222            }
223            if let Some(v) = maintain_every_forgets {
224                b = b.maintain_every_forgets(v);
225            }
226            if let Some(e) = &shared {
227                b = b.embedder(Box::new(e.clone()));
228            }
229            Ok(b.open(path)?.0)
230        });
231        Ok(Workspace::new(
232            WorkspaceLayout::new(root),
233            open,
234            workspace.limits,
235        ))
236    }
237}
238
239/// Parses the `[workspace]` section. An out-of-range pool limit is a usage
240/// error rather than a silent clamp: a person who wrote a number meant it, and
241/// finding out later that it was ignored is worse than being told now.
242fn parse_workspace(t: &toml::Table) -> Result<WorkspaceSettings, SettingsError> {
243    let mut out = WorkspaceSettings {
244        dir: None,
245        limits: WorkspaceLimits::default(),
246    };
247    if let Some(value) = t.get(WORKSPACE_SETTING_KEYS[0]) {
248        let dir = value
249            .as_str()
250            .ok_or_else(|| SettingsError::config("[workspace].dir must be a string"))?;
251        if dir.is_empty() {
252            return Err(SettingsError::config("[workspace].dir must not be empty"));
253        }
254        out.dir = Some(PathBuf::from(dir));
255    }
256    if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[1]) {
257        if n == 0 || n > MAX_OPEN_CEILING as u64 {
258            return Err(SettingsError::config(format!(
259                "[workspace].max_open must be between 1 and {MAX_OPEN_CEILING} \
260                 (one open database costs several file descriptors)"
261            )));
262        }
263        // In range by the check above, so the narrowing cannot truncate — the
264        // comparison happens in `u64` precisely so it holds where `usize` is 32
265        // bits too.
266        out.limits.max_open = n as usize;
267    }
268    if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[2]) {
269        out.limits.idle_timeout_ms = n;
270    }
271    Ok(out)
272}
273
274/// Reads and parses `config.toml`, or `Ok(None)` if none applies. An explicit
275/// `flag` path **must** exist (a read error is a usage error); otherwise
276/// `$PLUGMEM_CONFIG`, then the platform path from
277/// [`crate::default_config_path`], are read only if present. Wrappers call this once, then pass the table to
278/// [`Settings::from_table`] and also read their own keys (batch size, workers)
279/// from it.
280pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
281    let text = match read_config_text(flag)? {
282        Some(t) => t,
283        None => return Ok(None),
284    };
285    let table: toml::Table = text
286        .parse()
287        .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
288    Ok(Some(table))
289}
290
291/// A non-negative integer key from a table as `u64`, or `None`.
292pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
293    t.get(key)
294        .and_then(toml::Value::as_integer)
295        .filter(|n| *n >= 0)
296        .map(|n| n as u64)
297}
298
299/// Reads the config file text with flag/env/platform-default precedence.
300fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
301    if let Some(p) = flag {
302        return std::fs::read_to_string(p)
303            .map(Some)
304            .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
305    }
306    let candidate = std::env::var_os(ENV_CONFIG)
307        .map(PathBuf::from)
308        .or_else(crate::default_config_path);
309    match candidate {
310        Some(p) if p.exists() => std::fs::read_to_string(&p)
311            .map(Some)
312            .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
313        _ => Ok(None),
314    }
315}
316
317/// Applies the `[engine]` table onto a [`Config`] (the size-bearing fields;
318/// tuning parameters keep their defaults). A non-integer or negative value is
319/// a usage error.
320fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
321    let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
322        (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
323        (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
324        (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
325        (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
326    ];
327    for (key, slot) in fields {
328        if let Some(v) = t.get(key) {
329            let n = v.as_integer().filter(|n| *n >= 0).ok_or_else(|| {
330                SettingsError::config(format!("[engine].{key} must be a non-negative integer"))
331            })?;
332            *slot = n as usize;
333        }
334    }
335    Ok(())
336}
337
338/// The `[embedder]` section, before it is turned into an [`Embedder`].
339#[derive(Default)]
340struct EmbedderCfg {
341    kind: Option<String>,
342    url: Option<String>,
343    model: Option<String>,
344    api_key_env: Option<String>,
345}
346
347impl EmbedderCfg {
348    fn merge(&mut self, t: &toml::Table) {
349        let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
350        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[0]) {
351            self.kind = Some(v);
352        }
353        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
354            self.url = Some(v);
355        }
356        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
357            self.model = Some(v);
358        }
359        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
360            self.api_key_env = Some(v);
361        }
362    }
363
364    /// Builds the embedder. `kind = "none"` (or unset) → no embedder; an
365    /// OpenAI-compatible `kind` (ollama/openai/lmstudio/vllm/llamacpp) needs a
366    /// `url`, a `model` and `[engine].dim > 0`; an optional `api_key_env` names
367    /// an environment variable holding the bearer token.
368    fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
369        let kind = self.kind.as_deref().unwrap_or("none");
370        match kind {
371            "none" | "" => Ok(None),
372            "ollama" | "openai" | "openai-compat" | "lmstudio" | "vllm" | "llamacpp" => {
373                let url = self.url.clone().ok_or_else(|| {
374                    SettingsError::config(format!("[embedder] kind \"{kind}\" needs a url"))
375                })?;
376                let model = self.model.clone().ok_or_else(|| {
377                    SettingsError::config(format!("[embedder] kind \"{kind}\" needs a model"))
378                })?;
379                if dim == 0 {
380                    return Err(SettingsError::config(
381                        "[embedder] requires [engine].dim > 0 (the embedding size)",
382                    ));
383                }
384                let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
385                if let Some(env) = &self.api_key_env
386                    && let Some(key) = std::env::var_os(env)
387                {
388                    e = e.with_api_key(key.to_string_lossy().into_owned());
389                }
390                Ok(Some(Box::new(e)))
391            }
392            other => Err(SettingsError::config(format!(
393                "unknown [embedder] kind: {other}"
394            ))),
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    /// A unique temp directory; removed on drop.
404    struct TempDir(PathBuf);
405    impl TempDir {
406        fn new(tag: &str) -> Self {
407            let dir = std::env::temp_dir().join(format!(
408                "plugmem-settings-{tag}-{}-{}",
409                std::process::id(),
410                std::time::SystemTime::now()
411                    .duration_since(std::time::UNIX_EPOCH)
412                    .unwrap()
413                    .as_nanos()
414            ));
415            std::fs::create_dir_all(&dir).unwrap();
416            TempDir(dir)
417        }
418    }
419    impl Drop for TempDir {
420        fn drop(&mut self) {
421            let _ = std::fs::remove_dir_all(&self.0);
422        }
423    }
424
425    #[test]
426    fn engine_and_maintenance_parse() {
427        let text = "\
428[engine]
429dim = 384
430max_text = 2048
431[maintenance]
432snapshot_every_ops = 50
433snapshot_journal_bytes = 8192
434maintain_every_forgets = 3
435";
436        let table: toml::Table = text.parse().unwrap();
437        let s = Settings::from_table(Some(&table)).unwrap();
438        assert_eq!(s.config.dim, 384);
439        assert_eq!(s.config.max_text, 2048);
440        assert_eq!(s.snapshot_every_ops, Some(50));
441        assert_eq!(s.snapshot_journal_bytes, Some(8192));
442        assert_eq!(s.maintain_every_forgets, Some(3));
443
444        let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
445        assert!(matches!(
446            Settings::from_table(Some(&bad)),
447            Err(SettingsError::Config(_))
448        ));
449    }
450
451    #[test]
452    fn defaults_when_no_table() {
453        let s = Settings::from_table(None).unwrap();
454        assert!(s.database_path.is_none());
455        assert_eq!(s.config.dim, Config::default().dim);
456        assert!(s.embedder.is_none());
457        assert_eq!(s.snapshot_every_ops, None);
458    }
459
460    #[test]
461    fn embedder_merge_reads_every_field() {
462        let text = "\
463[embedder]
464kind = \"ollama\"
465url = \"http://localhost:11434/v1\"
466model = \"nomic-embed-text\"
467api_key_env = \"SOME_ENV\"
468[engine]
469dim = 8
470";
471        let table: toml::Table = text.parse().unwrap();
472        // An OpenAI-compatible kind with a url, model and dim > 0 builds.
473        let s = Settings::from_table(Some(&table)).unwrap();
474        assert!(s.embedder.is_some());
475    }
476
477    #[test]
478    fn database_path_reads_and_validates_from_config() {
479        let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
480            .parse()
481            .unwrap();
482        let settings = Settings::from_table(Some(&table)).unwrap();
483        assert_eq!(
484            settings.database_path.as_deref(),
485            Some(std::path::Path::new("/tmp/memory.plugmem"))
486        );
487
488        let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
489        assert!(matches!(
490            Settings::from_table(Some(&bad)),
491            Err(SettingsError::Config(message)) if message == "[database].path must be a string"
492        ));
493    }
494
495    #[test]
496    fn settings_open_applies_maintenance_and_embedder() {
497        // Every maintenance knob set, plus an embedder, so `Settings::open`
498        // exercises each builder branch. The embedder is never invoked by a
499        // bare open, so an unreachable url is fine here.
500        let tmp = TempDir::new("open");
501        let mut config = Config::default();
502        config.dim = 8;
503        let embedder = EmbedderCfg {
504            kind: Some("ollama".into()),
505            url: Some("http://127.0.0.1:0/v1".into()),
506            model: Some("m".into()),
507            api_key_env: None,
508        }
509        .build(8)
510        .unwrap();
511        assert!(embedder.is_some());
512        let settings = Settings {
513            database_path: None,
514            config,
515            embedder,
516            snapshot_every_ops: Some(4),
517            snapshot_journal_bytes: Some(4096),
518            maintain_every_forgets: Some(2),
519            workspace: WorkspaceSettings {
520                dir: None,
521                limits: WorkspaceLimits::default(),
522            },
523        };
524        let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
525        assert_eq!(db.stats().facts, 0);
526    }
527
528    #[test]
529    fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
530        // The default is one database: no section, no workspace, nothing to
531        // configure. This is the case that must never drift.
532        let bare = Settings::from_table(None).unwrap();
533        assert_eq!(bare.workspace.dir, None);
534        assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
535
536        let table: toml::Table =
537            "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
538                .parse()
539                .unwrap();
540        let s = Settings::from_table(Some(&table)).unwrap();
541        assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
542        assert_eq!(s.workspace.limits.max_open, 4);
543        assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
544
545        // A section that only sets the directory keeps the defaults.
546        let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
547        let s = Settings::from_table(Some(&only_dir)).unwrap();
548        assert_eq!(s.workspace.limits, WorkspaceLimits::default());
549    }
550
551    #[test]
552    fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
553        // Not clamped: a number somebody wrote is a number they meant, and
554        // discovering later that it was ignored is worse than being told now.
555        for bad in [
556            "[workspace]\nmax_open = 0\n".to_string(),
557            format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
558            // Well past what a 32-bit `usize` could hold, so the range check
559            // has to happen before the narrowing.
560            "[workspace]\nmax_open = 9999999999\n".to_string(),
561        ] {
562            let table: toml::Table = bad.parse().unwrap();
563            assert!(
564                matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
565                "{bad}"
566            );
567        }
568
569        for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
570            let table: toml::Table = bad.parse().unwrap();
571            assert!(
572                matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
573                "{bad}"
574            );
575        }
576
577        // The largest accepted value is accepted.
578        let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
579            .parse()
580            .unwrap();
581        let s = Settings::from_table(Some(&table)).unwrap();
582        assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
583    }
584
585    #[test]
586    fn open_workspace_builds_databases_from_the_same_settings() {
587        let tmp = TempDir::new("open-workspace");
588        let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
589             snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
590            .parse()
591            .unwrap();
592        let settings = Settings::from_table(Some(&table)).unwrap();
593        let ws = settings.open_workspace(&tmp.0).unwrap();
594
595        let name = crate::DbName::parse("chat-42").unwrap();
596        let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
597        db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
598            .unwrap();
599        assert_eq!(db.stats().facts, 1);
600        assert!(ws.layout().exists(&name));
601    }
602
603    #[test]
604    fn embedder_build_rules() {
605        assert!(EmbedderCfg::default().build(0).unwrap().is_none());
606        let no_url = EmbedderCfg {
607            kind: Some("ollama".into()),
608            ..Default::default()
609        };
610        assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
611        let no_model = EmbedderCfg {
612            kind: Some("ollama".into()),
613            url: Some("http://x/v1".into()),
614            ..Default::default()
615        };
616        assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
617        let zero_dim = EmbedderCfg {
618            kind: Some("ollama".into()),
619            url: Some("http://x/v1".into()),
620            model: Some("m".into()),
621            api_key_env: None,
622        };
623        assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
624        let ok = EmbedderCfg {
625            kind: Some("openai".into()),
626            url: Some("http://x/v1".into()),
627            model: Some("m".into()),
628            api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
629        };
630        assert!(ok.build(384).unwrap().is_some());
631        let weird = EmbedderCfg {
632            kind: Some("weird".into()),
633            ..Default::default()
634        };
635        assert!(matches!(weird.build(384), Err(SettingsError::Config(_))));
636    }
637
638    #[test]
639    fn load_reads_the_config_file() {
640        let tmp = TempDir::new("load");
641        let cfgfile = tmp.0.join("config.toml");
642        std::fs::write(
643            &cfgfile,
644            "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n[maintenance]\nsnapshot_every_ops = 64\n",
645        )
646        .unwrap();
647        let s = Settings::load(Some(&cfgfile)).unwrap();
648        assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
649        assert_eq!(s.config.dim, 512);
650        assert!(s.embedder.is_none());
651        assert_eq!(s.snapshot_every_ops, Some(64));
652
653        // An explicit path that does not exist is a usage error.
654        assert!(matches!(
655            Settings::load(Some(&tmp.0.join("nope.toml"))),
656            Err(SettingsError::Config(_))
657        ));
658    }
659
660    #[test]
661    fn read_config_none_and_batch_extra() {
662        // No file → Ok(None); a wrapper reads its own extra key from the table.
663        let tmp = TempDir::new("extra");
664        let missing = tmp.0.join("absent.toml");
665        // An absent *default* (no flag) yields None only if neither env nor the
666        // XDG default exists; exercise the explicit-missing-flag error instead.
667        assert!(read_config(Some(&missing)).is_err());
668
669        let cfgfile = tmp.0.join("config.toml");
670        std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
671        let table = read_config(Some(&cfgfile)).unwrap().unwrap();
672        let batch = table
673            .get("maintenance")
674            .and_then(toml::Value::as_table)
675            .and_then(|m| table_u64(m, "batch_size"));
676        assert_eq!(batch, Some(256));
677    }
678
679    #[test]
680    fn every_host_setting_is_documented() {
681        let docs = crate::settings_help::settings_help().docs();
682        for (section, keys) in [
683            ("database", DATABASE_SETTING_KEYS),
684            ("workspace", WORKSPACE_SETTING_KEYS),
685            ("engine", ENGINE_SETTING_KEYS),
686            ("embedder", EMBEDDER_SETTING_KEYS),
687            ("maintenance", MAINTENANCE_SETTING_KEYS),
688        ] {
689            let documented: Vec<_> = docs
690                .iter()
691                .filter(|doc| {
692                    doc.section == section
693                        && doc.scope == crate::settings_help::SettingScope::Shared
694                })
695                .map(|doc| doc.key)
696                .collect();
697            assert_eq!(
698                documented.as_slice(),
699                keys,
700                "undocumented {section} setting"
701            );
702        }
703    }
704}