Skip to main content

plugmem_host/
settings.rs

1//! Shared `config.toml` loader (feature `config`): resolve the engine
2//! [`Config`], an optional [`Embedder`], and the maintenance policy from a
3//! TOML file plus the environment, with precedence **flag/env > config file >
4//! default**.
5//!
6//! This is the loader the CLI and the MCP server share so they agree on config
7//! semantics. It is deliberately small: it reads four shared sections —
8//! `[database]` (the optional database path), `[engine]` (size-bearing
9//! [`Config`] fields), `[embedder]` (an OpenAI-compatible provider), and
10//! `[maintenance]` (snapshot/maintain thresholds). Keys a
11//! specific wrapper owns — the CLI's `[maintenance].batch_size`, the server's
12//! `[server].workers` — are **not** parsed here; a wrapper reads them from the
13//! same table via [`read_config`].
14//!
15//! Library users who build a [`Config`] in code do not need this module (and,
16//! with the feature off, do not pull the `toml` parser).
17
18use std::path::{Path, PathBuf};
19
20use crate::{Config, Database, DatabaseBuilder, Embedder, HostError, OpenAiCompatEmbedder};
21
22/// Environment variable naming the config file (below an explicit path).
23const ENV_CONFIG: &str = "PLUGMEM_CONFIG";
24/// Environment variable selecting the embedder kind (above the config file).
25const ENV_EMBEDDER: &str = "PLUGMEM_EMBEDDER";
26// Keep these inventories next to the parser. The settings-help tests compare
27// them with the public documentation catalogue, so adding a parser key without
28// adding its help entry fails loudly.
29pub(crate) const ENGINE_SETTING_KEYS: &[&str] = &[
30    "dim",
31    "max_bytes",
32    "max_text",
33    "max_blob",
34    "shards_facts",
35    "shards_entities",
36    "shards_edges",
37    "shards_temporal",
38    "shards_postings",
39];
40pub(crate) const DATABASE_SETTING_KEYS: &[&str] = &["path"];
41pub(crate) const EMBEDDER_SETTING_KEYS: &[&str] = &["kind", "url", "model", "api_key_env"];
42pub(crate) const MAINTENANCE_SETTING_KEYS: &[&str] = &[
43    "snapshot_every_ops",
44    "snapshot_journal_bytes",
45    "maintain_every_forgets",
46];
47
48/// A configuration error: malformed TOML, a bad `[engine]` value, or an
49/// `[embedder]` section missing a required field. Distinct from [`HostError`]
50/// (which covers opening the database once settings are resolved).
51#[derive(Debug, thiserror::Error)]
52#[non_exhaustive]
53pub enum SettingsError {
54    /// A usage error in the configuration (message is human-facing).
55    #[error("{0}")]
56    Config(String),
57}
58
59impl SettingsError {
60    fn config(msg: impl Into<String>) -> Self {
61        SettingsError::Config(msg.into())
62    }
63}
64
65/// Resolved runtime settings: the engine config, an optional embedder, and the
66/// maintenance policy. The wrapper-specific knobs (`import` batch size, server
67/// workers) are read separately from the same [`read_config`] table.
68pub struct Settings {
69    /// `[database].path`, if set. Wrapper-specific explicit paths take
70    /// precedence over this value; otherwise the platform default is used.
71    pub database_path: Option<PathBuf>,
72    /// The engine configuration (size-bearing fields from `[engine]`).
73    pub config: Config,
74    /// The embedder built from `[embedder]`, or `None` (lexical/graph/time
75    /// recall still work without one).
76    pub embedder: Option<Box<dyn Embedder>>,
77    /// `[maintenance].snapshot_every_ops`, if set.
78    pub snapshot_every_ops: Option<u64>,
79    /// `[maintenance].snapshot_journal_bytes`, if set.
80    pub snapshot_journal_bytes: Option<u64>,
81    /// `[maintenance].maintain_every_forgets`, if set.
82    pub maintain_every_forgets: Option<u64>,
83}
84
85impl Settings {
86    /// Loads settings from the config file resolved by [`read_config`] (an
87    /// explicit `flag` path, else `$PLUGMEM_CONFIG`, else the platform config
88    /// path from [`crate::default_config_path`]). Missing config → defaults.
89    pub fn load(flag: Option<&Path>) -> Result<Settings, SettingsError> {
90        let table = read_config(flag)?;
91        Settings::from_table(table.as_ref())
92    }
93
94    /// Builds settings from an already-parsed config table (or `None` for
95    /// all defaults). `$PLUGMEM_EMBEDDER` overrides `[embedder].kind`. Use
96    /// this when the caller also needs its own keys from the same table
97    /// (read once via [`read_config`], then passed here).
98    pub fn from_table(table: Option<&toml::Table>) -> Result<Settings, SettingsError> {
99        let mut config = Config::default();
100        let mut database_path = None;
101        let mut embedder = EmbedderCfg::default();
102        let mut snapshot_every_ops = None;
103        let mut snapshot_journal_bytes = None;
104        let mut maintain_every_forgets = None;
105
106        if let Some(table) = table {
107            if let Some(t) = table.get("database").and_then(toml::Value::as_table) {
108                database_path = t
109                    .get(DATABASE_SETTING_KEYS[0])
110                    .map(|value| {
111                        let path = value.as_str().ok_or_else(|| {
112                            SettingsError::config("[database].path must be a string")
113                        })?;
114                        if path.is_empty() {
115                            return Err(SettingsError::config("[database].path must not be empty"));
116                        }
117                        Ok(PathBuf::from(path))
118                    })
119                    .transpose()?;
120            }
121            if let Some(t) = table.get("engine").and_then(toml::Value::as_table) {
122                apply_engine(&mut config, t)?;
123            }
124            if let Some(t) = table.get("embedder").and_then(toml::Value::as_table) {
125                embedder.merge(t);
126            }
127            if let Some(t) = table.get("maintenance").and_then(toml::Value::as_table) {
128                snapshot_every_ops = table_u64(t, MAINTENANCE_SETTING_KEYS[0]);
129                snapshot_journal_bytes = table_u64(t, MAINTENANCE_SETTING_KEYS[1]);
130                maintain_every_forgets = table_u64(t, MAINTENANCE_SETTING_KEYS[2]);
131            }
132        }
133
134        if let Some(kind) = std::env::var_os(ENV_EMBEDDER) {
135            embedder.kind = Some(kind.to_string_lossy().into_owned());
136        }
137
138        let embedder = embedder.build(config.dim)?;
139        Ok(Settings {
140            database_path,
141            config,
142            embedder,
143            snapshot_every_ops,
144            snapshot_journal_bytes,
145            maintain_every_forgets,
146        })
147    }
148
149    /// Opens a read-write [`Database`], applying the maintenance policy and
150    /// embedder to the builder. Consumes `self` (the embedder moves into the
151    /// database). For a read-only handle, take [`Settings::embedder`] out
152    /// first, then call [`Database::open_readonly`] with [`Settings::config`].
153    pub fn open(self, path: &Path) -> Result<Database, HostError> {
154        let mut b: DatabaseBuilder = Database::builder(self.config);
155        if let Some(v) = self.snapshot_every_ops {
156            b = b.snapshot_every_ops(v);
157        }
158        if let Some(v) = self.snapshot_journal_bytes {
159            b = b.snapshot_journal_bytes(v);
160        }
161        if let Some(v) = self.maintain_every_forgets {
162            b = b.maintain_every_forgets(v);
163        }
164        if let Some(e) = self.embedder {
165            b = b.embedder(e);
166        }
167        Ok(b.open(path)?.0)
168    }
169}
170
171/// Reads and parses `config.toml`, or `Ok(None)` if none applies. An explicit
172/// `flag` path **must** exist (a read error is a usage error); otherwise
173/// `$PLUGMEM_CONFIG`, then the platform path from
174/// [`crate::default_config_path`], are read only if present. Wrappers call this once, then pass the table to
175/// [`Settings::from_table`] and also read their own keys (batch size, workers)
176/// from it.
177pub fn read_config(flag: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
178    let text = match read_config_text(flag)? {
179        Some(t) => t,
180        None => return Ok(None),
181    };
182    let table: toml::Table = text
183        .parse()
184        .map_err(|e| SettingsError::config(format!("config.toml is not valid TOML: {e}")))?;
185    Ok(Some(table))
186}
187
188/// A non-negative integer key from a table as `u64`, or `None`.
189pub(crate) fn table_u64(t: &toml::Table, key: &str) -> Option<u64> {
190    t.get(key)
191        .and_then(toml::Value::as_integer)
192        .filter(|n| *n >= 0)
193        .map(|n| n as u64)
194}
195
196/// Reads the config file text with flag/env/platform-default precedence.
197fn read_config_text(flag: Option<&Path>) -> Result<Option<String>, SettingsError> {
198    if let Some(p) = flag {
199        return std::fs::read_to_string(p)
200            .map(Some)
201            .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display())));
202    }
203    let candidate = std::env::var_os(ENV_CONFIG)
204        .map(PathBuf::from)
205        .or_else(crate::default_config_path);
206    match candidate {
207        Some(p) if p.exists() => std::fs::read_to_string(&p)
208            .map(Some)
209            .map_err(|e| SettingsError::config(format!("reading config {}: {e}", p.display()))),
210        _ => Ok(None),
211    }
212}
213
214/// Applies the `[engine]` table onto a [`Config`] (the size-bearing fields;
215/// tuning parameters keep their defaults). A non-integer or negative value is
216/// a usage error.
217fn apply_engine(cfg: &mut Config, t: &toml::Table) -> Result<(), SettingsError> {
218    let fields: [(&str, &mut usize); ENGINE_SETTING_KEYS.len()] = [
219        (ENGINE_SETTING_KEYS[0], &mut cfg.dim),
220        (ENGINE_SETTING_KEYS[1], &mut cfg.max_bytes),
221        (ENGINE_SETTING_KEYS[2], &mut cfg.max_text),
222        (ENGINE_SETTING_KEYS[3], &mut cfg.max_blob),
223        (ENGINE_SETTING_KEYS[4], &mut cfg.shards_facts),
224        (ENGINE_SETTING_KEYS[5], &mut cfg.shards_entities),
225        (ENGINE_SETTING_KEYS[6], &mut cfg.shards_edges),
226        (ENGINE_SETTING_KEYS[7], &mut cfg.shards_temporal),
227        (ENGINE_SETTING_KEYS[8], &mut cfg.shards_postings),
228    ];
229    for (key, slot) in fields {
230        if let Some(v) = t.get(key) {
231            let n = v.as_integer().filter(|n| *n >= 0).ok_or_else(|| {
232                SettingsError::config(format!("[engine].{key} must be a non-negative integer"))
233            })?;
234            *slot = n as usize;
235        }
236    }
237    Ok(())
238}
239
240/// The `[embedder]` section, before it is turned into an [`Embedder`].
241#[derive(Default)]
242struct EmbedderCfg {
243    kind: Option<String>,
244    url: Option<String>,
245    model: Option<String>,
246    api_key_env: Option<String>,
247}
248
249impl EmbedderCfg {
250    fn merge(&mut self, t: &toml::Table) {
251        let s = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).map(String::from);
252        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[0]) {
253            self.kind = Some(v);
254        }
255        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[1]) {
256            self.url = Some(v);
257        }
258        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[2]) {
259            self.model = Some(v);
260        }
261        if let Some(v) = s(t, EMBEDDER_SETTING_KEYS[3]) {
262            self.api_key_env = Some(v);
263        }
264    }
265
266    /// Builds the embedder. `kind = "none"` (or unset) → no embedder; an
267    /// OpenAI-compatible `kind` (ollama/openai/lmstudio/vllm/llamacpp) needs a
268    /// `url`, a `model` and `[engine].dim > 0`; an optional `api_key_env` names
269    /// an environment variable holding the bearer token.
270    fn build(&self, dim: usize) -> Result<Option<Box<dyn Embedder>>, SettingsError> {
271        let kind = self.kind.as_deref().unwrap_or("none");
272        match kind {
273            "none" | "" => Ok(None),
274            "ollama" | "openai" | "openai-compat" | "lmstudio" | "vllm" | "llamacpp" => {
275                let url = self.url.clone().ok_or_else(|| {
276                    SettingsError::config(format!("[embedder] kind \"{kind}\" needs a url"))
277                })?;
278                let model = self.model.clone().ok_or_else(|| {
279                    SettingsError::config(format!("[embedder] kind \"{kind}\" needs a model"))
280                })?;
281                if dim == 0 {
282                    return Err(SettingsError::config(
283                        "[embedder] requires [engine].dim > 0 (the embedding size)",
284                    ));
285                }
286                let mut e = OpenAiCompatEmbedder::new(&url, &model, dim);
287                if let Some(env) = &self.api_key_env
288                    && let Some(key) = std::env::var_os(env)
289                {
290                    e = e.with_api_key(key.to_string_lossy().into_owned());
291                }
292                Ok(Some(Box::new(e)))
293            }
294            other => Err(SettingsError::config(format!(
295                "unknown [embedder] kind: {other}"
296            ))),
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    /// A unique temp directory; removed on drop.
306    struct TempDir(PathBuf);
307    impl TempDir {
308        fn new(tag: &str) -> Self {
309            let dir = std::env::temp_dir().join(format!(
310                "plugmem-settings-{tag}-{}-{}",
311                std::process::id(),
312                std::time::SystemTime::now()
313                    .duration_since(std::time::UNIX_EPOCH)
314                    .unwrap()
315                    .as_nanos()
316            ));
317            std::fs::create_dir_all(&dir).unwrap();
318            TempDir(dir)
319        }
320    }
321    impl Drop for TempDir {
322        fn drop(&mut self) {
323            let _ = std::fs::remove_dir_all(&self.0);
324        }
325    }
326
327    #[test]
328    fn engine_and_maintenance_parse() {
329        let text = "\
330[engine]
331dim = 384
332shards_facts = 16
333[maintenance]
334snapshot_every_ops = 50
335snapshot_journal_bytes = 8192
336maintain_every_forgets = 3
337";
338        let table: toml::Table = text.parse().unwrap();
339        let s = Settings::from_table(Some(&table)).unwrap();
340        assert_eq!(s.config.dim, 384);
341        assert_eq!(s.config.shards_facts, 16);
342        assert_eq!(s.snapshot_every_ops, Some(50));
343        assert_eq!(s.snapshot_journal_bytes, Some(8192));
344        assert_eq!(s.maintain_every_forgets, Some(3));
345
346        let bad: toml::Table = "[engine]\ndim = \"huge\"".parse().unwrap();
347        assert!(matches!(
348            Settings::from_table(Some(&bad)),
349            Err(SettingsError::Config(_))
350        ));
351    }
352
353    #[test]
354    fn defaults_when_no_table() {
355        let s = Settings::from_table(None).unwrap();
356        assert!(s.database_path.is_none());
357        assert_eq!(s.config.dim, Config::default().dim);
358        assert!(s.embedder.is_none());
359        assert_eq!(s.snapshot_every_ops, None);
360    }
361
362    #[test]
363    fn embedder_merge_reads_every_field() {
364        let text = "\
365[embedder]
366kind = \"ollama\"
367url = \"http://localhost:11434/v1\"
368model = \"nomic-embed-text\"
369api_key_env = \"SOME_ENV\"
370[engine]
371dim = 8
372";
373        let table: toml::Table = text.parse().unwrap();
374        // An OpenAI-compatible kind with a url, model and dim > 0 builds.
375        let s = Settings::from_table(Some(&table)).unwrap();
376        assert!(s.embedder.is_some());
377    }
378
379    #[test]
380    fn database_path_reads_and_validates_from_config() {
381        let table: toml::Table = "[database]\npath = \"/tmp/memory.plugmem\""
382            .parse()
383            .unwrap();
384        let settings = Settings::from_table(Some(&table)).unwrap();
385        assert_eq!(
386            settings.database_path.as_deref(),
387            Some(std::path::Path::new("/tmp/memory.plugmem"))
388        );
389
390        let bad: toml::Table = "[database]\npath = 42".parse().unwrap();
391        assert!(matches!(
392            Settings::from_table(Some(&bad)),
393            Err(SettingsError::Config(message)) if message == "[database].path must be a string"
394        ));
395    }
396
397    #[test]
398    fn settings_open_applies_maintenance_and_embedder() {
399        // Every maintenance knob set, plus an embedder, so `Settings::open`
400        // exercises each builder branch. The embedder is never invoked by a
401        // bare open, so an unreachable url is fine here.
402        let tmp = TempDir::new("open");
403        let mut config = Config::default();
404        config.dim = 8;
405        let embedder = EmbedderCfg {
406            kind: Some("ollama".into()),
407            url: Some("http://127.0.0.1:0/v1".into()),
408            model: Some("m".into()),
409            api_key_env: None,
410        }
411        .build(8)
412        .unwrap();
413        assert!(embedder.is_some());
414        let settings = Settings {
415            database_path: None,
416            config,
417            embedder,
418            snapshot_every_ops: Some(4),
419            snapshot_journal_bytes: Some(4096),
420            maintain_every_forgets: Some(2),
421        };
422        let db = settings.open(&tmp.0.join("m.plugmem")).unwrap();
423        assert_eq!(db.stats().facts, 0);
424    }
425
426    #[test]
427    fn embedder_build_rules() {
428        assert!(EmbedderCfg::default().build(0).unwrap().is_none());
429        let no_url = EmbedderCfg {
430            kind: Some("ollama".into()),
431            ..Default::default()
432        };
433        assert!(matches!(no_url.build(384), Err(SettingsError::Config(_))));
434        let no_model = EmbedderCfg {
435            kind: Some("ollama".into()),
436            url: Some("http://x/v1".into()),
437            ..Default::default()
438        };
439        assert!(matches!(no_model.build(384), Err(SettingsError::Config(_))));
440        let zero_dim = EmbedderCfg {
441            kind: Some("ollama".into()),
442            url: Some("http://x/v1".into()),
443            model: Some("m".into()),
444            api_key_env: None,
445        };
446        assert!(matches!(zero_dim.build(0), Err(SettingsError::Config(_))));
447        let ok = EmbedderCfg {
448            kind: Some("openai".into()),
449            url: Some("http://x/v1".into()),
450            model: Some("m".into()),
451            api_key_env: Some("PLUGMEM_TEST_KEY_UNSET".into()),
452        };
453        assert!(ok.build(384).unwrap().is_some());
454        let weird = EmbedderCfg {
455            kind: Some("weird".into()),
456            ..Default::default()
457        };
458        assert!(matches!(weird.build(384), Err(SettingsError::Config(_))));
459    }
460
461    #[test]
462    fn load_reads_the_config_file() {
463        let tmp = TempDir::new("load");
464        let cfgfile = tmp.0.join("config.toml");
465        std::fs::write(
466            &cfgfile,
467            "[database]\npath = \"memory.plugmem\"\n[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n[maintenance]\nsnapshot_every_ops = 64\n",
468        )
469        .unwrap();
470        let s = Settings::load(Some(&cfgfile)).unwrap();
471        assert_eq!(s.database_path, Some(PathBuf::from("memory.plugmem")));
472        assert_eq!(s.config.dim, 512);
473        assert!(s.embedder.is_none());
474        assert_eq!(s.snapshot_every_ops, Some(64));
475
476        // An explicit path that does not exist is a usage error.
477        assert!(matches!(
478            Settings::load(Some(&tmp.0.join("nope.toml"))),
479            Err(SettingsError::Config(_))
480        ));
481    }
482
483    #[test]
484    fn read_config_none_and_batch_extra() {
485        // No file → Ok(None); a wrapper reads its own extra key from the table.
486        let tmp = TempDir::new("extra");
487        let missing = tmp.0.join("absent.toml");
488        // An absent *default* (no flag) yields None only if neither env nor the
489        // XDG default exists; exercise the explicit-missing-flag error instead.
490        assert!(read_config(Some(&missing)).is_err());
491
492        let cfgfile = tmp.0.join("config.toml");
493        std::fs::write(&cfgfile, "[maintenance]\nbatch_size = 256\n").unwrap();
494        let table = read_config(Some(&cfgfile)).unwrap().unwrap();
495        let batch = table
496            .get("maintenance")
497            .and_then(toml::Value::as_table)
498            .and_then(|m| table_u64(m, "batch_size"));
499        assert_eq!(batch, Some(256));
500    }
501
502    #[test]
503    fn every_host_setting_is_documented() {
504        let docs = crate::settings_help::settings_help().docs();
505        for (section, keys) in [
506            ("database", DATABASE_SETTING_KEYS),
507            ("engine", ENGINE_SETTING_KEYS),
508            ("embedder", EMBEDDER_SETTING_KEYS),
509            ("maintenance", MAINTENANCE_SETTING_KEYS),
510        ] {
511            let documented: Vec<_> = docs
512                .iter()
513                .filter(|doc| {
514                    doc.section == section
515                        && doc.scope == crate::settings_help::SettingScope::Shared
516                })
517                .map(|doc| doc.key)
518                .collect();
519            assert_eq!(
520                documented.as_slice(),
521                keys,
522                "undocumented {section} setting"
523            );
524        }
525    }
526}