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, FsyncPolicy, HostError, MAX_OPEN_CEILING,
22    OpenAiCompatEmbedder, 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    "fsync",
41];
42
43/// A configuration error: malformed TOML, a bad `[engine]` value, or an
44/// `[embedder]` section missing a required field. Distinct from [`HostError`]
45/// (which covers opening the database once settings are resolved).
46#[derive(Debug, thiserror::Error)]
47#[non_exhaustive]
48pub enum SettingsError {
49    /// A usage error in the configuration (message is human-facing).
50    #[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
60/// Resolved runtime settings: the engine config, an optional embedder, and the
61/// maintenance policy. The wrapper-specific knobs (`import` batch size, server
62/// workers) are read separately from the same [`read_config`] table.
63pub struct Settings {
64    /// `[database].path`, if set. Wrapper-specific explicit paths take
65    /// precedence over this value; otherwise the platform default is used.
66    pub database_path: Option<PathBuf>,
67    /// The engine configuration (size-bearing fields from `[engine]`).
68    pub config: Config,
69    /// The embedder built from `[embedder]`, or `None` (lexical/graph/time
70    /// recall still work without one).
71    pub embedder: Option<Box<dyn Embedder>>,
72    /// `[maintenance].snapshot_every_ops`, if set.
73    pub snapshot_every_ops: Option<u64>,
74    /// `[maintenance].snapshot_journal_bytes`, if set.
75    pub snapshot_journal_bytes: Option<u64>,
76    /// `[maintenance].maintain_every_forgets`, if set.
77    pub maintain_every_forgets: Option<u64>,
78    /// `[maintenance].fsync`, if set. `None` leaves the engine default
79    /// ([`FsyncPolicy::EachOp`]) — every acknowledged write survives a power
80    /// cut. This is the largest single lever on write throughput, which is why
81    /// changing it is a deliberate config edit and not a per-call flag.
82    pub fsync: Option<FsyncPolicy>,
83    /// The `[workspace]` section. Its `dir` is `None` unless the file names
84    /// one — **the default is a single database**, and nothing turns a
85    /// workspace on by itself.
86    pub workspace: WorkspaceSettings,
87}
88
89/// The `[workspace]` section: where a directory of named databases lives, and
90/// how many of them to keep open.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct WorkspaceSettings {
93    /// `[workspace].dir`, if set. Unset is the default and means there is no
94    /// workspace: one database, addressed by path, exactly as before.
95    pub dir: Option<PathBuf>,
96    /// Pool limits, defaulted when the section omits them.
97    pub limits: WorkspaceLimits,
98}
99
100impl Settings {
101    /// Loads settings from the config file resolved by [`read_config`] (an
102    /// explicit `flag` path, else `$PLUGMEM_CONFIG`, else the platform config
103    /// path from [`crate::default_config_path`]). Missing config → defaults.
104    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    /// Builds settings from an already-parsed config table (or `None` for
110    /// all defaults). `$PLUGMEM_EMBEDDER` overrides `[embedder].kind`. Use
111    /// this when the caller also needs its own keys from the same table
112    /// (read once via [`read_config`], then passed here).
113    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    /// Opens a read-write [`Database`], applying the maintenance policy and
176    /// embedder to the builder. Consumes `self` (the embedder moves into the
177    /// database). For a read-only handle, take [`Settings::embedder`] out
178    /// first, then call [`Database::open_readonly`] with [`Settings::config`].
179    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    /// Opens a [`Workspace`] rooted at `root`: many named databases, each built
200    /// with these same settings.
201    ///
202    /// The embedder is shared rather than duplicated — a hundred chats pointed
203    /// at one endpoint want one client, not a hundred (see [`SharedEmbedder`]).
204    ///
205    /// `root` is passed rather than read from [`WorkspaceSettings::dir`] so a
206    /// wrapper keeps its own precedence (flag, then environment, then config),
207    /// the same way it already does for the database path.
208    ///
209    /// # Errors
210    ///
211    /// Nothing yet — the databases open lazily, so a bad root is reported by
212    /// the first [`Workspace::get`] rather than here. The signature is
213    /// fallible because that is where the failure will move if the root ever
214    /// needs validating up front.
215    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
251/// Parses the `[workspace]` section. An out-of-range pool limit is a usage
252/// error rather than a silent clamp: a person who wrote a number meant it, and
253/// finding out later that it was ignored is worse than being told now.
254fn 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        // In range by the check above, so the narrowing cannot truncate — the
276        // comparison happens in `u64` precisely so it holds where `usize` is 32
277        // bits too.
278        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
286/// Reads and parses `config.toml`, or `Ok(None)` if none applies. An explicit
287/// `flag` path **must** exist (a read error is a usage error); otherwise
288/// `$PLUGMEM_CONFIG`, then the platform path from
289/// [`crate::default_config_path`], are read only if present. Wrappers call this once, then pass the table to
290/// [`Settings::from_table`] and also read their own keys (batch size, workers)
291/// from it.
292pub 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
303/// A non-negative integer key from a table as `u64`, or `None`.
304/// Reads `[maintenance].fsync` as a named policy.
305///
306/// A string rather than a boolean, because the two values are not opposites of
307/// one thing: `"each_op"` says *when* a record is durable, `"on_snapshot"` says
308/// which window may be lost. A misspelling is refused rather than silently
309/// treated as the default — quietly running with weaker durability than the
310/// file asks for is the one outcome worth erroring over.
311fn 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
334/// Reads the config file text with flag/env/platform-default precedence.
335fn 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
352/// Applies the `[engine]` table onto a [`Config`] (the size-bearing fields;
353/// tuning parameters keep their defaults). A non-integer or negative value is
354/// a usage error.
355fn 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/// The `[embedder]` section, before it is turned into an [`Embedder`].
374#[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    /// Builds the embedder. `kind = "none"` (or unset) → no embedder; an
400    /// OpenAI-compatible `kind` (ollama/openai/lmstudio/vllm/llamacpp) needs a
401    /// `url`, a `model` and `[engine].dim > 0`; an optional `api_key_env` names
402    /// an environment variable holding the bearer token.
403    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    /// A unique temp directory; removed on drop.
439    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        // An OpenAI-compatible kind with a url, model and dim > 0 builds.
508        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        // Every maintenance knob set, plus an embedder, so `Settings::open`
533        // exercises each builder branch. The embedder is never invoked by a
534        // bare open, so an unreachable url is fine here.
535        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        // The default is one database: no section, no workspace, nothing to
567        // configure. This is the case that must never drift.
568        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        // A section that only sets the directory keeps the defaults.
582        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        // Not clamped: a number somebody wrote is a number they meant, and
590        // discovering later that it was ignored is worse than being told now.
591        for bad in [
592            "[workspace]\nmax_open = 0\n".to_string(),
593            format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
594            // Well past what a 32-bit `usize` could hold, so the range check
595            // has to happen before the narrowing.
596            "[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        // The largest accepted value is accepted.
614        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        // The one thing worth erroring over: a typo must not quietly leave the
662        // database running with different durability than the file asks for.
663        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        // The gap this closes: `FsyncPolicy` was public in the host and
682        // reachable from nowhere else — not a CLI flag, not an MCP argument,
683        // not a napi option, not the config file. Only hand-written Rust.
684        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        // An explicit path that does not exist is a usage error.
743        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        // No file → Ok(None); a wrapper reads its own extra key from the table.
752        let tmp = TempDir::new("extra");
753        let missing = tmp.0.join("absent.toml");
754        // An absent *default* (no flag) yields None only if neither env nor the
755        // XDG default exists; exercise the explicit-missing-flag error instead.
756        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}