Skip to main content

plugmem_host/
settings_help.rs

1//! The single source of truth for config.toml help.
2//!
3//! The parser lives in [`super::settings`], while CLI/MCP/NAPI are separate
4//! surfaces. Keeping the public setting catalogue here lets those surfaces
5//! render their own help without copying descriptions or defaults.
6
7use std::fmt::Write as _;
8
9const PLATFORM_DEFAULT_SOURCE: &str = "platform default config path";
10
11/// Something in `config.toml` that was read and then ignored.
12///
13/// A warning rather than an error, deliberately: refusing an unknown key would
14/// mean an older binary could not read a config written for a newer one, which
15/// is a worse failure than a typo. But *silence* is worse than both — a
16/// misspelled `w_vec` changes no behaviour and says nothing, and the user is
17/// left believing they tuned something.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct SettingWarning {
20    /// The TOML section it appeared in, without brackets. Empty for an unknown
21    /// *section*, where [`Self::key`] is the section's own name.
22    pub section: String,
23    /// The key (or section) nobody claimed.
24    pub key: String,
25    /// The closest known name, when one is close enough to be worth offering.
26    pub did_you_mean: Option<&'static str>,
27}
28
29impl std::fmt::Display for SettingWarning {
30    /// One line, ready for a stderr note or a log.
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        if self.section.is_empty() {
33            write!(f, "unknown config section [{}]", self.key)?;
34        } else {
35            write!(f, "unknown setting [{}].{}", self.section, self.key)?;
36        }
37        match self.did_you_mean {
38            Some(near) => write!(f, " — did you mean `{near}`?"),
39            None => write!(f, " (ignored)"),
40        }
41    }
42}
43
44/// Which runtime surface owns a setting.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum SettingScope {
47    /// Parsed by `plugmem-host` and shared by every wrapper.
48    Shared,
49    /// Read by `plugmem-cli` in addition to the shared settings.
50    Cli,
51    /// Read by `plugmem-mcp` in addition to the shared settings.
52    Mcp,
53}
54
55impl SettingScope {
56    /// The stable, user-facing scope label.
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::Shared => "shared",
60            Self::Cli => "CLI",
61            Self::Mcp => "MCP",
62        }
63    }
64}
65
66/// Documentation for one supported config.toml key.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub struct SettingDoc {
69    /// TOML section, without brackets.
70    pub section: &'static str,
71    /// TOML key inside [`Self::section`].
72    pub key: &'static str,
73    /// Human-readable value type.
74    pub value_type: &'static str,
75    /// Default as displayed to users.
76    pub default: &'static str,
77    /// What the setting controls.
78    pub description: &'static str,
79    /// The wrapper(s) that consume the setting.
80    pub scope: SettingScope,
81}
82
83/// Runtime access to the complete config.toml help catalogue.
84#[derive(Clone, Copy, Debug)]
85pub struct SettingsHelp {
86    docs: &'static [SettingDoc],
87    config_path_precedence: &'static [&'static str],
88}
89
90impl SettingsHelp {
91    /// Every documented config.toml key.
92    pub const fn docs(self) -> &'static [SettingDoc] {
93        self.docs
94    }
95
96    /// Config-file discovery order, from highest to lowest precedence.
97    pub const fn config_path_precedence(self) -> &'static [&'static str] {
98        self.config_path_precedence
99    }
100
101    /// Every section this catalogue knows, in first-appearance order.
102    ///
103    /// Borrowed `&'static str`s from the catalogue itself: no allocation, and
104    /// there are five of them, so a linear scan beats any index.
105    fn sections(self) -> impl Iterator<Item = &'static str> {
106        self.docs
107            .iter()
108            .enumerate()
109            .filter(|(i, doc)| *i == 0 || self.docs[i - 1].section != doc.section)
110            .map(|(_, doc)| doc.section)
111    }
112
113    /// The keys documented under `section`.
114    fn keys_in(self, section: &str) -> impl Iterator<Item = &'static str> {
115        self.docs
116            .iter()
117            .filter(move |doc| doc.section == section)
118            .map(|doc| doc.key)
119    }
120
121    /// Reports every section and key in `table` that no surface claims.
122    ///
123    /// The catalogue is the authority rather than the parser's own key lists,
124    /// and it has to be: `[maintenance].batch_size` belongs to the CLI and
125    /// `[server].workers` to the MCP server, so a check that only knew what the
126    /// shared loader parses would warn about both on every run.
127    ///
128    /// Allocation-free in the ordinary case — a clean config returns an empty
129    /// `Vec`, which allocates nothing. Only a real mistake costs anything.
130    pub fn unknown_in(self, table: &toml::Table) -> Vec<SettingWarning> {
131        let mut out = Vec::new();
132        for (name, value) in table {
133            let Some(section) = self.sections().find(|s| s == name) else {
134                out.push(SettingWarning {
135                    section: String::new(),
136                    key: name.clone(),
137                    did_you_mean: nearest(name, self.sections()),
138                });
139                continue;
140            };
141            // A section given as something other than a table is the parser's
142            // business to reject, not this scan's.
143            let Some(entries) = value.as_table() else {
144                continue;
145            };
146            for key in entries.keys() {
147                if self.keys_in(section).any(|k| k == key) {
148                    continue;
149                }
150                out.push(SettingWarning {
151                    section: section.to_string(),
152                    key: key.clone(),
153                    did_you_mean: nearest(key, self.keys_in(section)),
154                });
155            }
156        }
157        out
158    }
159
160    /// Render the catalogue for a terminal or a human-facing tool response.
161    pub fn render_human(self) -> String {
162        let mut output = String::from("plugmem settings\n\n");
163        output.push_str("Config file precedence:\n");
164        for (index, source) in self.config_path_precedence.iter().enumerate() {
165            if *source == PLATFORM_DEFAULT_SOURCE {
166                match crate::default_config_path() {
167                    Some(path) => {
168                        let _ = writeln!(output, "  {}. {}", index + 1, path.display());
169                    }
170                    None => {
171                        let _ = writeln!(output, "  {}. {source} (unavailable)", index + 1);
172                    }
173                }
174            } else {
175                let _ = writeln!(output, "  {}. {source}", index + 1);
176            }
177        }
178        output.push('\n');
179
180        let mut section = None;
181        for doc in self.docs {
182            if section != Some(doc.section) {
183                if section.is_some() {
184                    output.push('\n');
185                }
186                let _ = writeln!(output, "[{}]", doc.section);
187                section = Some(doc.section);
188            }
189            let _ = writeln!(
190                output,
191                "  {} ({}, default: {}) — {} [{}]",
192                doc.key,
193                doc.value_type,
194                doc.default,
195                doc.description,
196                doc.scope.as_str()
197            );
198        }
199
200        output
201    }
202}
203
204/// The closest candidate to `typo`, if one is close enough to suggest.
205///
206/// One edit always, plus one per four characters: long names tolerate a bigger
207/// slip than short ones, and `dim` never gets confused with `url`. Offering a
208/// wrong guess is worse than offering none — it sends the reader to fix the
209/// wrong line.
210///
211/// The scale is set by the mistakes people actually make. `w_vector` for
212/// `w_vec` is three edits on an eight-character name: the likeliest typo in the
213/// whole catalogue, since the field is a vector weight and nobody abbreviates
214/// on the first try. A tighter budget looks principled and misses it.
215fn nearest(typo: &str, candidates: impl Iterator<Item = &'static str>) -> Option<&'static str> {
216    let budget = 1 + typo.chars().count() / 4;
217    candidates
218        .map(|c| (edit_distance(typo, c), c))
219        .filter(|(d, _)| *d <= budget)
220        .min_by_key(|(d, _)| *d)
221        .map(|(_, c)| c)
222}
223
224/// Levenshtein distance over `char`s, two rows at a time.
225///
226/// Two `Vec<usize>` the width of the shorter name — setting names are a handful
227/// of characters, and this runs only when something is already wrong.
228fn edit_distance(a: &str, b: &str) -> usize {
229    let b: Vec<char> = b.chars().collect();
230    let mut prev: Vec<usize> = (0..=b.len()).collect();
231    let mut row = vec![0; b.len() + 1];
232    for (i, ca) in a.chars().enumerate() {
233        row[0] = i + 1;
234        for (j, cb) in b.iter().enumerate() {
235            let cost = usize::from(ca != *cb);
236            row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
237        }
238        core::mem::swap(&mut prev, &mut row);
239    }
240    prev[b.len()]
241}
242
243const CONFIG_PATH_PRECEDENCE: &[&str] = &[
244    "--config PATH",
245    "$PLUGMEM_CONFIG",
246    "platform default config path",
247    "built-in defaults",
248];
249
250const DOCS: &[SettingDoc] = &[
251    SettingDoc {
252        section: "database",
253        key: "path",
254        value_type: "path string",
255        default: "platform data directory/memory.plugmem",
256        description: "Persistent database file; explicit --db/constructor path and PLUGMEM_DB override it",
257        scope: SettingScope::Shared,
258    },
259    SettingDoc {
260        section: "workspace",
261        key: "dir",
262        value_type: "path string",
263        default: "unset (one database, no workspace)",
264        description: "Directory of named databases; unset means the single-database default",
265        scope: SettingScope::Shared,
266    },
267    SettingDoc {
268        section: "workspace",
269        key: "max_open",
270        value_type: "positive integer",
271        default: "16",
272        description: "Workspace databases kept open at once; the least recently used is closed",
273        scope: SettingScope::Shared,
274    },
275    SettingDoc {
276        section: "workspace",
277        key: "idle_timeout_ms",
278        value_type: "non-negative integer",
279        default: "60000",
280        description: "Close a workspace database unused this long, releasing its lock; 0 never closes",
281        scope: SettingScope::Shared,
282    },
283    SettingDoc {
284        section: "engine",
285        key: "dim",
286        value_type: "non-negative integer",
287        default: "0",
288        description: "Embedding dimension; 0 disables vector storage",
289        scope: SettingScope::Shared,
290    },
291    SettingDoc {
292        section: "engine",
293        key: "max_bytes",
294        value_type: "non-negative integer",
295        default: "2147483648",
296        description: "Ceiling applied to each byte pool separately, not to their sum",
297        scope: SettingScope::Shared,
298    },
299    SettingDoc {
300        section: "engine",
301        key: "max_text",
302        value_type: "non-negative integer",
303        default: "4096",
304        description: "Maximum fact text length in bytes",
305        scope: SettingScope::Shared,
306    },
307    SettingDoc {
308        section: "engine",
309        key: "max_blob",
310        value_type: "non-negative integer",
311        default: "65536",
312        description: "Maximum single blob length in bytes",
313        scope: SettingScope::Shared,
314    },
315    SettingDoc {
316        section: "recall",
317        key: "bm25_k1",
318        value_type: "number > 0",
319        default: "1.2",
320        description: "BM25 term-frequency saturation: higher lets a repeated word keep counting",
321        scope: SettingScope::Shared,
322    },
323    SettingDoc {
324        section: "recall",
325        key: "bm25_b",
326        value_type: "number in [0, 1]",
327        default: "0.75",
328        description: "BM25 length normalisation: 0 ignores fact length, 1 penalises long facts fully",
329        scope: SettingScope::Shared,
330    },
331    SettingDoc {
332        section: "recall",
333        key: "rrf_k",
334        value_type: "integer >= 1",
335        default: "60",
336        description: "Reciprocal-rank-fusion constant: larger flattens the gap between rank 1 and rank 10",
337        scope: SettingScope::Shared,
338    },
339    SettingDoc {
340        section: "recall",
341        key: "w_bm25",
342        value_type: "number >= 0",
343        default: "1.0",
344        description: "Weight of the lexical source in the fused score; 0 switches it off",
345        scope: SettingScope::Shared,
346    },
347    SettingDoc {
348        section: "recall",
349        key: "w_vec",
350        value_type: "number >= 0",
351        default: "1.0",
352        description: "Weight of the vector source; 0 switches it off (and costs nothing when dim = 0)",
353        scope: SettingScope::Shared,
354    },
355    SettingDoc {
356        section: "recall",
357        key: "w_graph",
358        value_type: "number >= 0",
359        default: "1.0",
360        description: "Weight of the entity-graph source; 0 switches off relational expansion",
361        scope: SettingScope::Shared,
362    },
363    SettingDoc {
364        section: "recall",
365        key: "w_time",
366        value_type: "number >= 0",
367        default: "1.0",
368        description: "Weight of the temporal source (the recorded_at window); 0 switches it off",
369        scope: SettingScope::Shared,
370    },
371    SettingDoc {
372        section: "recall",
373        key: "w_recency",
374        value_type: "number >= 0",
375        default: "0.25",
376        description: "How much a fact's age discounts it, on top of the sources above",
377        scope: SettingScope::Shared,
378    },
379    SettingDoc {
380        section: "recall",
381        key: "half_life_days",
382        value_type: "integer >= 1",
383        default: "180",
384        description: "Age at which the recency discount has halved; larger keeps old facts competitive",
385        scope: SettingScope::Shared,
386    },
387    SettingDoc {
388        section: "recall",
389        key: "graph_depth",
390        value_type: "non-negative integer",
391        default: "2",
392        description: "Default hops the graph source may follow from an anchor entity; a recall's own `graph_depth` overrides it. Uncapped — the walk is bounded by its entity and edge caps, not by depth",
393        scope: SettingScope::Shared,
394    },
395    SettingDoc {
396        section: "recall",
397        key: "graph_decay",
398        value_type: "number in (0, 1]",
399        default: "0.5",
400        description: "How much each extra hop discounts a fact reached through the graph",
401        scope: SettingScope::Shared,
402    },
403    SettingDoc {
404        section: "recall",
405        key: "hnsw_ef_search",
406        value_type: "integer >= 1",
407        default: "64",
408        description: "Default HNSW beam width; higher is more accurate and slower. A recall's own `ef` overrides it, and it does nothing while the index is still flat",
409        scope: SettingScope::Shared,
410    },
411    SettingDoc {
412        section: "recall",
413        key: "similar_cos",
414        value_type: "number in [0, 1]",
415        default: "0.85",
416        description: "Cosine above which remember reports an existing fact as possibly conflicting (it never revises on its own)",
417        scope: SettingScope::Shared,
418    },
419    SettingDoc {
420        section: "recall",
421        key: "similar_jaccard",
422        value_type: "number in [0, 1]",
423        default: "0.5",
424        description: "Token overlap above which remember reports a possible conflict, for memories with no vectors",
425        scope: SettingScope::Shared,
426    },
427    SettingDoc {
428        section: "index",
429        key: "hnsw_ef_construction",
430        value_type: "integer >= hnsw_m (16 by default)",
431        default: "200",
432        description: "Beam width while building the vector graph: higher builds a better index, slower",
433        scope: SettingScope::Shared,
434    },
435    SettingDoc {
436        section: "index",
437        key: "flat_to_hnsw",
438        value_type: "integer >= 1",
439        default: "24000",
440        description: "Vector count at which maintenance stops scanning flat and builds the HNSW graph",
441        scope: SettingScope::Shared,
442    },
443    SettingDoc {
444        section: "embedder",
445        key: "kind",
446        value_type: "string",
447        default: "none",
448        description: "Embedding provider: none, ollama, openai, lmstudio, vllm or llamacpp",
449        scope: SettingScope::Shared,
450    },
451    SettingDoc {
452        section: "embedder",
453        key: "url",
454        value_type: "string",
455        default: "unset",
456        description: "OpenAI-compatible /v1/embeddings endpoint",
457        scope: SettingScope::Shared,
458    },
459    SettingDoc {
460        section: "embedder",
461        key: "model",
462        value_type: "string",
463        default: "unset",
464        description: "Embedding model name",
465        scope: SettingScope::Shared,
466    },
467    SettingDoc {
468        section: "embedder",
469        key: "api_key_env",
470        value_type: "string",
471        default: "unset",
472        description: "Environment variable containing the bearer token",
473        scope: SettingScope::Shared,
474    },
475    SettingDoc {
476        section: "maintenance",
477        key: "snapshot_every_ops",
478        value_type: "non-negative integer",
479        default: "1024",
480        description: "Snapshot after this many mutations",
481        scope: SettingScope::Shared,
482    },
483    SettingDoc {
484        section: "maintenance",
485        key: "snapshot_journal_bytes",
486        value_type: "non-negative integer",
487        default: "4194304",
488        description: "Snapshot when the journal reaches this size",
489        scope: SettingScope::Shared,
490    },
491    SettingDoc {
492        section: "maintenance",
493        key: "maintain_every_forgets",
494        value_type: "non-negative integer",
495        default: "off",
496        description: "Run policy maintenance after this many forgets",
497        scope: SettingScope::Shared,
498    },
499    SettingDoc {
500        section: "maintenance",
501        key: "fsync",
502        value_type: "\"each_op\" | \"on_snapshot\"",
503        default: "each_op",
504        description: "When journal appends reach the disk. \"each_op\": every acknowledged write \
505survives a power cut. \"on_snapshot\": faster, an OS crash may lose the journal tail since the \
506last snapshot",
507        scope: SettingScope::Shared,
508    },
509    SettingDoc {
510        section: "maintenance",
511        key: "batch_size",
512        value_type: "positive integer",
513        default: "128",
514        description: "CLI import facts per embedding request and journal fsync",
515        scope: SettingScope::Cli,
516    },
517    SettingDoc {
518        section: "server",
519        key: "workers",
520        value_type: "positive integer",
521        default: "half of available cores",
522        description: "MCP worker threads",
523        scope: SettingScope::Mcp,
524    },
525];
526
527static SETTINGS_HELP: SettingsHelp = SettingsHelp {
528    docs: DOCS,
529    config_path_precedence: CONFIG_PATH_PRECEDENCE,
530};
531
532/// Returns the shared settings catalogue used by host, CLI, MCP and NAPI.
533pub const fn settings_help() -> &'static SettingsHelp {
534    &SETTINGS_HELP
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn edit_distance_holds_at_the_degenerate_ends() {
543        // The empty string against anything is that thing's length, from both
544        // sides: with `a` empty the inner loop never runs and the seeded first
545        // row is the answer; with `b` empty the table is one column wide and
546        // only `row[0]` ever moves. Both are easy to get wrong by one.
547        assert_eq!(edit_distance("", ""), 0);
548        assert_eq!(edit_distance("", "dim"), 3);
549        assert_eq!(edit_distance("dim", ""), 3);
550
551        // One character each way, same and different.
552        assert_eq!(edit_distance("a", "a"), 0);
553        assert_eq!(edit_distance("a", "b"), 1);
554        assert_eq!(edit_distance("a", ""), 1);
555        assert_eq!(edit_distance(" ", ""), 1);
556        assert_eq!(edit_distance(" ", "a"), 1);
557
558        // The three edits, each in isolation.
559        assert_eq!(edit_distance("dim", "dm"), 1, "deletion");
560        assert_eq!(edit_distance("dim", "diim"), 1, "insertion");
561        assert_eq!(edit_distance("dim", "dir"), 1, "substitution");
562
563        // Counted in characters, not bytes: a multi-byte name must not read as
564        // several edits away from itself.
565        assert_eq!(edit_distance("ключ", "ключ"), 0);
566        assert_eq!(edit_distance("ключ", "клуч"), 1);
567        assert_eq!(edit_distance("ключ", ""), 4);
568
569        // Symmetric, which a two-row implementation can quietly break.
570        for (a, b) in [("dim", "max_text"), ("", "fsync"), ("a", "workers")] {
571            assert_eq!(edit_distance(a, b), edit_distance(b, a), "{a} vs {b}");
572        }
573    }
574
575    #[test]
576    fn a_suggestion_is_offered_only_when_it_is_worth_offering() {
577        let engine = || settings_help().keys_in("engine");
578
579        // Close enough to be the obvious intent.
580        assert_eq!(nearest("dm", engine()), Some("dim"));
581        assert_eq!(nearest("max_txt", engine()), Some("max_text"));
582
583        // The one the budget exists for: three edits on an eight-character
584        // name, and the likeliest typo in the catalogue.
585        let recall = || settings_help().keys_in("recall");
586        assert_eq!(nearest("w_vector", recall()), Some("w_vec"));
587        assert_eq!(nearest("similar_cosine", recall()), Some("similar_cos"));
588
589        // A truncation is not chased. `half_life` is five edits from
590        // `half_life_days`, and widening the budget far enough to reach it
591        // would start matching keys that share a prefix and nothing else.
592        // The warning still names the key; only the guess is withheld.
593        assert_eq!(nearest("half_life", recall()), None);
594
595        // Not close to anything: silence beats sending someone to the wrong
596        // line. A single character is the sharpest case — the budget floors at
597        // one edit, so it must not reach a three-character key.
598        assert_eq!(nearest("a", engine()), None);
599        assert_eq!(nearest("", engine()), None);
600        assert_eq!(nearest(" ", engine()), None);
601        assert_eq!(nearest("completely_unrelated", engine()), None);
602    }
603
604    /// A config table from lines, so the fixtures indent with the code instead
605    /// of being pinned to the file's left margin.
606    fn toml_of(lines: &[&str]) -> toml::Table {
607        lines.join("\n").parse().expect("valid TOML fixture")
608    }
609
610    #[test]
611    fn unknown_sections_and_keys_are_reported_with_their_context() {
612        let table = toml_of(&[
613            "[engine]",
614            "dim = 8",
615            "max_txt = 10",
616            "",
617            "[embedder]",
618            r#"kind = "none""#,
619            "",
620            "[engin]",
621            "dim = 4",
622        ]);
623
624        let found = settings_help().unknown_in(&table);
625        // A misspelled key inside a real section, and a misspelled section.
626        assert_eq!(
627            found,
628            vec![
629                SettingWarning {
630                    section: String::new(),
631                    key: "engin".to_string(),
632                    did_you_mean: Some("engine"),
633                },
634                SettingWarning {
635                    section: "engine".to_string(),
636                    key: "max_txt".to_string(),
637                    did_you_mean: Some("max_text"),
638                },
639            ]
640        );
641        assert!(
642            found[0]
643                .to_string()
644                .contains("unknown config section [engin]")
645        );
646        assert!(found[1].to_string().contains("[engine].max_txt"));
647    }
648
649    #[test]
650    fn keys_a_wrapper_owns_are_not_warned_about() {
651        // The reason the catalogue is the authority and the parser's own lists
652        // are not: host parses neither of these, and warning about them would
653        // fire on every CLI and MCP run.
654        let table = toml_of(&[
655            "[maintenance]",
656            "batch_size = 256",
657            "",
658            "[server]",
659            "workers = 4",
660        ]);
661        assert_eq!(settings_help().unknown_in(&table), vec![]);
662    }
663
664    #[test]
665    fn a_clean_config_warns_about_nothing() {
666        let mut text = String::new();
667        let mut section = "";
668        for doc in DOCS {
669            if doc.section != section {
670                let _ = writeln!(text, "[{}]", doc.section);
671                section = doc.section;
672            }
673            // The value is irrelevant here: this scan checks names, and the
674            // parser owns types. Every documented key must pass it.
675            let _ = writeln!(text, "{} = 0", doc.key);
676        }
677        let table: toml::Table = text.parse().unwrap();
678        assert_eq!(
679            settings_help().unknown_in(&table),
680            vec![],
681            "the catalogue must accept everything it documents"
682        );
683    }
684
685    #[test]
686    fn every_documented_setting_has_a_complete_description() {
687        assert!(!DOCS.is_empty());
688        for doc in DOCS {
689            assert!(!doc.section.is_empty());
690            assert!(!doc.key.is_empty());
691            assert!(!doc.value_type.is_empty());
692            assert!(!doc.default.is_empty());
693            assert!(!doc.description.is_empty());
694        }
695    }
696
697    #[test]
698    fn human_help_contains_every_documented_key() {
699        let rendered = settings_help().render_human();
700        for doc in DOCS {
701            assert!(
702                rendered.contains(doc.key),
703                "missing {}.{}",
704                doc.section,
705                doc.key
706            );
707        }
708    }
709}