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};
28use std::time::Duration;
29
30use crate::{
31    Config, Database, DatabaseBuilder, EmbedErrorPolicy, EmbedRetry, Embedder, FsyncPolicy,
32    HostError, MAX_OPEN_CEILING, OpenAiCompatEmbedder, Opener, SettingWarning, SharedEmbedder,
33    Workspace, WorkspaceLayout, WorkspaceLimits, settings_help::settings_help,
34};
35
36/// Environment variable naming the config file (below an explicit path).
37const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
38/// Environment variable that overrides `[embedder].enabled`.
39const ENV_EMBEDDER_ENABLED: &str = "PLUGMEM_EMBEDDER_ENABLED";
40/// Environment variable that overrides `[embedder].on_error`.
41const ENV_EMBEDDER_ON_ERROR: &str = "PLUGMEM_EMBEDDER_ON_ERROR";
42/// Environment variable that overrides `[embedder].timeout_ms`.
43const ENV_EMBEDDER_TIMEOUT_MS: &str = "PLUGMEM_EMBEDDER_TIMEOUT_MS";
44/// Environment variable that overrides `[embedder].retry_after_ms`.
45const ENV_EMBEDDER_RETRY_AFTER_MS: &str = "PLUGMEM_EMBEDDER_RETRY_AFTER_MS";
46/// Environment variable that overrides `[embedder].retry_max_ms`.
47const ENV_EMBEDDER_RETRY_MAX_MS: &str = "PLUGMEM_EMBEDDER_RETRY_MAX_MS";
48// Keep these inventories next to the parser. The settings-help tests compare
49// them with the public documentation catalogue, so adding a parser key without
50// adding its help entry fails loudly.
51pub(crate) const ENGINE_SETTING_KEYS: &[&str] = &["dim", "max_bytes", "max_text", "max_blob"];
52/// `[recall]` — what comes back for a query, and in what order.
53///
54/// A separate section from `[engine]` because it answers a different question.
55/// `[engine]` is about how big things may get; these decide *answers*, and
56/// folding twenty of them into one section would bury the four that govern
57/// size. Every one of them may differ from what the file was written with:
58/// reopening with new weights is how a caller changes the ranking.
59pub(crate) const RECALL_SETTING_KEYS: &[&str] = &[
60    "bm25_k1",
61    "bm25_b",
62    "rrf_k",
63    "w_bm25",
64    "w_vec",
65    "w_graph",
66    "w_time",
67    "w_recency",
68    "half_life_days",
69    "graph_depth",
70    "graph_decay",
71    "hnsw_ef_search",
72    "similar_cos",
73    "similar_jaccard",
74];
75/// `[index]` — how the vector index is built, and when it stops being flat.
76pub(crate) const INDEX_SETTING_KEYS: &[&str] = &["hnsw_ef_construction", "flat_to_hnsw"];
77pub(crate) const DATABASE_SETTING_KEYS: &[&str] = &["path"];
78pub(crate) const WORKSPACE_SETTING_KEYS: &[&str] = &["dir", "max_open", "idle_timeout_ms"];
79pub(crate) const EMBEDDER_SETTING_KEYS: &[&str] = &[
80    "enabled",
81    "url",
82    "model",
83    "space_id",
84    "api_key_env",
85    "on_error",
86    "timeout_ms",
87    "retry_after_ms",
88    "retry_max_ms",
89];
90pub(crate) const MAINTENANCE_SETTING_KEYS: &[&str] = &[
91    "snapshot_every_ops",
92    "snapshot_journal_bytes",
93    "maintain_every_forgets",
94    "fsync",
95];
96
97/// A configuration error: malformed TOML, a bad `[engine]` value, or an
98/// `[embedder]` section missing a required field. Distinct from [`HostError`]
99/// (which covers opening the database once settings are resolved).
100#[derive(Debug, thiserror::Error)]
101#[non_exhaustive]
102pub enum SettingsError {
103    /// A usage error in the configuration (message is human-facing).
104    #[error("{0}")]
105    Config(String),
106}
107
108impl SettingsError {
109    fn config(msg: impl Into<String>) -> Self {
110        SettingsError::Config(msg.into())
111    }
112}
113
114/// Resolved runtime settings: the engine config, an optional embedder, and the
115/// maintenance policy. The wrapper-specific knobs (`import` batch size, server
116/// workers) are read separately from the same [`read_config`] table.
117pub struct Settings {
118    /// `[database].path`, if set. Wrapper-specific explicit paths take
119    /// precedence over this value; otherwise the platform default is used.
120    pub database_path: Option<PathBuf>,
121    /// The engine configuration (size-bearing fields from `[engine]`).
122    pub config: Config,
123    /// The embedder built from `[embedder]`, or `None` (lexical/graph/time
124    /// recall still work without one).
125    pub embedder: Option<Box<dyn Embedder>>,
126    /// `[embedder].on_error` — what a verb does when the provider cannot be
127    /// reached. Defaults to [`EmbedErrorPolicy::Fail`], which is what every
128    /// release before this one did.
129    pub embed_error_policy: EmbedErrorPolicy,
130    /// `[embedder].retry_after_ms` / `retry_max_ms` — when a database that
131    /// suspended its own embedder calls it again. Inert unless the policy is
132    /// [`EmbedErrorPolicy::Degrade`], since nothing else suspends by itself.
133    pub embed_retry: EmbedRetry,
134    /// `[maintenance].snapshot_every_ops`, if set.
135    pub snapshot_every_ops: Option<u64>,
136    /// `[maintenance].snapshot_journal_bytes`, if set.
137    pub snapshot_journal_bytes: Option<u64>,
138    /// `[maintenance].maintain_every_forgets`, if set.
139    pub maintain_every_forgets: Option<u64>,
140    /// `[maintenance].fsync`, if set. `None` leaves the engine default
141    /// ([`FsyncPolicy::EachOp`]) — every acknowledged write survives a power
142    /// cut. This is the largest single lever on write throughput, which is why
143    /// changing it is a deliberate config edit and not a per-call flag.
144    pub fsync: Option<FsyncPolicy>,
145    /// The `[workspace]` section. Its `dir` is `None` unless the file names
146    /// one — **the default is a single database**, and nothing turns a
147    /// workspace on by itself.
148    pub workspace: WorkspaceSettings,
149    /// Sections and keys in the file that nothing claimed, in file order.
150    ///
151    /// Empty for a clean config, which is why this is a field rather than an
152    /// error: a typo must not stop a program that was configured correctly
153    /// enough to run. **Show them.** A surface that drops these is back to the
154    /// silence this exists to end — see [`SettingWarning`].
155    pub warnings: Vec<SettingWarning>,
156}
157
158/// The `[workspace]` section: where a directory of named databases lives, and
159/// how many of them to keep open.
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub struct WorkspaceSettings {
162    /// `[workspace].dir`, if set. Unset is the default and means there is no
163    /// workspace: one database, addressed by path, exactly as before.
164    pub dir: Option<PathBuf>,
165    /// Pool limits, defaulted when the section omits them.
166    pub limits: WorkspaceLimits,
167}
168
169impl Settings {
170    /// Loads settings from the config file resolved by [`read_config`] (an
171    /// explicit `flag` path, else `$PLUGMEM_CONFIG`, else the platform config
172    /// path from [`crate::default_config_path`]). Missing config → defaults.
173    pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
174        let table = read_config(flag)?;
175        Settings::from_table(table.as_ref())
176    }
177
178    /// Builds settings from an already-parsed config table (or `None` for
179    /// all defaults). `$PLUGMEM_EMBEDDER_ENABLED` overrides
180    /// `[embedder].enabled`. Use this when the caller also needs its own keys
181    /// from the same table (read once via [`read_config`], then passed here).
182    pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
183        let mut config = Config::default();
184        let mut database_path = None;
185        let mut embedder = EmbedderCfg::default();
186        let mut snapshot_every_ops = None;
187        let mut snapshot_journal_bytes = None;
188        let mut maintain_every_forgets = None;
189        let mut fsync = None;
190        let mut workspace = WorkspaceSettings {
191            dir: None,
192            limits: WorkspaceLimits::default(),
193        };
194        let warnings = table
195            .map(|t| settings_help().unknown_in(t))
196            .unwrap_or_default();
197
198        if let Some(table) = table {
199            if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
200                database_path = t
201                    .get(DATABASE_SETTING_KEYS[0])
202                    .map(|value| {
203                        let path = value.as_str().ok_or_else(|| {
204                            SettingsError::config("[database].path must be a string")
205                        })?;
206                        if path.is_empty() {
207                            return Err(SettingsError::config("[database].path must not be empty"));
208                        }
209                        Ok(PathBuf::from(path))
210                    })
211                    .transpose()?;
212            }
213            if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
214                apply_engine(&mut config, t)?;
215            }
216            if let Some(t) = table.get("recall").and_then(toml::Value::as_table) {
217                apply_recall(&mut config, t)?;
218            }
219            if let Some(t) = table.get("index").and_then(toml::Value::as_table) {
220                apply_index(&mut config, t)?;
221            }
222            // Ranges are the engine's to judge, and it already knows them: a
223            // weight must be finite and non-negative, `similar_cos` must be a
224            // cosine. Validating here rather than per-key keeps one definition
225            // of "valid" instead of a second copy that can drift from it.
226            config
227                .validate()
228                .map_err(|e| SettingsError::config(format!("config.toml: {e}")))?;
229            if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
230                embedder.merge(t)?;
231            }
232            if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
233                snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
234                snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
235                maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
236                fsync = parse_fsync(t)?;
237            }
238            if let Some(t) = table.get("workspace").and_then(toml::Value::as_table) {
239                workspace = parse_workspace(t)?;
240            }
241        }
242
243        // Environment over file, for every key of this section rather than
244        // for one of them: the operational moment these exist for - "the
245        // provider is down, run without it for now" - is exactly when editing
246        // a config file is the wrong thing to ask of somebody.
247        if let Some(enabled) = std::env::var_os(ENV_EMBEDDER_ENABLED) {
248            embedder.enabled = Some(parse_embedder_enabled(&enabled.to_string_lossy())?);
249        }
250        if let Some(policy) = std::env::var_os(ENV_EMBEDDER_ON_ERROR) {
251            embedder.on_error = Some(parse_on_error(&policy.to_string_lossy())?);
252        }
253        if let Some(ms) = std::env::var_os(ENV_EMBEDDER_TIMEOUT_MS) {
254            embedder.timeout = Some(parse_timeout_ms(&env_number(
255                &ms.to_string_lossy(),
256                ENV_EMBEDDER_TIMEOUT_MS,
257            )?));
258        }
259        if let Some(ms) = std::env::var_os(ENV_EMBEDDER_RETRY_AFTER_MS) {
260            embedder.retry_after_ms = Some(env_number(
261                &ms.to_string_lossy(),
262                ENV_EMBEDDER_RETRY_AFTER_MS,
263            )?);
264        }
265        if let Some(ms) = std::env::var_os(ENV_EMBEDDER_RETRY_MAX_MS) {
266            embedder.retry_max_ms = Some(env_number(
267                &ms.to_string_lossy(),
268                ENV_EMBEDDER_RETRY_MAX_MS,
269            )?);
270        }
271
272        let embed_error_policy = embedder.on_error.unwrap_or_default();
273        let embed_retry = embedder.retry();
274        let embedder = embedder.build(config.dim)?;
275        Ok(Settings {
276            database_path,
277            config,
278            embedder,
279            embed_error_policy,
280            embed_retry,
281            snapshot_every_ops,
282            snapshot_journal_bytes,
283            maintain_every_forgets,
284            fsync,
285            workspace,
286            warnings,
287        })
288    }
289
290    /// Opens a read-write [`Database`], applying the maintenance policy and
291    /// embedder to the builder. Consumes `self` (the embedder moves into the
292    /// database). For a read-only handle, take [`Settings::embedder`] out
293    /// first, then call [`Database::open_readonly`] with [`Settings::config`].
294    pub fn open(self, path: &Path) -> Result<Database, HostError> {
295        let mut b: DatabaseBuilder = Database::builder(self.config);
296        if let Some(v) = self.snapshot_every_ops {
297            b = b.snapshot_every_ops(v);
298        }
299        if let Some(v) = self.snapshot_journal_bytes {
300            b = b.snapshot_journal_bytes(v);
301        }
302        if let Some(v) = self.maintain_every_forgets {
303            b = b.maintain_every_forgets(v);
304        }
305        if let Some(v) = self.fsync {
306            b = b.fsync(v);
307        }
308        if let Some(e) = self.embedder {
309            b = b.embedder(e);
310        }
311        b = b
312            .on_embed_error(self.embed_error_policy)
313            .embed_retry(self.embed_retry);
314        Ok(b.open(path)?.0)
315    }
316
317    /// Opens a [`Workspace`] rooted at `root`: many named databases, each built
318    /// with these same settings.
319    ///
320    /// The embedder is shared rather than duplicated — a hundred chats pointed
321    /// at one endpoint want one client, not a hundred (see [`SharedEmbedder`]).
322    ///
323    /// `root` is passed rather than read from [`WorkspaceSettings::dir`] so a
324    /// wrapper keeps its own precedence (flag, then environment, then config),
325    /// the same way it already does for the database path.
326    ///
327    /// # Errors
328    ///
329    /// Nothing yet — the databases open lazily, so a bad root is reported by
330    /// the first [`Workspace::get`] rather than here. The signature is
331    /// fallible because that is where the failure will move if the root ever
332    /// needs validating up front.
333    pub fn open_workspace(self, root: &Path) -> Result<Workspace, crate::WorkspaceError> {
334        let Settings {
335            config,
336            embedder,
337            embed_error_policy,
338            embed_retry,
339            snapshot_every_ops,
340            snapshot_journal_bytes,
341            maintain_every_forgets,
342            workspace,
343            ..
344        } = self;
345        let shared = embedder.map(SharedEmbedder::new);
346
347        let open: Opener = Box::new(move |path: &Path| {
348            let mut b = Database::builder(config.clone());
349            if let Some(v) = snapshot_every_ops {
350                b = b.snapshot_every_ops(v);
351            }
352            if let Some(v) = snapshot_journal_bytes {
353                b = b.snapshot_journal_bytes(v);
354            }
355            if let Some(v) = maintain_every_forgets {
356                b = b.maintain_every_forgets(v);
357            }
358            if let Some(e) = &shared {
359                b = b.embedder(Box::new(e.clone()));
360            }
361            // Every database in a workspace shares one provider, so they must
362            // also share what happens when it stops answering; a per-database
363            // default here would degrade one memory and fail another against
364            // the same dead endpoint.
365            b = b
366                .on_embed_error(embed_error_policy)
367                .embed_retry(embed_retry);
368            Ok(b.open(path)?.0)
369        });
370        Ok(Workspace::new(
371            WorkspaceLayout::new(root),
372            open,
373            workspace.limits,
374        ))
375    }
376}
377
378/// Parses the `[workspace]` section. An out-of-range pool limit is a usage
379/// error rather than a silent clamp: a person who wrote a number meant it, and
380/// finding out later that it was ignored is worse than being told now.
381fn parse_workspace(t: &toml::Table) -> Result<WorkspaceSettings, SettingsError> {
382    let mut out = WorkspaceSettings {
383        dir: None,
384        limits: WorkspaceLimits::default(),
385    };
386    if let Some(value) = t.get(WORKSPACE_SETTING_KEYS[0]) {
387        let dir = value
388            .as_str()
389            .ok_or_else(|| SettingsError::config("[workspace].dir must be a string"))?;
390        if dir.is_empty() {
391            return Err(SettingsError::config("[workspace].dir must not be empty"));
392        }
393        out.dir = Some(PathBuf::from(dir));
394    }
395    if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[1]) {
396        if n == 0 || n > MAX_OPEN_CEILING as u64 {
397            return Err(SettingsError::config(format!(
398                "[workspace].max_open must be between 1 and {MAX_OPEN_CEILING} \
399                 (one open database costs several file descriptors)"
400            )));
401        }
402        // In range by the check above, so the narrowing cannot truncate — the
403        // comparison happens in `u64` precisely so it holds where `usize` is 32
404        // bits too.
405        out.limits.max_open = n as usize;
406    }
407    if let Some(n) = table_u64(t, WORKSPACE_SETTING_KEYS[2]) {
408        out.limits.idle_timeout_ms = n;
409    }
410    Ok(out)
411}
412
413/// Reads and parses `config.toml`, or `Ok(None)` if none applies. An explicit
414/// `flag` path **must** exist (a read error is a usage error); otherwise
415/// `$PLUGMEM_CONFIG`, then the platform path from
416/// [`crate::default_config_path`], are read only if present. Wrappers call this once, then pass the table to
417/// [`Settings::from_table`] and also read their own keys (batch size, workers)
418/// from it.
419pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
420    let text = match read_config_text(flag)? {
421        Some(t) => t,
422        None => return Ok(None),
423    };
424    let table: toml::Table = text
425        .parse()
426        .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
427    Ok(Some(table))
428}
429
430/// A non-negative integer key from a table as `u64`, or `None`.
431/// Reads `[maintenance].fsync` as a named policy.
432///
433/// A string rather than a boolean, because the two values are not opposites of
434/// one thing: `"each_op"` says *when* a record is durable, `"on_snapshot"` says
435/// which window may be lost. A misspelling is refused rather than silently
436/// treated as the default — quietly running with weaker durability than the
437/// file asks for is the one outcome worth erroring over.
438fn parse_fsync(t: &toml::Table) -> Result<Option<FsyncPolicy>, SettingsError> {
439    let Some(value) = t.get(MAINTENANCE_SETTING_KEYS[3]) else {
440        return Ok(None);
441    };
442    let name = value.as_str().ok_or_else(|| {
443        SettingsError::config("[maintenance].fsync must be \"each_op\" or \"on_snapshot\"")
444    })?;
445    match name {
446        "each_op" => Ok(Some(FsyncPolicy::EachOp)),
447        "on_snapshot" => Ok(Some(FsyncPolicy::OnSnapshot)),
448        other => Err(SettingsError::config(format!(
449            "[maintenance].fsync must be \"each_op\" or \"on_snapshot\", got \"{other}\""
450        ))),
451    }
452}
453
454/// `"fail"` / `"degrade"`, from a file or from the environment.
455fn parse_on_error(value: &str) -> Result<EmbedErrorPolicy, SettingsError> {
456    match value {
457        "fail" => Ok(EmbedErrorPolicy::Fail),
458        "degrade" => Ok(EmbedErrorPolicy::Degrade),
459        other => Err(SettingsError::config(format!(
460            "[embedder].on_error must be \"fail\" or \"degrade\", got \"{other}\""
461        ))),
462    }
463}
464
465/// A non-negative integer from a TOML value, refused rather than ignored.
466///
467/// [`table_u64`] drops what it cannot read, which is right for a knob whose
468/// absence means "engine default". These four decide whether a memory keeps
469/// working when its provider dies, and a silently dropped `timeout_ms = "5s"`
470/// would leave somebody sure they had bounded a wait they had not.
471fn table_number(value: &toml::Value, key: &str) -> Result<u64, SettingsError> {
472    value
473        .as_integer()
474        .filter(|n| *n >= 0)
475        .map(|n| n as u64)
476        .ok_or_else(|| {
477            SettingsError::config(format!(
478                "[embedder].{key} must be a non-negative integer number of milliseconds"
479            ))
480        })
481}
482
483/// The same, from an environment variable.
484fn env_number(value: &str, var: &str) -> Result<u64, SettingsError> {
485    value.trim().parse::<u64>().map_err(|_| {
486        SettingsError::config(format!(
487            "{var} must be a non-negative integer number of milliseconds, got \"{value}\""
488        ))
489    })
490}
491
492/// `0` means "no timeout" — the one spelling of "wait indefinitely" a TOML
493/// integer has, and the behaviour every release before this one had.
494fn parse_timeout_ms(ms: &u64) -> Option<Duration> {
495    (*ms > 0).then(|| Duration::from_millis(*ms))
496}
497
498pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
499    t.get(key)
500        .and_then(toml::Value::as_integer)
501        .filter(|n| *n >= 0)
502        .map(|n| n as u64)
503}
504
505/// Reads the config file text with flag/env/platform-default precedence.
506fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
507    if let Some(p) = flag {
508        return std::fs::read_to_string(p)
509            .map(Some)
510            .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
511    }
512    let candidate = std::env::var_os(ENV_CONFIG)
513        .map(PathBuf::from)
514        .or_else(crate::default_config_path);
515    match candidate {
516        Some(p) if p.exists() => std::fs::read_to_string(&p)
517            .map(Some)
518            .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
519        _ => Ok(None),
520    }
521}
522
523/// A non-negative integer from `[section].key`, or `None` when absent.
524fn setting_uint(t: &toml::Table, section: &str, key: &str) -> Result<Option<i64>, SettingsError> {
525    let Some(v) = t.get(key) else {
526        return Ok(None);
527    };
528    v.as_integer().filter(|n| *n >= 0).map(Some).ok_or_else(|| {
529        SettingsError::config(format!("[{section}].{key} must be a non-negative integer"))
530    })
531}
532
533/// A number from `[section].key` as `f32`, or `None` when absent.
534///
535/// An integer is accepted for a float key: `w_vec = 1` is what anyone writes,
536/// and refusing it over the missing decimal point would be pedantry.
537fn setting_f32(t: &toml::Table, section: &str, key: &str) -> Result<Option<f32>, SettingsError> {
538    let Some(v) = t.get(key) else {
539        return Ok(None);
540    };
541    v.as_float()
542        .or_else(|| v.as_integer().map(|n| n as f64))
543        .map(|n| Some(n as f32))
544        .ok_or_else(|| SettingsError::config(format!("[{section}].{key} must be a number")))
545}
546
547/// Applies the `[engine]` table onto a [`Config`]: the size-bearing fields,
548/// the ones a database is *built* with. See [`ENGINE_SETTING_KEYS`].
549fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
550    let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
551        (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
552        (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
553        (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
554        (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
555    ];
556    for (key, slot) in fields {
557        if let Some(n) = setting_uint(t, "engine", key)? {
558            *slot = n as usize;
559        }
560    }
561    Ok(())
562}
563
564/// Applies the `[recall]` table onto a [`Config`]. See
565/// [`RECALL_SETTING_KEYS`] for why these are their own section.
566fn apply_recall(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
567    let floats: [(&str, &mut f32); 10] = [
568        (RECALL_SETTING_KEYS[0], &mut cfg.bm25_k1),
569        (RECALL_SETTING_KEYS[1], &mut cfg.bm25_b),
570        (RECALL_SETTING_KEYS[3], &mut cfg.w_bm25),
571        (RECALL_SETTING_KEYS[4], &mut cfg.w_vec),
572        (RECALL_SETTING_KEYS[5], &mut cfg.w_graph),
573        (RECALL_SETTING_KEYS[6], &mut cfg.w_time),
574        (RECALL_SETTING_KEYS[7], &mut cfg.w_recency),
575        (RECALL_SETTING_KEYS[10], &mut cfg.graph_decay),
576        (RECALL_SETTING_KEYS[12], &mut cfg.similar_cos),
577        (RECALL_SETTING_KEYS[13], &mut cfg.similar_jaccard),
578    ];
579    for (key, slot) in floats {
580        if let Some(v) = setting_f32(t, "recall", key)? {
581            *slot = v;
582        }
583    }
584    let uints: [(&str, &mut u32); 3] = [
585        (RECALL_SETTING_KEYS[2], &mut cfg.rrf_k),
586        (RECALL_SETTING_KEYS[8], &mut cfg.half_life_days),
587        (RECALL_SETTING_KEYS[9], &mut cfg.graph_depth),
588    ];
589    for (key, slot) in uints {
590        if let Some(n) = setting_uint(t, "recall", key)? {
591            *slot = n as u32;
592        }
593    }
594    if let Some(n) = setting_uint(t, "recall", RECALL_SETTING_KEYS[11])? {
595        cfg.hnsw_ef_search = n as usize;
596    }
597    Ok(())
598}
599
600/// Applies the `[index]` table onto a [`Config`].
601fn apply_index(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
602    let fields: [(&str, &mut usize); INDEX_SETTING_KEYS.len()] = [
603        (INDEX_SETTING_KEYS[0], &mut cfg.hnsw_ef_construction),
604        (INDEX_SETTING_KEYS[1], &mut cfg.flat_to_hnsw),
605    ];
606    for (key, slot) in fields {
607        if let Some(n) = setting_uint(t, "index", key)? {
608            *slot = n as usize;
609        }
610    }
611    Ok(())
612}
613
614/// The `[embedder]` section, before it is turned into an [`Embedder`].
615#[derive(Default)]
616struct EmbedderCfg {
617    enabled: Option<bool>,
618    url: Option<String>,
619    model: Option<String>,
620    space_id: Option<String>,
621    api_key_env: Option<String>,
622    on_error: Option<EmbedErrorPolicy>,
623    /// `None` = the provider's own default; `Some(None)` = wait forever.
624    timeout: Option<Option<Duration>>,
625    /// Milliseconds as written: `None` = backoff, `Some(0)` = manual,
626    /// `Some(n)` = a fixed interval. Turned into an [`EmbedRetry`] by
627    /// [`EmbedderCfg::retry`], which is the only place that mapping lives.
628    retry_after_ms: Option<u64>,
629    retry_max_ms: Option<u64>,
630}
631
632impl EmbedderCfg {
633    fn merge(&mut self, t: &toml::Table) -> Result<(), SettingsError> {
634        let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
635        if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[0]) {
636            self.enabled =
637                Some(value.as_bool().ok_or_else(|| {
638                    SettingsError::config("[embedder].enabled must be a boolean")
639                })?);
640        }
641        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
642            self.url = Some(v);
643        }
644        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
645            self.model = Some(v);
646        }
647        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
648            self.space_id = Some(v);
649        }
650        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[4]) {
651            self.api_key_env = Some(v);
652        }
653        if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[5]) {
654            let text = value
655                .as_str()
656                .ok_or_else(|| SettingsError::config("[embedder].on_error must be a string"))?;
657            self.on_error = Some(parse_on_error(text)?);
658        }
659        if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[6]) {
660            self.timeout = Some(parse_timeout_ms(&table_number(
661                value,
662                EMBEDDER_SETTING_KEYS[6],
663            )?));
664        }
665        if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[7]) {
666            self.retry_after_ms = Some(table_number(value, EMBEDDER_SETTING_KEYS[7])?);
667        }
668        if let Some(value) = t.get(EMBEDDER_SETTING_KEYS[8]) {
669            self.retry_max_ms = Some(table_number(value, EMBEDDER_SETTING_KEYS[8])?);
670        }
671        Ok(())
672    }
673
674    /// How a suspended embedder comes back, from the two milliseconds keys.
675    ///
676    /// One function so the file, the environment and the defaults cannot
677    /// disagree about what "0" means, and so the mapping is documented in
678    /// exactly one place:
679    ///
680    /// - nothing written -> the default backoff (1s doubling to `retry_max_ms`);
681    /// - `retry_after_ms = 0` -> never; the host resumes it explicitly;
682    /// - `retry_after_ms = n` -> that interval, every time.
683    fn retry(&self) -> EmbedRetry {
684        match self.retry_after_ms {
685            None => EmbedRetry::Backoff {
686                first: crate::DEFAULT_EMBED_RETRY_FIRST,
687                max: self
688                    .retry_max_ms
689                    .map_or(crate::DEFAULT_EMBED_RETRY_MAX, Duration::from_millis),
690            },
691            Some(0) => EmbedRetry::Manual,
692            Some(ms) => EmbedRetry::Fixed(Duration::from_millis(ms)),
693        }
694    }
695
696    /// Builds the one supported embedder. An explicitly disabled embedder, or
697    /// an absent/incomplete section with no activation request, produces no
698    /// embedder. An active embedder needs a `url`, a `model` and
699    /// `[engine].dim > 0`; an optional `api_key_env` names an environment
700    /// variable holding the bearer token.
701    fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
702        let enabled = self
703            .enabled
704            .unwrap_or(self.url.is_some() || self.model.is_some());
705        if !enabled {
706            return Ok(None);
707        }
708        let url = self
709            .url
710            .clone()
711            .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a URL"))?;
712        let model = self
713            .model
714            .clone()
715            .ok_or_else(|| SettingsError::config("[embedder] enabled embedder needs a model"))?;
716        if dim == 0 {
717            return Err(SettingsError::config(
718                "[embedder] requires [engine].dim > 0 (the embedding size)",
719            ));
720        }
721        let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
722        if let Some(timeout) = self.timeout {
723            e = e.with_timeout(timeout);
724        }
725        if let Some(space_id) = &self.space_id {
726            e = e.with_space_id(space_id);
727        }
728        if let Some(env) = &self.api_key_env
729            && let Some(key) = std::env::var_os(env)
730        {
731            e = e.with_api_key(key.to_string_lossy().into_owned());
732        }
733        Ok(Some(Box::new(e)))
734    }
735}
736
737fn parse_embedder_enabled(value: &str) -> Result<bool, SettingsError> {
738    match value {
739        "true" => Ok(true),
740        "false" => Ok(false),
741        other => Err(SettingsError::config(format!(
742            "{ENV_EMBEDDER_ENABLED} must be true or false, got \"{other}\""
743        ))),
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    /// A config table from lines, so the fixtures indent with the code instead
752    /// of being pinned to the file's left margin.
753    fn toml_of(lines: &[&str]) -> toml::Table {
754        lines.join("\n").parse().expect("valid TOML fixture")
755    }
756
757    /// A unique temp directory; removed on drop.
758    struct TempDir(PathBuf);
759    impl TempDir {
760        fn new(tag: &str) -> Self {
761            let dir = std::env::temp_dir().join(format!(
762                "plugmem-settings-{tag}-{}-{}",
763                std::process::id(),
764                std::time::SystemTime::now()
765                    .duration_since(std::time::UNIX_EPOCH)
766                    .unwrap()
767                    .as_nanos()
768            ));
769            std::fs::create_dir_all(&dir).unwrap();
770            TempDir(dir)
771        }
772    }
773    impl Drop for TempDir {
774        fn drop(&mut self) {
775            let _ = std::fs::remove_dir_all(&self.0);
776        }
777    }
778
779    #[test]
780    fn engine_and_maintenance_parse() {
781        let table = toml_of(&[
782            "[engine]",
783            "dim = 384",
784            "max_text = 2048",
785            "[maintenance]",
786            "snapshot_every_ops = 50",
787            "snapshot_journal_bytes = 8192",
788            "maintain_every_forgets = 3",
789        ]);
790        let s = Settings::from_table(Some(&table)).unwrap();
791        assert_eq!(s.config.dim, 384);
792        assert_eq!(s.config.max_text, 2048);
793        assert_eq!(s.snapshot_every_ops, Some(50));
794        assert_eq!(s.snapshot_journal_bytes, Some(8192));
795        assert_eq!(s.maintain_every_forgets, Some(3));
796
797        let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
798        assert!(matches!(
799            Settings::from_table(Some(&bad)),
800            Err(SettingsError::Config(_))
801        ));
802    }
803
804    #[test]
805    fn defaults_when_no_table() {
806        let s = Settings::from_table(None).unwrap();
807        assert!(s.database_path.is_none());
808        assert_eq!(s.config.dim, Config::default().dim);
809        assert!(s.embedder.is_none());
810        assert_eq!(s.snapshot_every_ops, None);
811    }
812
813    #[test]
814    fn embedder_merge_reads_every_field() {
815        let table = toml_of(&[
816            "[embedder]",
817            "enabled = true",
818            r#"url = "http://localhost:11434/v1/embeddings""#,
819            r#"model = "nomic-embed-text""#,
820            r#"space_id = "nomic-embed-text@v1""#,
821            r#"api_key_env = "SOME_ENV""#,
822            "[engine]",
823            "dim = 8",
824        ]);
825        // The shared OpenAI-compatible client builds with a url, model and
826        // dim > 0; the server may be OpenAI, Ollama or another compatible one.
827        let s = Settings::from_table(Some(&table)).unwrap();
828        let embedder = s.embedder.unwrap();
829        assert_eq!(embedder.space_id(), "nomic-embed-text@v1");
830        assert_eq!(embedder.dim(), 8);
831    }
832
833    #[test]
834    fn embedder_failure_policy_reads_every_key() {
835        let table = toml_of(&[
836            "[embedder]",
837            "enabled = true",
838            r#"url = "http://localhost:11434/v1/embeddings""#,
839            r#"model = "nomic-embed-text""#,
840            r#"on_error = "degrade""#,
841            "timeout_ms = 2500",
842            "retry_after_ms = 750",
843            "[engine]",
844            "dim = 8",
845        ]);
846        let s = Settings::from_table(Some(&table)).unwrap();
847        assert_eq!(s.embed_error_policy, EmbedErrorPolicy::Degrade);
848        assert_eq!(s.embed_retry, EmbedRetry::Fixed(Duration::from_millis(750)));
849    }
850
851    #[test]
852    fn an_unconfigured_embedder_section_keeps_the_old_behaviour() {
853        // The default matters more than the feature: somebody who upgrades and
854        // changes nothing must still get the error they get today.
855        let table = toml_of(&["[engine]", "dim = 8"]);
856        let s = Settings::from_table(Some(&table)).unwrap();
857        assert_eq!(s.embed_error_policy, EmbedErrorPolicy::Fail);
858        assert_eq!(
859            s.embed_retry,
860            EmbedRetry::Backoff {
861                first: crate::DEFAULT_EMBED_RETRY_FIRST,
862                max: crate::DEFAULT_EMBED_RETRY_MAX,
863            }
864        );
865    }
866
867    #[test]
868    fn retry_keys_map_onto_the_three_shapes() {
869        // One table, because the mapping is the thing being tested and it is
870        // easy to get one arm of it wrong in isolation.
871        let cfg = |lines: &[&str]| {
872            let mut lines = lines.to_vec();
873            lines.insert(0, "[embedder]");
874            Settings::from_table(Some(&toml_of(&lines)))
875                .unwrap()
876                .embed_retry
877        };
878        assert_eq!(cfg(&["retry_after_ms = 0"]), EmbedRetry::Manual);
879        assert_eq!(
880            cfg(&["retry_after_ms = 250"]),
881            EmbedRetry::Fixed(Duration::from_millis(250))
882        );
883        assert_eq!(
884            cfg(&["retry_max_ms = 5000"]),
885            EmbedRetry::Backoff {
886                first: crate::DEFAULT_EMBED_RETRY_FIRST,
887                max: Duration::from_millis(5000),
888            }
889        );
890        // A cap without a doubling to cap is not an error, it is simply unused
891        // — saying so in a test keeps somebody from "fixing" it later.
892        assert_eq!(
893            cfg(&["retry_after_ms = 100", "retry_max_ms = 5000"]),
894            EmbedRetry::Fixed(Duration::from_millis(100))
895        );
896    }
897
898    #[test]
899    fn a_malformed_failure_key_is_refused_rather_than_ignored() {
900        // These four decide whether a memory keeps working when its provider
901        // dies. A dropped value would leave somebody sure they configured
902        // something they did not.
903        for lines in [
904            vec!["[embedder]", r#"on_error = "sometimes""#],
905            vec!["[embedder]", "on_error = true"],
906            vec!["[embedder]", r#"timeout_ms = "5s""#],
907            vec!["[embedder]", "timeout_ms = -1"],
908            vec!["[embedder]", r#"retry_after_ms = "soon""#],
909            vec!["[embedder]", "retry_max_ms = -5"],
910        ] {
911            let table = toml_of(&lines);
912            assert!(
913                matches!(
914                    Settings::from_table(Some(&table)),
915                    Err(SettingsError::Config(_))
916                ),
917                "accepted {lines:?}"
918            );
919        }
920    }
921
922    #[test]
923    fn a_zero_timeout_means_wait_indefinitely() {
924        // TOML has no "unset" to write in place of a number, so zero carries
925        // it — the same spelling every release before this one had by default.
926        assert_eq!(parse_timeout_ms(&0), None);
927        assert_eq!(parse_timeout_ms(&1500), Some(Duration::from_millis(1500)));
928    }
929
930    #[test]
931    fn the_environment_parsers_answer_the_same_way_the_file_does() {
932        // The env overrides cannot be exercised through `std::env::set_var`
933        // (it is unsafe and racy across test threads), so the parsers they
934        // share with the file are tested directly instead.
935        assert_eq!(
936            parse_on_error("degrade").unwrap(),
937            EmbedErrorPolicy::Degrade
938        );
939        assert_eq!(parse_on_error("fail").unwrap(), EmbedErrorPolicy::Fail);
940        assert!(parse_on_error("Degrade").is_err());
941        assert_eq!(
942            env_number("2500", "PLUGMEM_EMBEDDER_TIMEOUT_MS").unwrap(),
943            2500
944        );
945        assert!(env_number("-1", "PLUGMEM_EMBEDDER_TIMEOUT_MS").is_err());
946        assert!(env_number("2.5", "PLUGMEM_EMBEDDER_TIMEOUT_MS").is_err());
947    }
948
949    #[test]
950    fn database_path_reads_and_validates_from_config() {
951        let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
952            .parse()
953            .unwrap();
954        let settings = Settings::from_table(Some(&table)).unwrap();
955        assert_eq!(
956            settings.database_path.as_deref(),
957            Some(std::path::Path::new("/tmp/memory.plugmem"))
958        );
959
960        let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
961        assert!(matches!(
962            Settings::from_table(Some(&bad)),
963            Err(SettingsError::Config(message)) if message == "[database].path must be a string"
964        ));
965    }
966
967    #[test]
968    fn settings_open_applies_maintenance_and_embedder() {
969        // Every maintenance knob set, plus an embedder, so `Settings::open`
970        // exercises each builder branch. The embedder is never invoked by a
971        // bare open, so an unreachable url is fine here.
972        let tmp = TempDir::new("open");
973        let mut config = Config::default();
974        config.dim = 8;
975        let embedder = EmbedderCfg {
976            enabled: Some(true),
977            url: Some("http://127.0.0.1:0/v1/embeddings".into()),
978            model: Some("m".into()),
979            ..Default::default()
980        }
981        .build(8)
982        .unwrap();
983        assert!(embedder.is_some());
984        let settings = Settings {
985            database_path: None,
986            config,
987            embedder,
988            embed_error_policy: EmbedErrorPolicy::default(),
989            embed_retry: EmbedRetry::default(),
990            snapshot_every_ops: Some(4),
991            snapshot_journal_bytes: Some(4096),
992            maintain_every_forgets: Some(2),
993            fsync: Some(FsyncPolicy::OnSnapshot),
994            workspace: WorkspaceSettings {
995                dir: None,
996                limits: WorkspaceLimits::default(),
997            },
998            warnings: Vec::new(),
999        };
1000        let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
1001        assert_eq!(db.stats().facts, 0);
1002    }
1003
1004    #[test]
1005    fn the_workspace_section_is_absent_by_default_and_parsed_when_present() {
1006        // The default is one database: no section, no workspace, nothing to
1007        // configure. This is the case that must never drift.
1008        let bare = Settings::from_table(None).unwrap();
1009        assert_eq!(bare.workspace.dir, None);
1010        assert_eq!(bare.workspace.limits, WorkspaceLimits::default());
1011
1012        let table: toml::Table =
1013            "[workspace]\ndir = \"/srv/bot\"\nmax_open = 4\nidle_timeout_ms = 5000\n"
1014                .parse()
1015                .unwrap();
1016        let s = Settings::from_table(Some(&table)).unwrap();
1017        assert_eq!(s.workspace.dir, Some(PathBuf::from("/srv/bot")));
1018        assert_eq!(s.workspace.limits.max_open, 4);
1019        assert_eq!(s.workspace.limits.idle_timeout_ms, 5_000);
1020
1021        // A section that only sets the directory keeps the defaults.
1022        let only_dir: toml::Table = "[workspace]\ndir = \"/srv/bot\"\n".parse().unwrap();
1023        let s = Settings::from_table(Some(&only_dir)).unwrap();
1024        assert_eq!(s.workspace.limits, WorkspaceLimits::default());
1025    }
1026
1027    #[test]
1028    fn a_workspace_pool_limit_out_of_range_is_a_usage_error() {
1029        // Not clamped: a number somebody wrote is a number they meant, and
1030        // discovering later that it was ignored is worse than being told now.
1031        for bad in [
1032            "[workspace]\nmax_open = 0\n".to_string(),
1033            format!("[workspace]\nmax_open = {}\n", MAX_OPEN_CEILING + 1),
1034            // Well past what a 32-bit `usize` could hold, so the range check
1035            // has to happen before the narrowing.
1036            "[workspace]\nmax_open = 9999999999\n".to_string(),
1037        ] {
1038            let table: toml::Table = bad.parse().unwrap();
1039            assert!(
1040                matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("max_open")),
1041                "{bad}"
1042            );
1043        }
1044
1045        for bad in ["[workspace]\ndir = 42\n", "[workspace]\ndir = \"\"\n"] {
1046            let table: toml::Table = bad.parse().unwrap();
1047            assert!(
1048                matches!(Settings::from_table(Some(&table)), Err(SettingsError::Config(m)) if m.contains("dir")),
1049                "{bad}"
1050            );
1051        }
1052
1053        // The largest accepted value is accepted.
1054        let table: toml::Table = format!("[workspace]\nmax_open = {MAX_OPEN_CEILING}\n")
1055            .parse()
1056            .unwrap();
1057        let s = Settings::from_table(Some(&table)).unwrap();
1058        assert_eq!(s.workspace.limits.max_open, MAX_OPEN_CEILING);
1059    }
1060
1061    #[test]
1062    fn open_workspace_builds_databases_from_the_same_settings() {
1063        let tmp = TempDir::new("open-workspace");
1064        let table: toml::Table = "[engine]\ndim = 8\n[maintenance]\nsnapshot_every_ops = 4\n\
1065             snapshot_journal_bytes = 4096\nmaintain_every_forgets = 2\n"
1066            .parse()
1067            .unwrap();
1068        let settings = Settings::from_table(Some(&table)).unwrap();
1069        let ws = settings.open_workspace(&tmp.0).unwrap();
1070
1071        let name = crate::DbName::parse("chat-42").unwrap();
1072        let db = ws.get(&name, 1_000, crate::IfMissing::Create).unwrap();
1073        db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
1074            .unwrap();
1075        assert_eq!(db.stats().facts, 1);
1076        assert!(ws.layout().exists(&name));
1077    }
1078
1079    #[test]
1080    fn fsync_policy_is_named_and_a_misspelling_is_refused() {
1081        let parse = |body: &str| {
1082            let table: toml::Table = body.parse().unwrap();
1083            let t = table.get("maintenance").unwrap().as_table().unwrap();
1084            parse_fsync(t)
1085        };
1086
1087        assert_eq!(
1088            parse("[maintenance]\n").unwrap(),
1089            None,
1090            "absent stays default"
1091        );
1092        assert_eq!(
1093            parse("[maintenance]\nfsync = \"each_op\"\n").unwrap(),
1094            Some(FsyncPolicy::EachOp)
1095        );
1096        assert_eq!(
1097            parse("[maintenance]\nfsync = \"on_snapshot\"\n").unwrap(),
1098            Some(FsyncPolicy::OnSnapshot)
1099        );
1100
1101        // The one thing worth erroring over: a typo must not quietly leave the
1102        // database running with different durability than the file asks for.
1103        for bad in [
1104            "[maintenance]\nfsync = \"on-snapshot\"\n",
1105            "[maintenance]\nfsync = \"none\"\n",
1106            "[maintenance]\nfsync = true\n",
1107            "[maintenance]\nfsync = 1\n",
1108        ] {
1109            let Err(err) = parse(bad) else {
1110                panic!("{bad:?} must be refused");
1111            };
1112            assert!(
1113                err.to_string().contains("each_op"),
1114                "the message names the legal values: {err}"
1115            );
1116        }
1117    }
1118
1119    #[test]
1120    fn fsync_reaches_settings_from_the_config_file() {
1121        // The gap this closes: `FsyncPolicy` was public in the host and
1122        // reachable from nowhere else — not a CLI flag, not an MCP argument,
1123        // not a napi option, not the config file. Only hand-written Rust.
1124        let table: toml::Table = "[maintenance]\nfsync = \"on_snapshot\"\n".parse().unwrap();
1125        let settings = Settings::from_table(Some(&table)).unwrap();
1126        assert_eq!(settings.fsync, Some(FsyncPolicy::OnSnapshot));
1127
1128        let plain = Settings::from_table(None).unwrap();
1129        assert_eq!(plain.fsync, None, "no config means the engine default");
1130    }
1131
1132    #[test]
1133    fn embedder_build_rules() {
1134        assert!(EmbedderCfg::default().build(0).unwrap().is_none());
1135        let no_url = EmbedderCfg {
1136            enabled: Some(true),
1137            ..Default::default()
1138        };
1139        assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
1140        let no_model = EmbedderCfg {
1141            enabled: Some(true),
1142            url: Some("http://x/v1/embeddings".into()),
1143            ..Default::default()
1144        };
1145        assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
1146        let zero_dim = EmbedderCfg {
1147            enabled: Some(true),
1148            url: Some("http://x/v1/embeddings".into()),
1149            model: Some("m".into()),
1150            ..Default::default()
1151        };
1152        assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
1153        let ok = EmbedderCfg {
1154            enabled: None,
1155            url: Some("http://x/v1/embeddings".into()),
1156            model: Some("m".into()),
1157            api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
1158            ..Default::default()
1159        };
1160        assert!(ok.build(384).unwrap().is_some());
1161        let disabled = EmbedderCfg {
1162            enabled: Some(false),
1163            url: Some("http://x/v1/embeddings".into()),
1164            model: Some("m".into()),
1165            ..Default::default()
1166        };
1167        assert!(disabled.build(0).unwrap().is_none());
1168        assert!(parse_embedder_enabled("true").unwrap());
1169        assert!(!parse_embedder_enabled("false").unwrap());
1170        assert!(parse_embedder_enabled("ollama").is_err());
1171    }
1172
1173    #[test]
1174    fn load_reads_the_config_file() {
1175        let tmp = TempDir::new("load");
1176        let cfgfile = tmp.0.join("config.toml");
1177        std::fs::write(
1178            &cfgfile,
1179            "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nenabled = false\n[maintenance]\nsnapshot_every_ops = 64\n",
1180        )
1181        .unwrap();
1182        let s = Settings::load(Some(&cfgfile)).unwrap();
1183        assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
1184        assert_eq!(s.config.dim, 512);
1185        assert!(s.embedder.is_none());
1186        assert_eq!(s.snapshot_every_ops, Some(64));
1187
1188        // An explicit path that does not exist is a usage error.
1189        assert!(matches!(
1190            Settings::load(Some(&tmp.0.join("nope.toml"))),
1191            Err(SettingsError::Config(_))
1192        ));
1193    }
1194
1195    #[test]
1196    fn read_config_none_and_batch_extra() {
1197        // No file → Ok(None); a wrapper reads its own extra key from the table.
1198        let tmp = TempDir::new("extra");
1199        let missing = tmp.0.join("absent.toml");
1200        // An absent *default* (no flag) yields None only if neither env nor the
1201        // XDG default exists; exercise the explicit-missing-flag error instead.
1202        assert!(read_config(Some(&missing)).is_err());
1203
1204        let cfgfile = tmp.0.join("config.toml");
1205        std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
1206        let table = read_config(Some(&cfgfile)).unwrap().unwrap();
1207        let batch = table
1208            .get("maintenance")
1209            .and_then(toml::Value::as_table)
1210            .and_then(|m| table_u64(m, "batch_size"));
1211        assert_eq!(batch, Some(256));
1212    }
1213
1214    #[test]
1215    fn every_tuning_key_actually_reaches_the_config() {
1216        // The test the missing one would have caught. Documenting a key and
1217        // parsing it are two different acts, and for the whole of 0.5.0 the
1218        // catalogue could have promised a knob that went nowhere: nothing
1219        // compared a *value* written in the file against the `Config` that came
1220        // out. Each key here is set to something no default equals, then read
1221        // back off the resolved config.
1222        let cfg = Config::default();
1223        let table = toml_of(&[
1224            "[recall]",
1225            "bm25_k1 = 2.5",
1226            "bm25_b = 0.25",
1227            "rrf_k = 17",
1228            "w_bm25 = 3.0",
1229            "w_vec = 4.0",
1230            "w_graph = 5.0",
1231            "w_time = 6.0",
1232            "w_recency = 0.75",
1233            "half_life_days = 7",
1234            "graph_depth = 4",
1235            "graph_decay = 0.125",
1236            "hnsw_ef_search = 111",
1237            "similar_cos = 0.31",
1238            "similar_jaccard = 0.32",
1239            "[index]",
1240            "hnsw_ef_construction = 222",
1241            "flat_to_hnsw = 333",
1242        ]);
1243        let s = Settings::from_table(Some(&table)).unwrap();
1244
1245        assert_eq!(s.config.bm25_k1, 2.5);
1246        assert_eq!(s.config.bm25_b, 0.25);
1247        assert_eq!(s.config.rrf_k, 17);
1248        assert_eq!(s.config.w_bm25, 3.0);
1249        assert_eq!(s.config.w_vec, 4.0);
1250        assert_eq!(s.config.w_graph, 5.0);
1251        assert_eq!(s.config.w_time, 6.0);
1252        assert_eq!(s.config.w_recency, 0.75);
1253        assert_eq!(s.config.half_life_days, 7);
1254        assert_eq!(s.config.graph_depth, 4);
1255        assert_eq!(s.config.graph_decay, 0.125);
1256        assert_eq!(s.config.hnsw_ef_search, 111);
1257        assert_eq!(s.config.similar_cos, 0.31);
1258        assert_eq!(s.config.similar_jaccard, 0.32);
1259        assert_eq!(s.config.hnsw_ef_construction, 222);
1260        assert_eq!(s.config.flat_to_hnsw, 333);
1261
1262        // Every value above differs from its default, so the assertions cannot
1263        // pass on a parser that read nothing at all.
1264        assert_ne!(s.config.bm25_k1, cfg.bm25_k1);
1265        assert_ne!(s.config.flat_to_hnsw, cfg.flat_to_hnsw);
1266        assert!(s.warnings.is_empty(), "{:?}", s.warnings);
1267    }
1268
1269    #[test]
1270    fn an_integer_is_accepted_where_a_float_is_meant() {
1271        // `w_vec = 1` is what a person writes. Refusing it over the missing
1272        // decimal point would be pedantry, and the failure would be a warning
1273        // about a key that is spelled perfectly.
1274        let table = toml_of(&["[recall]", "w_vec = 2", "graph_decay = 1"]);
1275        let s = Settings::from_table(Some(&table)).unwrap();
1276        assert_eq!(s.config.w_vec, 2.0);
1277        assert_eq!(s.config.graph_decay, 1.0);
1278    }
1279
1280    #[test]
1281    fn a_tuning_value_out_of_range_is_refused_by_name() {
1282        // The range belongs to the engine, and it names the field it rejected;
1283        // this only has to carry that through instead of inventing a second,
1284        // drifting copy of what "valid" means.
1285        for line in ["graph_decay = 2.0", "similar_cos = -1.0", "w_vec = -0.5"] {
1286            let table = toml_of(&["[recall]", line]);
1287            let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
1288                panic!("{line} must be refused");
1289            };
1290            let field = line.split(' ').next().unwrap();
1291            assert!(
1292                message.contains(field),
1293                "the message must name the offending field: {message}"
1294            );
1295        }
1296
1297        // A wrong *type* is caught before the engine sees it, and names the
1298        // section too, since the same key name can live in more than one.
1299        let table = toml_of(&["[recall]", r#"w_vec = "lots""#]);
1300        let Err(SettingsError::Config(message)) = Settings::from_table(Some(&table)) else {
1301            panic!("a string weight must be refused");
1302        };
1303        assert!(message.contains("[recall].w_vec"), "{message}");
1304    }
1305
1306    #[test]
1307    fn every_host_setting_is_documented() {
1308        let docs = crate::settings_help::settings_help().docs();
1309        for (section, keys) in [
1310            ("database", DATABASE_SETTING_KEYS),
1311            ("workspace", WORKSPACE_SETTING_KEYS),
1312            ("engine", ENGINE_SETTING_KEYS),
1313            ("recall", RECALL_SETTING_KEYS),
1314            ("index", INDEX_SETTING_KEYS),
1315            ("embedder", EMBEDDER_SETTING_KEYS),
1316            ("maintenance", MAINTENANCE_SETTING_KEYS),
1317        ] {
1318            let documented: Vec<_> = docs
1319                .iter()
1320                .filter(|doc| {
1321                    doc.section == section
1322                        && doc.scope == crate::settings_help::SettingScope::Shared
1323                })
1324                .map(|doc| doc.key)
1325                .collect();
1326            assert_eq!(
1327                documented.as_slice(),
1328                keys,
1329                "undocumented {section} setting"
1330            );
1331        }
1332    }
1333}