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 every wrapper shares, so they agree on config semantics
7//! and — more to the point — so a knob added once is a knob every surface
8//! offers. It reads six shared sections: `[database]` (the optional database
9//! path), `[engine]` (the size-bearing [`Config`] fields a database is built
10//! with), `[recall]` (what comes back for a query, and in what order),
11//! `[index]` (how the vector index is built), `[embedder]` (an
12//! OpenAI-compatible provider), and `[maintenance]` (snapshot/maintain
13//! thresholds and the fsync policy).
14//!
15//! Keys a specific wrapper owns — the CLI's `[maintenance].batch_size`, the
16//! server's `[server].workers` — are **not** parsed here; a wrapper reads them
17//! from the same table via [`read_config`]. They are still in the catalogue,
18//! because that is what tells [`crate::settings_help`] they are not typos.
19//!
20//! Anything else is reported through [`Settings::warnings`] rather than
21//! ignored: a misspelled key changes no behaviour, and saying nothing about it
22//! is how someone ends up believing they tuned something.
23//!
24//! Library users who build a [`Config`] in code do not need this module (and,
25//! with the feature off, do not pull the `toml` parser).
26
27use std::path::{Path, PathBuf};
28
29use crate::{
30    Config, Database, DatabaseBuilder, Embedder, FsyncPolicy, HostError, MAX_OPEN_CEILING,
31    OpenAiCompatEmbedder, Opener, SettingWarning, SharedEmbedder, Workspace, WorkspaceLayout,
32    WorkspaceLimits, settings_help::settings_help,
33};
34
35/// Environment variable naming the config file (below an explicit path).
36const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
37/// Environment variable selecting the embedder kind (above the config file).
38const ENV_EMBEDDER: &str = "PLUGMEM_EMBEDDER";
39// Keep these inventories next to the parser. The settings-help tests compare
40// them with the public documentation catalogue, so adding a parser key without
41// adding its help entry fails loudly.
42pub(crate) const ENGINE_SETTING_KEYS: &[&str] = &["dim", "max_bytes", "max_text", "max_blob"];
43/// `[recall]` — what comes back for a query, and in what order.
44///
45/// A separate section from `[engine]` because it answers a different question.
46/// `[engine]` is about how big things may get; these decide *answers*, and
47/// folding twenty of them into one section would bury the four that govern
48/// size. Every one of them may differ from what the file was written with:
49/// reopening with new weights is how a caller changes the ranking.
50pub(crate) const RECALL_SETTING_KEYS: &[&str] = &[
51    "bm25_k1",
52    "bm25_b",
53    "rrf_k",
54    "w_bm25",
55    "w_vec",
56    "w_graph",
57    "w_time",
58    "w_recency",
59    "half_life_days",
60    "graph_depth",
61    "graph_decay",
62    "hnsw_ef_search",
63    "similar_cos",
64    "similar_jaccard",
65];
66/// `[index]` — how the vector index is built, and when it stops being flat.
67pub(crate) const INDEX_SETTING_KEYS: &[&str] = &["hnsw_ef_construction", "flat_to_hnsw"];
68pub(crate) const DATABASE_SETTING_KEYS: &[&str] = &["path"];
69pub(crate) const WORKSPACE_SETTING_KEYS: &[&str] = &["dir", "max_open", "idle_timeout_ms"];
70pub(crate) const EMBEDDER_SETTING_KEYS: &[&str] = &["kind", "url", "model", "api_key_env"];
71pub(crate) const MAINTENANCE_SETTING_KEYS: &[&str] = &[
72    "snapshot_every_ops",
73    "snapshot_journal_bytes",
74    "maintain_every_forgets",
75    "fsync",
76];
77
78/// A configuration error: malformed TOML, a bad `[engine]` value, or an
79/// `[embedder]` section missing a required field. Distinct from [`HostError`]
80/// (which covers opening the database once settings are resolved).
81#[derive(Debug, thiserror::Error)]
82#[non_exhaustive]
83pub enum SettingsError {
84    /// A usage error in the configuration (message is human-facing).
85    #[error("{0}")]
86    Config(String),
87}
88
89impl SettingsError {
90    fn config(msg: impl Into<String>) -> Self {
91        SettingsError::Config(msg.into())
92    }
93}
94
95/// Resolved runtime settings: the engine config, an optional embedder, and the
96/// maintenance policy. The wrapper-specific knobs (`import` batch size, server
97/// workers) are read separately from the same [`read_config`] table.
98pub struct Settings {
99    /// `[database].path`, if set. Wrapper-specific explicit paths take
100    /// precedence over this value; otherwise the platform default is used.
101    pub database_path: Option<PathBuf>,
102    /// The engine configuration (size-bearing fields from `[engine]`).
103    pub config: Config,
104    /// The embedder built from `[embedder]`, or `None` (lexical/graph/time
105    /// recall still work without one).
106    pub embedder: Option<Box<dyn Embedder>>,
107    /// `[maintenance].snapshot_every_ops`, if set.
108    pub snapshot_every_ops: Option<u64>,
109    /// `[maintenance].snapshot_journal_bytes`, if set.
110    pub snapshot_journal_bytes: Option<u64>,
111    /// `[maintenance].maintain_every_forgets`, if set.
112    pub maintain_every_forgets: Option<u64>,
113    /// `[maintenance].fsync`, if set. `None` leaves the engine default
114    /// ([`FsyncPolicy::EachOp`]) — every acknowledged write survives a power
115    /// cut. This is the largest single lever on write throughput, which is why
116    /// changing it is a deliberate config edit and not a per-call flag.
117    pub fsync: Option<FsyncPolicy>,
118    /// The `[workspace]` section. Its `dir` is `None` unless the file names
119    /// one — **the default is a single database**, and nothing turns a
120    /// workspace on by itself.
121    pub workspace: WorkspaceSettings,
122    /// Sections and keys in the file that nothing claimed, in file order.
123    ///
124    /// Empty for a clean config, which is why this is a field rather than an
125    /// error: a typo must not stop a program that was configured correctly
126    /// enough to run. **Show them.** A surface that drops these is back to the
127    /// silence this exists to end — see [`SettingWarning`].
128    pub warnings: Vec<SettingWarning>,
129}
130
131/// The `[workspace]` section: where a directory of named databases lives, and
132/// how many of them to keep open.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct WorkspaceSettings {
135    /// `[workspace].dir`, if set. Unset is the default and means there is no
136    /// workspace: one database, addressed by path, exactly as before.
137    pub dir: Option<PathBuf>,
138    /// Pool limits, defaulted when the section omits them.
139    pub limits: WorkspaceLimits,
140}
141
142impl Settings {
143    /// Loads settings from the config file resolved by [`read_config`] (an
144    /// explicit `flag` path, else `$PLUGMEM_CONFIG`, else the platform config
145    /// path from [`crate::default_config_path`]). Missing config → defaults.
146    pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
147        let table = read_config(flag)?;
148        Settings::from_table(table.as_ref())
149    }
150
151    /// Builds settings from an already-parsed config table (or `None` for
152    /// all defaults). `$PLUGMEM_EMBEDDER` overrides `[embedder].kind`. Use
153    /// this when the caller also needs its own keys from the same table
154    /// (read once via [`read_config`], then passed here).
155    pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
156        let mut config = Config::default();
157        let mut database_path = None;
158        let mut embedder = EmbedderCfg::default();
159        let mut snapshot_every_ops = None;
160        let mut snapshot_journal_bytes = None;
161        let mut maintain_every_forgets = None;
162        let mut fsync = None;
163        let mut workspace = WorkspaceSettings {
164            dir: None,
165            limits: WorkspaceLimits::default(),
166        };
167        let warnings = table
168            .map(|t| settings_help().unknown_in(t))
169            .unwrap_or_default();
170
171        if let Some(table) = table {
172            if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
173                database_path = t
174                    .get(DATABASE_SETTING_KEYS[0])
175                    .map(|value| {
176                        let path = value.as_str().ok_or_else(|| {
177                            SettingsError::config("[database].path must be a string")
178                        })?;
179                        if path.is_empty() {
180                            return Err(SettingsError::config("[database].path must not be empty"));
181                        }
182                        Ok(PathBuf::from(path))
183                    })
184                    .transpose()?;
185            }
186            if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
187                apply_engine(&mut config, t)?;
188            }
189            if let Some(t) = table.get("recall").and_then(toml::Value::as_table) {
190                apply_recall(&mut config, t)?;
191            }
192            if let Some(t) = table.get("index").and_then(toml::Value::as_table) {
193                apply_index(&mut config, t)?;
194            }
195            // Ranges are the engine's to judge, and it already knows them: a
196            // weight must be finite and non-negative, `similar_cos` must be a
197            // cosine. Validating here rather than per-key keeps one definition
198            // of "valid" instead of a second copy that can drift from it.
199            config
200                .validate()
201                .map_err(|e| SettingsError::config(format!("config.toml: {e}")))?;
202            if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
203                embedder.merge(t);
204            }
205            if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
206                snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
207                snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
208                maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
209                fsync = parse_fsync(t)?;
210            }
211            if let Some(t) = table.get("workspace").and_then(toml::Value::as_table) {
212                workspace = parse_workspace(t)?;
213            }
214        }
215
216        if let Some(kind) = std::env::var_os(ENV_EMBEDDER) {
217            embedder.kind = Some(kind.to_string_lossy().into_owned());
218        }
219
220        let embedder = embedder.build(config.dim)?;
221        Ok(Settings {
222            database_path,
223            config,
224            embedder,
225            snapshot_every_ops,
226            snapshot_journal_bytes,
227            maintain_every_forgets,
228            fsync,
229            workspace,
230            warnings,
231        })
232    }
233
234    /// Opens a read-write [`Database`], applying the maintenance policy and
235    /// embedder to the builder. Consumes `self` (the embedder moves into the
236    /// database). For a read-only handle, take [`Settings::embedder`] out
237    /// first, then call [`Database::open_readonly`] with [`Settings::config`].
238    pub fn open(self, path: &Path) -> Result<Database, HostError> {
239        let mut b: DatabaseBuilder = Database::builder(self.config);
240        if let Some(v) = self.snapshot_every_ops {
241            b = b.snapshot_every_ops(v);
242        }
243        if let Some(v) = self.snapshot_journal_bytes {
244            b = b.snapshot_journal_bytes(v);
245        }
246        if let Some(v) = self.maintain_every_forgets {
247            b = b.maintain_every_forgets(v);
248        }
249        if let Some(v) = self.fsync {
250            b = b.fsync(v);
251        }
252        if let Some(e) = self.embedder {
253            b = b.embedder(e);
254        }
255        Ok(b.open(path)?.0)
256    }
257
258    /// Opens a [`Workspace`] rooted at `root`: many named databases, each built
259    /// with these same settings.
260    ///
261    /// The embedder is shared rather than duplicated — a hundred chats pointed
262    /// at one endpoint want one client, not a hundred (see [`SharedEmbedder`]).
263    ///
264    /// `root` is passed rather than read from [`WorkspaceSettings::dir`] so a
265    /// wrapper keeps its own precedence (flag, then environment, then config),
266    /// the same way it already does for the database path.
267    ///
268    /// # Errors
269    ///
270    /// Nothing yet — the databases open lazily, so a bad root is reported by
271    /// the first [`Workspace::get`] rather than here. The signature is
272    /// fallible because that is where the failure will move if the root ever
273    /// needs validating up front.
274    pub fn open_workspace(self, root: &Path) -> Result<Workspace, crate::WorkspaceError> {
275        let Settings {
276            config,
277            embedder,
278            snapshot_every_ops,
279            snapshot_journal_bytes,
280            maintain_every_forgets,
281            workspace,
282            ..
283        } = self;
284        let shared = embedder.map(SharedEmbedder::new);
285
286        let open: Opener = Box::new(move |path: &Path| {
287            let mut b = Database::builder(config.clone());
288            if let Some(v) = snapshot_every_ops {
289                b = b.snapshot_every_ops(v);
290            }
291            if let Some(v) = snapshot_journal_bytes {
292                b = b.snapshot_journal_bytes(v);
293            }
294            if let Some(v) = maintain_every_forgets {
295                b = b.maintain_every_forgets(v);
296            }
297            if let Some(e) = &shared {
298                b = b.embedder(Box::new(e.clone()));
299            }
300            Ok(b.open(path)?.0)
301        });
302        Ok(Workspace::new(
303            WorkspaceLayout::new(root),
304            open,
305            workspace.limits,
306        ))
307    }
308}
309
310/// Parses the `[workspace]` section. An out-of-range pool limit is a usage
311/// error rather than a silent clamp: a person who wrote a number meant it, and
312/// finding out later that it was ignored is worse than being told now.
313fn parse_workspace(t: &toml::Table) -> Result<WorkspaceSettings, SettingsError> {
314    let mut out = WorkspaceSettings {
315        dir: None,
316        limits: WorkspaceLimits::default(),
317    };
318    if let Some(value) = t.get(WORKSPACE_SETTING_KEYS[0]) {
319        let dir = value
320            .as_str()
321            .ok_or_else(|| SettingsError::config("[workspace].dir must be a string"))?;
322        if dir.is_empty() {
323            return Err(SettingsError::config("[workspace].dir must not be empty"));
324        }
325        out.dir = Some(PathBuf::from(dir));
326    }
327    if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[1]) {
328        if n == 0 || n > MAX_OPEN_CEILING as u64 {
329            return Err(SettingsError::config(format!(
330                "[workspace].max_open must be between 1 and {MAX_OPEN_CEILING} \
331                 (one open database costs several file descriptors)"
332            )));
333        }
334        // In range by the check above, so the narrowing cannot truncate — the
335        // comparison happens in `u64` precisely so it holds where `usize` is 32
336        // bits too.
337        out.limits.max_open = n as usize;
338    }
339    if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[2]) {
340        out.limits.idle_timeout_ms = n;
341    }
342    Ok(out)
343}
344
345/// Reads and parses `config.toml`, or `Ok(None)` if none applies. An explicit
346/// `flag` path **must** exist (a read error is a usage error); otherwise
347/// `$PLUGMEM_CONFIG`, then the platform path from
348/// [`crate::default_config_path`], are read only if present. Wrappers call this once, then pass the table to
349/// [`Settings::from_table`] and also read their own keys (batch size, workers)
350/// from it.
351pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
352    let text = match read_config_text(flag)? {
353        Some(t) => t,
354        None => return Ok(None),
355    };
356    let table: toml::Table = text
357        .parse()
358        .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
359    Ok(Some(table))
360}
361
362/// A non-negative integer key from a table as `u64`, or `None`.
363/// Reads `[maintenance].fsync` as a named policy.
364///
365/// A string rather than a boolean, because the two values are not opposites of
366/// one thing: `"each_op"` says *when* a record is durable, `"on_snapshot"` says
367/// which window may be lost. A misspelling is refused rather than silently
368/// treated as the default — quietly running with weaker durability than the
369/// file asks for is the one outcome worth erroring over.
370fn parse_fsync(t: &toml::Table) -> Result<Option<FsyncPolicy>, SettingsError> {
371    let Some(value) = t.get(MAINTENANCE_SETTING_KEYS[3]) else {
372        return Ok(None);
373    };
374    let name = value.as_str().ok_or_else(|| {
375        SettingsError::config("[maintenance].fsync must be \"each_op\" or \"on_snapshot\"")
376    })?;
377    match name {
378        "each_op" => Ok(Some(FsyncPolicy::EachOp)),
379        "on_snapshot" => Ok(Some(FsyncPolicy::OnSnapshot)),
380        other => Err(SettingsError::config(format!(
381            "[maintenance].fsync must be \"each_op\" or \"on_snapshot\", got \"{other}\""
382        ))),
383    }
384}
385
386pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
387    t.get(key)
388        .and_then(toml::Value::as_integer)
389        .filter(|n| *n >= 0)
390        .map(|n| n as u64)
391}
392
393/// Reads the config file text with flag/env/platform-default precedence.
394fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
395    if let Some(p) = flag {
396        return std::fs::read_to_string(p)
397            .map(Some)
398            .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
399    }
400    let candidate = std::env::var_os(ENV_CONFIG)
401        .map(PathBuf::from)
402        .or_else(crate::default_config_path);
403    match candidate {
404        Some(p) if p.exists() => std::fs::read_to_string(&p)
405            .map(Some)
406            .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
407        _ => Ok(None),
408    }
409}
410
411/// A non-negative integer from `[section].key`, or `None` when absent.
412fn setting_uint(t: &toml::Table, section: &str, key: &str) -> Result<Option<i64>, SettingsError> {
413    let Some(v) = t.get(key) else {
414        return Ok(None);
415    };
416    v.as_integer().filter(|n| *n >= 0).map(Some).ok_or_else(|| {
417        SettingsError::config(format!("[{section}].{key} must be a non-negative integer"))
418    })
419}
420
421/// A number from `[section].key` as `f32`, or `None` when absent.
422///
423/// An integer is accepted for a float key: `w_vec = 1` is what anyone writes,
424/// and refusing it over the missing decimal point would be pedantry.
425fn setting_f32(t: &toml::Table, section: &str, key: &str) -> Result<Option<f32>, SettingsError> {
426    let Some(v) = t.get(key) else {
427        return Ok(None);
428    };
429    v.as_float()
430        .or_else(|| v.as_integer().map(|n| n as f64))
431        .map(|n| Some(n as f32))
432        .ok_or_else(|| SettingsError::config(format!("[{section}].{key} must be a number")))
433}
434
435/// Applies the `[engine]` table onto a [`Config`]: the size-bearing fields,
436/// the ones a database is *built* with. See [`ENGINE_SETTING_KEYS`].
437fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
438    let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
439        (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
440        (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
441        (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
442        (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
443    ];
444    for (key, slot) in fields {
445        if let Some(n) = setting_uint(t, "engine", key)? {
446            *slot = n as usize;
447        }
448    }
449    Ok(())
450}
451
452/// Applies the `[recall]` table onto a [`Config`]. See
453/// [`RECALL_SETTING_KEYS`] for why these are their own section.
454fn apply_recall(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
455    let floats: [(&str, &mut f32); 10] = [
456        (RECALL_SETTING_KEYS[0], &mut cfg.bm25_k1),
457        (RECALL_SETTING_KEYS[1], &mut cfg.bm25_b),
458        (RECALL_SETTING_KEYS[3], &mut cfg.w_bm25),
459        (RECALL_SETTING_KEYS[4], &mut cfg.w_vec),
460        (RECALL_SETTING_KEYS[5], &mut cfg.w_graph),
461        (RECALL_SETTING_KEYS[6], &mut cfg.w_time),
462        (RECALL_SETTING_KEYS[7], &mut cfg.w_recency),
463        (RECALL_SETTING_KEYS[10], &mut cfg.graph_decay),
464        (RECALL_SETTING_KEYS[12], &mut cfg.similar_cos),
465        (RECALL_SETTING_KEYS[13], &mut cfg.similar_jaccard),
466    ];
467    for (key, slot) in floats {
468        if let Some(v) = setting_f32(t, "recall", key)? {
469            *slot = v;
470        }
471    }
472    let uints: [(&str, &mut u32); 3] = [
473        (RECALL_SETTING_KEYS[2], &mut cfg.rrf_k),
474        (RECALL_SETTING_KEYS[8], &mut cfg.half_life_days),
475        (RECALL_SETTING_KEYS[9], &mut cfg.graph_depth),
476    ];
477    for (key, slot) in uints {
478        if let Some(n) = setting_uint(t, "recall", key)? {
479            *slot = n as u32;
480        }
481    }
482    if let Some(n) = setting_uint(t, "recall", RECALL_SETTING_KEYS[11])? {
483        cfg.hnsw_ef_search = n as usize;
484    }
485    Ok(())
486}
487
488/// Applies the `[index]` table onto a [`Config`].
489fn apply_index(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
490    let fields: [(&str, &mut usize); INDEX_SETTING_KEYS.len()] = [
491        (INDEX_SETTING_KEYS[0], &mut cfg.hnsw_ef_construction),
492        (INDEX_SETTING_KEYS[1], &mut cfg.flat_to_hnsw),
493    ];
494    for (key, slot) in fields {
495        if let Some(n) = setting_uint(t, "index", key)? {
496            *slot = n as usize;
497        }
498    }
499    Ok(())
500}
501
502/// The `[embedder]` section, before it is turned into an [`Embedder`].
503#[derive(Default)]
504struct EmbedderCfg {
505    kind: Option<String>,
506    url: Option<String>,
507    model: Option<String>,
508    api_key_env: Option<String>,
509}
510
511impl EmbedderCfg {
512    fn merge(&mut self, t: &toml::Table) {
513        let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
514        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[0]) {
515            self.kind = Some(v);
516        }
517        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
518            self.url = Some(v);
519        }
520        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
521            self.model = Some(v);
522        }
523        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
524            self.api_key_env = Some(v);
525        }
526    }
527
528    /// Builds the embedder. `kind = "none"` (or unset) → no embedder; an
529    /// OpenAI-compatible `kind` (ollama/openai/lmstudio/vllm/llamacpp) needs a
530    /// `url`, a `model` and `[engine].dim > 0`; an optional `api_key_env` names
531    /// an environment variable holding the bearer token.
532    fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
533        let kind = self.kind.as_deref().unwrap_or("none");
534        match kind {
535            "none" | "" => Ok(None),
536            "ollama" | "openai" | "openai-compat" | "lmstudio" | "vllm" | "llamacpp" => {
537                let url = self.url.clone().ok_or_else(|| {
538                    SettingsError::config(format!("[embedder] kind \"{kind}\" needs a url"))
539                })?;
540                let model = self.model.clone().ok_or_else(|| {
541                    SettingsError::config(format!("[embedder] kind \"{kind}\" needs a model"))
542                })?;
543                if dim == 0 {
544                    return Err(SettingsError::config(
545                        "[embedder] requires [engine].dim > 0 (the embedding size)",
546                    ));
547                }
548                let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
549                if let Some(env) = &self.api_key_env
550                    && let Some(key) = std::env::var_os(env)
551                {
552                    e = e.with_api_key(key.to_string_lossy().into_owned());
553                }
554                Ok(Some(Box::new(e)))
555            }
556            other => Err(SettingsError::config(format!(
557                "unknown [embedder] kind: {other}"
558            ))),
559        }
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    /// A config table from lines, so the fixtures indent with the code instead
568    /// of being pinned to the file's left margin.
569    fn toml_of(lines: &[&str]) -> toml::Table {
570        lines.join("\n").parse().expect("valid TOML fixture")
571    }
572
573    /// A unique temp directory; removed on drop.
574    struct TempDir(PathBuf);
575    impl TempDir {
576        fn new(tag: &str) -> Self {
577            let dir = std::env::temp_dir().join(format!(
578                "plugmem-settings-{tag}-{}-{}",
579                std::process::id(),
580                std::time::SystemTime::now()
581                    .duration_since(std::time::UNIX_EPOCH)
582                    .unwrap()
583                    .as_nanos()
584            ));
585            std::fs::create_dir_all(&dir).unwrap();
586            TempDir(dir)
587        }
588    }
589    impl Drop for TempDir {
590        fn drop(&mut self) {
591            let _ = std::fs::remove_dir_all(&self.0);
592        }
593    }
594
595    #[test]
596    fn engine_and_maintenance_parse() {
597        let table = toml_of(&[
598            "[engine]",
599            "dim = 384",
600            "max_text = 2048",
601            "[maintenance]",
602            "snapshot_every_ops = 50",
603            "snapshot_journal_bytes = 8192",
604            "maintain_every_forgets = 3",
605        ]);
606        let s = Settings::from_table(Some(&table)).unwrap();
607        assert_eq!(s.config.dim, 384);
608        assert_eq!(s.config.max_text, 2048);
609        assert_eq!(s.snapshot_every_ops, Some(50));
610        assert_eq!(s.snapshot_journal_bytes, Some(8192));
611        assert_eq!(s.maintain_every_forgets, Some(3));
612
613        let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
614        assert!(matches!(
615            Settings::from_table(Some(&bad)),
616            Err(SettingsError::Config(_))
617        ));
618    }
619
620    #[test]
621    fn defaults_when_no_table() {
622        let s = Settings::from_table(None).unwrap();
623        assert!(s.database_path.is_none());
624        assert_eq!(s.config.dim, Config::default().dim);
625        assert!(s.embedder.is_none());
626        assert_eq!(s.snapshot_every_ops, None);
627    }
628
629    #[test]
630    fn embedder_merge_reads_every_field() {
631        let table = toml_of(&[
632            "[embedder]",
633            r#"kind = "ollama""#,
634            r#"url = "http://localhost:11434/v1""#,
635            r#"model = "nomic-embed-text""#,
636            r#"api_key_env = "SOME_ENV""#,
637            "[engine]",
638            "dim = 8",
639        ]);
640        // An OpenAI-compatible kind with a url, model and dim > 0 builds.
641        let s = Settings::from_table(Some(&table)).unwrap();
642        assert!(s.embedder.is_some());
643    }
644
645    #[test]
646    fn database_path_reads_and_validates_from_config() {
647        let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
648            .parse()
649            .unwrap();
650        let settings = Settings::from_table(Some(&table)).unwrap();
651        assert_eq!(
652            settings.database_path.as_deref(),
653            Some(std::path::Path::new("/tmp/memory.plugmem"))
654        );
655
656        let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
657        assert!(matches!(
658            Settings::from_table(Some(&bad)),
659            Err(SettingsError::Config(message)) if message == "[database].path must be a string"
660        ));
661    }
662
663    #[test]
664    fn settings_open_applies_maintenance_and_embedder() {
665        // Every maintenance knob set, plus an embedder, so `Settings::open`
666        // exercises each builder branch. The embedder is never invoked by a
667        // bare open, so an unreachable url is fine here.
668        let tmp = TempDir::new("open");
669        let mut config = Config::default();
670        config.dim = 8;
671        let embedder = EmbedderCfg {
672            kind: Some("ollama".into()),
673            url: Some("http://127.0.0.1:0/v1".into()),
674            model: Some("m".into()),
675            api_key_env: None,
676        }
677        .build(8)
678        .unwrap();
679        assert!(embedder.is_some());
680        let settings = Settings {
681            database_path: None,
682            config,
683            embedder,
684            snapshot_every_ops: Some(4),
685            snapshot_journal_bytes: Some(4096),
686            maintain_every_forgets: Some(2),
687            fsync: Some(FsyncPolicy::OnSnapshot),
688            workspace: WorkspaceSettings {
689                dir: None,
690                limits: WorkspaceLimits::default(),
691            },
692            warnings: Vec::new(),
693        };
694        let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
695        assert_eq!(db.stats().facts, 0);
696    }
697
698    #[test]
699    fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
700        // The default is one database: no section, no workspace, nothing to
701        // configure. This is the case that must never drift.
702        let bare = Settings::from_table(None).unwrap();
703        assert_eq!(bare.workspace.dir, None);
704        assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
705
706        let table: toml::Table =
707            "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
708                .parse()
709                .unwrap();
710        let s = Settings::from_table(Some(&table)).unwrap();
711        assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
712        assert_eq!(s.workspace.limits.max_open, 4);
713        assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
714
715        // A section that only sets the directory keeps the defaults.
716        let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
717        let s = Settings::from_table(Some(&only_dir)).unwrap();
718        assert_eq!(s.workspace.limits, WorkspaceLimits::default());
719    }
720
721    #[test]
722    fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
723        // Not clamped: a number somebody wrote is a number they meant, and
724        // discovering later that it was ignored is worse than being told now.
725        for bad in [
726            "[workspace]\nmax_open = 0\n".to_string(),
727            format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
728            // Well past what a 32-bit `usize` could hold, so the range check
729            // has to happen before the narrowing.
730            "[workspace]\nmax_open = 9999999999\n".to_string(),
731        ] {
732            let table: toml::Table = bad.parse().unwrap();
733            assert!(
734                matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
735                "{bad}"
736            );
737        }
738
739        for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
740            let table: toml::Table = bad.parse().unwrap();
741            assert!(
742                matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
743                "{bad}"
744            );
745        }
746
747        // The largest accepted value is accepted.
748        let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
749            .parse()
750            .unwrap();
751        let s = Settings::from_table(Some(&table)).unwrap();
752        assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
753    }
754
755    #[test]
756    fn open_workspace_builds_databases_from_the_same_settings() {
757        let tmp = TempDir::new("open-workspace");
758        let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
759             snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
760            .parse()
761            .unwrap();
762        let settings = Settings::from_table(Some(&table)).unwrap();
763        let ws = settings.open_workspace(&tmp.0).unwrap();
764
765        let name = crate::DbName::parse("chat-42").unwrap();
766        let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
767        db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
768            .unwrap();
769        assert_eq!(db.stats().facts, 1);
770        assert!(ws.layout().exists(&name));
771    }
772
773    #[test]
774    fn fsync_policy_is_named_and_a_misspelling_is_refused() {
775        let parse = |body: &str| {
776            let table: toml::Table = body.parse().unwrap();
777            let t = table.get("maintenance").unwrap().as_table().unwrap();
778            parse_fsync(t)
779        };
780
781        assert_eq!(
782            parse("[maintenance]\n").unwrap(),
783            None,
784            "absent stays default"
785        );
786        assert_eq!(
787            parse("[maintenance]\nfsync = \"each_op\"\n").unwrap(),
788            Some(FsyncPolicy::EachOp)
789        );
790        assert_eq!(
791            parse("[maintenance]\nfsync = \"on_snapshot\"\n").unwrap(),
792            Some(FsyncPolicy::OnSnapshot)
793        );
794
795        // The one thing worth erroring over: a typo must not quietly leave the
796        // database running with different durability than the file asks for.
797        for bad in [
798            "[maintenance]\nfsync = \"on-snapshot\"\n",
799            "[maintenance]\nfsync = \"none\"\n",
800            "[maintenance]\nfsync = true\n",
801            "[maintenance]\nfsync = 1\n",
802        ] {
803            let Err(err) = parse(bad) else {
804                panic!("{bad:?} must be refused");
805            };
806            assert!(
807                err.to_string().contains("each_op"),
808                "the message names the legal values: {err}"
809            );
810        }
811    }
812
813    #[test]
814    fn fsync_reaches_settings_from_the_config_file() {
815        // The gap this closes: `FsyncPolicy` was public in the host and
816        // reachable from nowhere else — not a CLI flag, not an MCP argument,
817        // not a napi option, not the config file. Only hand-written Rust.
818        let table: toml::Table = "[maintenance]\nfsync = \"on_snapshot\"\n".parse().unwrap();
819        let settings = Settings::from_table(Some(&table)).unwrap();
820        assert_eq!(settings.fsync, Some(FsyncPolicy::OnSnapshot));
821
822        let plain = Settings::from_table(None).unwrap();
823        assert_eq!(plain.fsync, None, "no config means the engine default");
824    }
825
826    #[test]
827    fn embedder_build_rules() {
828        assert!(EmbedderCfg::default().build(0).unwrap().is_none());
829        let no_url = EmbedderCfg {
830            kind: Some("ollama".into()),
831            ..Default::default()
832        };
833        assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
834        let no_model = EmbedderCfg {
835            kind: Some("ollama".into()),
836            url: Some("http://x/v1".into()),
837            ..Default::default()
838        };
839        assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
840        let zero_dim = EmbedderCfg {
841            kind: Some("ollama".into()),
842            url: Some("http://x/v1".into()),
843            model: Some("m".into()),
844            api_key_env: None,
845        };
846        assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
847        let ok = EmbedderCfg {
848            kind: Some("openai".into()),
849            url: Some("http://x/v1".into()),
850            model: Some("m".into()),
851            api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
852        };
853        assert!(ok.build(384).unwrap().is_some());
854        let weird = EmbedderCfg {
855            kind: Some("weird".into()),
856            ..Default::default()
857        };
858        assert!(matches!(weird.build(384), Err(SettingsError::Config(_))));
859    }
860
861    #[test]
862    fn load_reads_the_config_file() {
863        let tmp = TempDir::new("load");
864        let cfgfile = tmp.0.join("config.toml");
865        std::fs::write(
866            &cfgfile,
867            "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n[maintenance]\nsnapshot_every_ops = 64\n",
868        )
869        .unwrap();
870        let s = Settings::load(Some(&cfgfile)).unwrap();
871        assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
872        assert_eq!(s.config.dim, 512);
873        assert!(s.embedder.is_none());
874        assert_eq!(s.snapshot_every_ops, Some(64));
875
876        // An explicit path that does not exist is a usage error.
877        assert!(matches!(
878            Settings::load(Some(&tmp.0.join("nope.toml"))),
879            Err(SettingsError::Config(_))
880        ));
881    }
882
883    #[test]
884    fn read_config_none_and_batch_extra() {
885        // No file → Ok(None); a wrapper reads its own extra key from the table.
886        let tmp = TempDir::new("extra");
887        let missing = tmp.0.join("absent.toml");
888        // An absent *default* (no flag) yields None only if neither env nor the
889        // XDG default exists; exercise the explicit-missing-flag error instead.
890        assert!(read_config(Some(&missing)).is_err());
891
892        let cfgfile = tmp.0.join("config.toml");
893        std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
894        let table = read_config(Some(&cfgfile)).unwrap().unwrap();
895        let batch = table
896            .get("maintenance")
897            .and_then(toml::Value::as_table)
898            .and_then(|m| table_u64(m, "batch_size"));
899        assert_eq!(batch, Some(256));
900    }
901
902    #[test]
903    fn every_tuning_key_actually_reaches_the_config() {
904        // The test the missing one would have caught. Documenting a key and
905        // parsing it are two different acts, and for the whole of 0.5.0 the
906        // catalogue could have promised a knob that went nowhere: nothing
907        // compared a *value* written in the file against the `Config` that came
908        // out. Each key here is set to something no default equals, then read
909        // back off the resolved config.
910        let cfg = Config::default();
911        let table = toml_of(&[
912            "[recall]",
913            "bm25_k1 = 2.5",
914            "bm25_b = 0.25",
915            "rrf_k = 17",
916            "w_bm25 = 3.0",
917            "w_vec = 4.0",
918            "w_graph = 5.0",
919            "w_time = 6.0",
920            "w_recency = 0.75",
921            "half_life_days = 7",
922            "graph_depth = 4",
923            "graph_decay = 0.125",
924            "hnsw_ef_search = 111",
925            "similar_cos = 0.31",
926            "similar_jaccard = 0.32",
927            "[index]",
928            "hnsw_ef_construction = 222",
929            "flat_to_hnsw = 333",
930        ]);
931        let s = Settings::from_table(Some(&table)).unwrap();
932
933        assert_eq!(s.config.bm25_k1, 2.5);
934        assert_eq!(s.config.bm25_b, 0.25);
935        assert_eq!(s.config.rrf_k, 17);
936        assert_eq!(s.config.w_bm25, 3.0);
937        assert_eq!(s.config.w_vec, 4.0);
938        assert_eq!(s.config.w_graph, 5.0);
939        assert_eq!(s.config.w_time, 6.0);
940        assert_eq!(s.config.w_recency, 0.75);
941        assert_eq!(s.config.half_life_days, 7);
942        assert_eq!(s.config.graph_depth, 4);
943        assert_eq!(s.config.graph_decay, 0.125);
944        assert_eq!(s.config.hnsw_ef_search, 111);
945        assert_eq!(s.config.similar_cos, 0.31);
946        assert_eq!(s.config.similar_jaccard, 0.32);
947        assert_eq!(s.config.hnsw_ef_construction, 222);
948        assert_eq!(s.config.flat_to_hnsw, 333);
949
950        // Every value above differs from its default, so the assertions cannot
951        // pass on a parser that read nothing at all.
952        assert_ne!(s.config.bm25_k1, cfg.bm25_k1);
953        assert_ne!(s.config.flat_to_hnsw, cfg.flat_to_hnsw);
954        assert!(s.warnings.is_empty(), "{:?}", s.warnings);
955    }
956
957    #[test]
958    fn an_integer_is_accepted_where_a_float_is_meant() {
959        // `w_vec = 1` is what a person writes. Refusing it over the missing
960        // decimal point would be pedantry, and the failure would be a warning
961        // about a key that is spelled perfectly.
962        let table = toml_of(&["[recall]", "w_vec = 2", "graph_decay = 1"]);
963        let s = Settings::from_table(Some(&table)).unwrap();
964        assert_eq!(s.config.w_vec, 2.0);
965        assert_eq!(s.config.graph_decay, 1.0);
966    }
967
968    #[test]
969    fn a_tuning_value_out_of_range_is_refused_by_name() {
970        // The range belongs to the engine, and it names the field it rejected;
971        // this only has to carry that through instead of inventing a second,
972        // drifting copy of what "valid" means.
973        for line in ["graph_decay = 2.0", "similar_cos = -1.0", "w_vec = -0.5"] {
974            let table = toml_of(&["[recall]", line]);
975            let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
976                panic!("{line} must be refused");
977            };
978            let field = line.split(' ').next().unwrap();
979            assert!(
980                message.contains(field),
981                "the message must name the offending field: {message}"
982            );
983        }
984
985        // A wrong *type* is caught before the engine sees it, and names the
986        // section too, since the same key name can live in more than one.
987        let table = toml_of(&["[recall]", r#"w_vec = "lots""#]);
988        let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
989            panic!("a string weight must be refused");
990        };
991        assert!(message.contains("[recall].w_vec"), "{message}");
992    }
993
994    #[test]
995    fn every_host_setting_is_documented() {
996        let docs = crate::settings_help::settings_help().docs();
997        for (section, keys) in [
998            ("database", DATABASE_SETTING_KEYS),
999            ("workspace", WORKSPACE_SETTING_KEYS),
1000            ("engine", ENGINE_SETTING_KEYS),
1001            ("recall", RECALL_SETTING_KEYS),
1002            ("index", INDEX_SETTING_KEYS),
1003            ("embedder", EMBEDDER_SETTING_KEYS),
1004            ("maintenance", MAINTENANCE_SETTING_KEYS),
1005        ] {
1006            let documented: Vec<_> = docs
1007                .iter()
1008                .filter(|doc| {
1009                    doc.section == section
1010                        && doc.scope == crate::settings_help::SettingScope::Shared
1011                })
1012                .map(|doc| doc.key)
1013                .collect();
1014            assert_eq!(
1015                documented.as_slice(),
1016                keys,
1017                "undocumented {section} setting"
1018            );
1019        }
1020    }
1021}