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