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] = &["enabled", "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_ENABLED` overrides
153    /// `[embedder].enabled`. Use this when the caller also needs its own keys
154    /// from the same table (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(enabled) = std::env::var_os(ENV_EMBEDDER_ENABLED) {
217            embedder.enabled = Some(parse_embedder_enabled(&enabled.to_string_lossy())?);
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    enabled: Option<bool>,
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) -> Result<(), SettingsError> {
513        let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
514        if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[0]) {
515            self.enabled =
516                Some(value.as_bool().ok_or_else(|| {
517                    SettingsError::config("[embedder].enabled must be a boolean")
518                })?);
519        }
520        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
521            self.url = Some(v);
522        }
523        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
524            self.model = Some(v);
525        }
526        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
527            self.api_key_env = Some(v);
528        }
529        Ok(())
530    }
531
532    /// Builds the one supported embedder. An explicitly disabled embedder, or
533    /// an absent/incomplete section with no activation request, produces no
534    /// embedder. An active embedder needs a `url`, a `model` and
535    /// `[engine].dim > 0`; an optional `api_key_env` names an environment
536    /// variable holding the bearer token.
537    fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
538        let enabled = self
539            .enabled
540            .unwrap_or(self.url.is_some() || self.model.is_some());
541        if !enabled {
542            return Ok(None);
543        }
544        let url = self
545            .url
546            .clone()
547            .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a URL"))?;
548        let model = self
549            .model
550            .clone()
551            .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a model"))?;
552        if dim == 0 {
553            return Err(SettingsError::config(
554                "[embedder] requires [engine].dim > 0 (the embedding size)",
555            ));
556        }
557        let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
558        if let Some(env) = &self.api_key_env
559            && let Some(key) = std::env::var_os(env)
560        {
561            e = e.with_api_key(key.to_string_lossy().into_owned());
562        }
563        Ok(Some(Box::new(e)))
564    }
565}
566
567fn parse_embedder_enabled(value: &str) -> Result<bool, SettingsError> {
568    match value {
569        "true" => Ok(true),
570        "false" => Ok(false),
571        other => Err(SettingsError::config(format!(
572            "{ENV_EMBEDDER_ENABLED} must be true or false, got \"{other}\""
573        ))),
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    /// A config table from lines, so the fixtures indent with the code instead
582    /// of being pinned to the file's left margin.
583    fn toml_of(lines: &[&str]) -> toml::Table {
584        lines.join("\n").parse().expect("valid TOML fixture")
585    }
586
587    /// A unique temp directory; removed on drop.
588    struct TempDir(PathBuf);
589    impl TempDir {
590        fn new(tag: &str) -> Self {
591            let dir = std::env::temp_dir().join(format!(
592                "plugmem-settings-{tag}-{}-{}",
593                std::process::id(),
594                std::time::SystemTime::now()
595                    .duration_since(std::time::UNIX_EPOCH)
596                    .unwrap()
597                    .as_nanos()
598            ));
599            std::fs::create_dir_all(&dir).unwrap();
600            TempDir(dir)
601        }
602    }
603    impl Drop for TempDir {
604        fn drop(&mut self) {
605            let _ = std::fs::remove_dir_all(&self.0);
606        }
607    }
608
609    #[test]
610    fn engine_and_maintenance_parse() {
611        let table = toml_of(&[
612            "[engine]",
613            "dim = 384",
614            "max_text = 2048",
615            "[maintenance]",
616            "snapshot_every_ops = 50",
617            "snapshot_journal_bytes = 8192",
618            "maintain_every_forgets = 3",
619        ]);
620        let s = Settings::from_table(Some(&table)).unwrap();
621        assert_eq!(s.config.dim, 384);
622        assert_eq!(s.config.max_text, 2048);
623        assert_eq!(s.snapshot_every_ops, Some(50));
624        assert_eq!(s.snapshot_journal_bytes, Some(8192));
625        assert_eq!(s.maintain_every_forgets, Some(3));
626
627        let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
628        assert!(matches!(
629            Settings::from_table(Some(&bad)),
630            Err(SettingsError::Config(_))
631        ));
632    }
633
634    #[test]
635    fn defaults_when_no_table() {
636        let s = Settings::from_table(None).unwrap();
637        assert!(s.database_path.is_none());
638        assert_eq!(s.config.dim, Config::default().dim);
639        assert!(s.embedder.is_none());
640        assert_eq!(s.snapshot_every_ops, None);
641    }
642
643    #[test]
644    fn embedder_merge_reads_every_field() {
645        let table = toml_of(&[
646            "[embedder]",
647            "enabled = true",
648            r#"url = "http://localhost:11434/v1/embeddings""#,
649            r#"model = "nomic-embed-text""#,
650            r#"api_key_env = "SOME_ENV""#,
651            "[engine]",
652            "dim = 8",
653        ]);
654        // The shared OpenAI-compatible client builds with a url, model and
655        // dim > 0; the server may be OpenAI, Ollama or another compatible one.
656        let s = Settings::from_table(Some(&table)).unwrap();
657        assert!(s.embedder.is_some());
658    }
659
660    #[test]
661    fn database_path_reads_and_validates_from_config() {
662        let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
663            .parse()
664            .unwrap();
665        let settings = Settings::from_table(Some(&table)).unwrap();
666        assert_eq!(
667            settings.database_path.as_deref(),
668            Some(std::path::Path::new("/tmp/memory.plugmem"))
669        );
670
671        let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
672        assert!(matches!(
673            Settings::from_table(Some(&bad)),
674            Err(SettingsError::Config(message)) if message == "[database].path must be a string"
675        ));
676    }
677
678    #[test]
679    fn settings_open_applies_maintenance_and_embedder() {
680        // Every maintenance knob set, plus an embedder, so `Settings::open`
681        // exercises each builder branch. The embedder is never invoked by a
682        // bare open, so an unreachable url is fine here.
683        let tmp = TempDir::new("open");
684        let mut config = Config::default();
685        config.dim = 8;
686        let embedder = EmbedderCfg {
687            enabled: Some(true),
688            url: Some("http://127.0.0.1:0/v1/embeddings".into()),
689            model: Some("m".into()),
690            api_key_env: None,
691        }
692        .build(8)
693        .unwrap();
694        assert!(embedder.is_some());
695        let settings = Settings {
696            database_path: None,
697            config,
698            embedder,
699            snapshot_every_ops: Some(4),
700            snapshot_journal_bytes: Some(4096),
701            maintain_every_forgets: Some(2),
702            fsync: Some(FsyncPolicy::OnSnapshot),
703            workspace: WorkspaceSettings {
704                dir: None,
705                limits: WorkspaceLimits::default(),
706            },
707            warnings: Vec::new(),
708        };
709        let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
710        assert_eq!(db.stats().facts, 0);
711    }
712
713    #[test]
714    fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
715        // The default is one database: no section, no workspace, nothing to
716        // configure. This is the case that must never drift.
717        let bare = Settings::from_table(None).unwrap();
718        assert_eq!(bare.workspace.dir, None);
719        assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
720
721        let table: toml::Table =
722            "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
723                .parse()
724                .unwrap();
725        let s = Settings::from_table(Some(&table)).unwrap();
726        assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
727        assert_eq!(s.workspace.limits.max_open, 4);
728        assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
729
730        // A section that only sets the directory keeps the defaults.
731        let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
732        let s = Settings::from_table(Some(&only_dir)).unwrap();
733        assert_eq!(s.workspace.limits, WorkspaceLimits::default());
734    }
735
736    #[test]
737    fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
738        // Not clamped: a number somebody wrote is a number they meant, and
739        // discovering later that it was ignored is worse than being told now.
740        for bad in [
741            "[workspace]\nmax_open = 0\n".to_string(),
742            format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
743            // Well past what a 32-bit `usize` could hold, so the range check
744            // has to happen before the narrowing.
745            "[workspace]\nmax_open = 9999999999\n".to_string(),
746        ] {
747            let table: toml::Table = bad.parse().unwrap();
748            assert!(
749                matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
750                "{bad}"
751            );
752        }
753
754        for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
755            let table: toml::Table = bad.parse().unwrap();
756            assert!(
757                matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
758                "{bad}"
759            );
760        }
761
762        // The largest accepted value is accepted.
763        let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
764            .parse()
765            .unwrap();
766        let s = Settings::from_table(Some(&table)).unwrap();
767        assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
768    }
769
770    #[test]
771    fn open_workspace_builds_databases_from_the_same_settings() {
772        let tmp = TempDir::new("open-workspace");
773        let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
774             snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
775            .parse()
776            .unwrap();
777        let settings = Settings::from_table(Some(&table)).unwrap();
778        let ws = settings.open_workspace(&tmp.0).unwrap();
779
780        let name = crate::DbName::parse("chat-42").unwrap();
781        let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
782        db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
783            .unwrap();
784        assert_eq!(db.stats().facts, 1);
785        assert!(ws.layout().exists(&name));
786    }
787
788    #[test]
789    fn fsync_policy_is_named_and_a_misspelling_is_refused() {
790        let parse = |body: &str| {
791            let table: toml::Table = body.parse().unwrap();
792            let t = table.get("maintenance").unwrap().as_table().unwrap();
793            parse_fsync(t)
794        };
795
796        assert_eq!(
797            parse("[maintenance]\n").unwrap(),
798            None,
799            "absent stays default"
800        );
801        assert_eq!(
802            parse("[maintenance]\nfsync = \"each_op\"\n").unwrap(),
803            Some(FsyncPolicy::EachOp)
804        );
805        assert_eq!(
806            parse("[maintenance]\nfsync = \"on_snapshot\"\n").unwrap(),
807            Some(FsyncPolicy::OnSnapshot)
808        );
809
810        // The one thing worth erroring over: a typo must not quietly leave the
811        // database running with different durability than the file asks for.
812        for bad in [
813            "[maintenance]\nfsync = \"on-snapshot\"\n",
814            "[maintenance]\nfsync = \"none\"\n",
815            "[maintenance]\nfsync = true\n",
816            "[maintenance]\nfsync = 1\n",
817        ] {
818            let Err(err) = parse(bad) else {
819                panic!("{bad:?} must be refused");
820            };
821            assert!(
822                err.to_string().contains("each_op"),
823                "the message names the legal values: {err}"
824            );
825        }
826    }
827
828    #[test]
829    fn fsync_reaches_settings_from_the_config_file() {
830        // The gap this closes: `FsyncPolicy` was public in the host and
831        // reachable from nowhere else — not a CLI flag, not an MCP argument,
832        // not a napi option, not the config file. Only hand-written Rust.
833        let table: toml::Table = "[maintenance]\nfsync = \"on_snapshot\"\n".parse().unwrap();
834        let settings = Settings::from_table(Some(&table)).unwrap();
835        assert_eq!(settings.fsync, Some(FsyncPolicy::OnSnapshot));
836
837        let plain = Settings::from_table(None).unwrap();
838        assert_eq!(plain.fsync, None, "no config means the engine default");
839    }
840
841    #[test]
842    fn embedder_build_rules() {
843        assert!(EmbedderCfg::default().build(0).unwrap().is_none());
844        let no_url = EmbedderCfg {
845            enabled: Some(true),
846            ..Default::default()
847        };
848        assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
849        let no_model = EmbedderCfg {
850            enabled: Some(true),
851            url: Some("http://x/v1/embeddings".into()),
852            ..Default::default()
853        };
854        assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
855        let zero_dim = EmbedderCfg {
856            enabled: Some(true),
857            url: Some("http://x/v1/embeddings".into()),
858            model: Some("m".into()),
859            api_key_env: None,
860        };
861        assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
862        let ok = EmbedderCfg {
863            enabled: None,
864            url: Some("http://x/v1/embeddings".into()),
865            model: Some("m".into()),
866            api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
867        };
868        assert!(ok.build(384).unwrap().is_some());
869        let disabled = EmbedderCfg {
870            enabled: Some(false),
871            url: Some("http://x/v1/embeddings".into()),
872            model: Some("m".into()),
873            ..Default::default()
874        };
875        assert!(disabled.build(0).unwrap().is_none());
876        assert!(parse_embedder_enabled("true").unwrap());
877        assert!(!parse_embedder_enabled("false").unwrap());
878        assert!(parse_embedder_enabled("ollama").is_err());
879    }
880
881    #[test]
882    fn load_reads_the_config_file() {
883        let tmp = TempDir::new("load");
884        let cfgfile = tmp.0.join("config.toml");
885        std::fs::write(
886            &cfgfile,
887            "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nenabled = false\n[maintenance]\nsnapshot_every_ops = 64\n",
888        )
889        .unwrap();
890        let s = Settings::load(Some(&cfgfile)).unwrap();
891        assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
892        assert_eq!(s.config.dim, 512);
893        assert!(s.embedder.is_none());
894        assert_eq!(s.snapshot_every_ops, Some(64));
895
896        // An explicit path that does not exist is a usage error.
897        assert!(matches!(
898            Settings::load(Some(&tmp.0.join("nope.toml"))),
899            Err(SettingsError::Config(_))
900        ));
901    }
902
903    #[test]
904    fn read_config_none_and_batch_extra() {
905        // No file → Ok(None); a wrapper reads its own extra key from the table.
906        let tmp = TempDir::new("extra");
907        let missing = tmp.0.join("absent.toml");
908        // An absent *default* (no flag) yields None only if neither env nor the
909        // XDG default exists; exercise the explicit-missing-flag error instead.
910        assert!(read_config(Some(&missing)).is_err());
911
912        let cfgfile = tmp.0.join("config.toml");
913        std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
914        let table = read_config(Some(&cfgfile)).unwrap().unwrap();
915        let batch = table
916            .get("maintenance")
917            .and_then(toml::Value::as_table)
918            .and_then(|m| table_u64(m, "batch_size"));
919        assert_eq!(batch, Some(256));
920    }
921
922    #[test]
923    fn every_tuning_key_actually_reaches_the_config() {
924        // The test the missing one would have caught. Documenting a key and
925        // parsing it are two different acts, and for the whole of 0.5.0 the
926        // catalogue could have promised a knob that went nowhere: nothing
927        // compared a *value* written in the file against the `Config` that came
928        // out. Each key here is set to something no default equals, then read
929        // back off the resolved config.
930        let cfg = Config::default();
931        let table = toml_of(&[
932            "[recall]",
933            "bm25_k1 = 2.5",
934            "bm25_b = 0.25",
935            "rrf_k = 17",
936            "w_bm25 = 3.0",
937            "w_vec = 4.0",
938            "w_graph = 5.0",
939            "w_time = 6.0",
940            "w_recency = 0.75",
941            "half_life_days = 7",
942            "graph_depth = 4",
943            "graph_decay = 0.125",
944            "hnsw_ef_search = 111",
945            "similar_cos = 0.31",
946            "similar_jaccard = 0.32",
947            "[index]",
948            "hnsw_ef_construction = 222",
949            "flat_to_hnsw = 333",
950        ]);
951        let s = Settings::from_table(Some(&table)).unwrap();
952
953        assert_eq!(s.config.bm25_k1, 2.5);
954        assert_eq!(s.config.bm25_b, 0.25);
955        assert_eq!(s.config.rrf_k, 17);
956        assert_eq!(s.config.w_bm25, 3.0);
957        assert_eq!(s.config.w_vec, 4.0);
958        assert_eq!(s.config.w_graph, 5.0);
959        assert_eq!(s.config.w_time, 6.0);
960        assert_eq!(s.config.w_recency, 0.75);
961        assert_eq!(s.config.half_life_days, 7);
962        assert_eq!(s.config.graph_depth, 4);
963        assert_eq!(s.config.graph_decay, 0.125);
964        assert_eq!(s.config.hnsw_ef_search, 111);
965        assert_eq!(s.config.similar_cos, 0.31);
966        assert_eq!(s.config.similar_jaccard, 0.32);
967        assert_eq!(s.config.hnsw_ef_construction, 222);
968        assert_eq!(s.config.flat_to_hnsw, 333);
969
970        // Every value above differs from its default, so the assertions cannot
971        // pass on a parser that read nothing at all.
972        assert_ne!(s.config.bm25_k1, cfg.bm25_k1);
973        assert_ne!(s.config.flat_to_hnsw, cfg.flat_to_hnsw);
974        assert!(s.warnings.is_empty(), "{:?}", s.warnings);
975    }
976
977    #[test]
978    fn an_integer_is_accepted_where_a_float_is_meant() {
979        // `w_vec = 1` is what a person writes. Refusing it over the missing
980        // decimal point would be pedantry, and the failure would be a warning
981        // about a key that is spelled perfectly.
982        let table = toml_of(&["[recall]", "w_vec = 2", "graph_decay = 1"]);
983        let s = Settings::from_table(Some(&table)).unwrap();
984        assert_eq!(s.config.w_vec, 2.0);
985        assert_eq!(s.config.graph_decay, 1.0);
986    }
987
988    #[test]
989    fn a_tuning_value_out_of_range_is_refused_by_name() {
990        // The range belongs to the engine, and it names the field it rejected;
991        // this only has to carry that through instead of inventing a second,
992        // drifting copy of what "valid" means.
993        for line in ["graph_decay = 2.0", "similar_cos = -1.0", "w_vec = -0.5"] {
994            let table = toml_of(&["[recall]", line]);
995            let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
996                panic!("{line} must be refused");
997            };
998            let field = line.split(' ').next().unwrap();
999            assert!(
1000                message.contains(field),
1001                "the message must name the offending field: {message}"
1002            );
1003        }
1004
1005        // A wrong *type* is caught before the engine sees it, and names the
1006        // section too, since the same key name can live in more than one.
1007        let table = toml_of(&["[recall]", r#"w_vec = "lots""#]);
1008        let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
1009            panic!("a string weight must be refused");
1010        };
1011        assert!(message.contains("[recall].w_vec"), "{message}");
1012    }
1013
1014    #[test]
1015    fn every_host_setting_is_documented() {
1016        let docs = crate::settings_help::settings_help().docs();
1017        for (section, keys) in [
1018            ("database", DATABASE_SETTING_KEYS),
1019            ("workspace", WORKSPACE_SETTING_KEYS),
1020            ("engine", ENGINE_SETTING_KEYS),
1021            ("recall", RECALL_SETTING_KEYS),
1022            ("index", INDEX_SETTING_KEYS),
1023            ("embedder", EMBEDDER_SETTING_KEYS),
1024            ("maintenance", MAINTENANCE_SETTING_KEYS),
1025        ] {
1026            let documented: Vec<_> = docs
1027                .iter()
1028                .filter(|doc| {
1029                    doc.section == section
1030                        && doc.scope == crate::settings_help::SettingScope::Shared
1031                })
1032                .map(|doc| doc.key)
1033                .collect();
1034            assert_eq!(
1035                documented.as_slice(),
1036                keys,
1037                "undocumented {section} setting"
1038            );
1039        }
1040    }
1041}