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 the CLI, the MCP server and
4//! the Node and Python bindings are separate surfaces. Keeping the public
5//! setting catalogue here lets those surfaces render their own help without
6//! copying descriptions or defaults.
7
8use std::fmt::Write as _;
9
10const PLATFORM_DEFAULT_SOURCE: &str = "platform default config path";
11
12/// Something in `config.toml` that was read and then ignored.
13///
14/// A warning rather than an error, deliberately: refusing an unknown key would
15/// mean an older binary could not read a config written for a newer one, which
16/// is a worse failure than a typo. But *silence* is worse than both — a
17/// misspelled `w_vec` changes no behaviour and says nothing, and the user is
18/// left believing they tuned something.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct SettingWarning {
21    /// The TOML section it appeared in, without brackets. Empty for an unknown
22    /// *section*, where [`Self::key`] is the section's own name.
23    pub section: String,
24    /// The key (or section) nobody claimed.
25    pub key: String,
26    /// The closest known name, when one is close enough to be worth offering.
27    pub did_you_mean: Option<&'static str>,
28}
29
30impl std::fmt::Display for SettingWarning {
31    /// One line, ready for a stderr note or a log.
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        if self.section.is_empty() {
34            write!(f, "unknown config section [{}]", self.key)?;
35        } else {
36            write!(f, "unknown setting [{}].{}", self.section, self.key)?;
37        }
38        match self.did_you_mean {
39            Some(near) => write!(f, " — did you mean `{near}`?"),
40            None => write!(f, " (ignored)"),
41        }
42    }
43}
44
45/// Which runtime surface owns a setting.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SettingScope {
48    /// Parsed by `plugmem-host` and shared by every wrapper.
49    Shared,
50    /// Read by `plugmem-cli` in addition to the shared settings.
51    Cli,
52    /// Read by `plugmem-mcp` in addition to the shared settings.
53    Mcp,
54}
55
56impl SettingScope {
57    /// The stable, user-facing scope label.
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::Shared => "shared",
61            Self::Cli => "CLI",
62            Self::Mcp => "MCP",
63        }
64    }
65}
66
67/// Documentation for one supported config.toml key.
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub struct SettingDoc {
70    /// TOML section, without brackets.
71    pub section: &'static str,
72    /// TOML key inside [`Self::section`].
73    pub key: &'static str,
74    /// Human-readable value type.
75    pub value_type: &'static str,
76    /// Default as displayed to users.
77    pub default: &'static str,
78    /// What the setting controls.
79    pub description: &'static str,
80    /// A valid TOML value for this key.
81    ///
82    /// Not a second opinion about the default — several defaults cannot be
83    /// written as a value at all ("automatic", "off", "half of available
84    /// cores"), and `config.example.toml` still has to show a line somebody
85    /// can uncomment. It is also what makes that file testable: the generator
86    /// renders an all-keys-set variant, and a test parses it through the real
87    /// loader and requires zero warnings.
88    pub example: &'static str,
89    /// The wrapper(s) that consume the setting.
90    pub scope: SettingScope,
91}
92
93/// Runtime access to the complete config.toml help catalogue.
94#[derive(Clone, Copy, Debug)]
95pub struct SettingsHelp {
96    docs: &'static [SettingDoc],
97    config_path_precedence: &'static [&'static str],
98}
99
100impl SettingsHelp {
101    /// Every documented config.toml key.
102    pub const fn docs(self) -> &'static [SettingDoc] {
103        self.docs
104    }
105
106    /// Config-file discovery order, from highest to lowest precedence.
107    pub const fn config_path_precedence(self) -> &'static [&'static str] {
108        self.config_path_precedence
109    }
110
111    /// Every section this catalogue knows, in first-appearance order.
112    ///
113    /// Borrowed `&'static str`s from the catalogue itself: no allocation, and
114    /// there are five of them, so a linear scan beats any index.
115    fn sections(self) -> impl Iterator<Item = &'static str> {
116        self.docs
117            .iter()
118            .enumerate()
119            .filter(|(i, doc)| *i == 0 || self.docs[i - 1].section != doc.section)
120            .map(|(_, doc)| doc.section)
121    }
122
123    /// The keys documented under `section`.
124    fn keys_in(self, section: &str) -> impl Iterator<Item = &'static str> {
125        self.docs
126            .iter()
127            .filter(move |doc| doc.section == section)
128            .map(|doc| doc.key)
129    }
130
131    /// Reports every section and key in `table` that no surface claims.
132    ///
133    /// The catalogue is the authority rather than the parser's own key lists,
134    /// and it has to be: `[maintenance].batch_size` belongs to the CLI and
135    /// `[server].workers` to the MCP server, so a check that only knew what the
136    /// shared loader parses would warn about both on every run.
137    ///
138    /// Allocation-free in the ordinary case — a clean config returns an empty
139    /// `Vec`, which allocates nothing. Only a real mistake costs anything.
140    pub fn unknown_in(self, table: &toml::Table) -> Vec<SettingWarning> {
141        let mut out = Vec::new();
142        for (name, value) in table {
143            let Some(section) = self.sections().find(|s| s == name) else {
144                out.push(SettingWarning {
145                    section: String::new(),
146                    key: name.clone(),
147                    did_you_mean: nearest(name, self.sections()),
148                });
149                continue;
150            };
151            // A section given as something other than a table is the parser's
152            // business to reject, not this scan's.
153            let Some(entries) = value.as_table() else {
154                continue;
155            };
156            for key in entries.keys() {
157                if self.keys_in(section).any(|k| k == key) {
158                    continue;
159                }
160                out.push(SettingWarning {
161                    section: section.to_string(),
162                    key: key.clone(),
163                    did_you_mean: nearest(key, self.keys_in(section)),
164                });
165            }
166        }
167        out
168    }
169
170    /// Renders `config.example.toml`: every supported key, with its default,
171    /// its type and one line about what it does.
172    ///
173    /// Generated rather than written, because a hand-kept example is another
174    /// copy of the catalogue that ages without anyone noticing — which is
175    /// exactly what happened to the four README samples this replaces. A test
176    /// compares the committed file with this output, so the two cannot drift.
177    ///
178    /// **Every line is commented out.** An example that sets forty keys
179    /// explicitly freezes today's defaults into the config of everyone who
180    /// copies it: a later release improves `flat_to_hnsw` and they never see
181    /// it. Uncomment the two or three you actually mean to change.
182    ///
183    /// `set` renders the same file with every key *active*, which is what the
184    /// test parses through the real loader — an example nothing can parse is
185    /// worth less than no example.
186    pub fn render_config_example(self, set: bool) -> String {
187        // One line per push rather than one long escaped literal: the header
188        // is prose a person reads first, and it should be as easy to fix here
189        // as it is to read there.
190        let mut output = String::new();
191        for line in [
192            "# plugmem — every supported config.toml key, with its default.",
193            "#",
194            "# GENERATED from the settings catalogue. Do not edit: run",
195            "#   cargo run -p plugmem-host --bin config_example",
196            "# and commit the result. A test fails when this file and the",
197            "# catalogue disagree, so an edit here is undone by the next run.",
198            "#",
199            "# Every key below is commented out and shows the default this build",
200            "# uses. Uncomment only what you mean to change — a config that sets",
201            "# everything explicitly freezes today's defaults, and never picks up",
202            "# a better one.",
203            "#",
204            "# What each setting is FOR, and when changing it is a good idea,",
205            "# lives in crates/plugmem-host/SETTINGS.md. This file is the shape;",
206            "# that file is the reasoning.",
207            "#",
208        ] {
209            output.push_str(line);
210            output.push('\n');
211        }
212        output.push_str("# Config file precedence, highest first:\n");
213        for (index, source) in self.config_path_precedence.iter().enumerate() {
214            let _ = writeln!(output, "#   {}. {source}", index + 1);
215        }
216
217        let mut section = None;
218        for doc in self.docs {
219            if section != Some(doc.section) {
220                let _ = write!(output, "\n[{}]\n", doc.section);
221                section = Some(doc.section);
222            }
223            let scope = match doc.scope {
224                SettingScope::Shared => String::new(),
225                other => format!(", read only by {}", other.as_str()),
226            };
227            let _ = writeln!(
228                output,
229                "# {} — {}\n# {}, default: {}{}",
230                doc.key, doc.description, doc.value_type, doc.default, scope
231            );
232            let prefix = if set { "" } else { "# " };
233            let _ = writeln!(output, "{prefix}{} = {}", doc.key, doc.example);
234        }
235        output
236    }
237
238    /// Render the catalogue for a terminal or a human-facing tool response.
239    pub fn render_human(self) -> String {
240        let mut output = String::from("plugmem settings\n\n");
241        output.push_str("Config file precedence:\n");
242        for (index, source) in self.config_path_precedence.iter().enumerate() {
243            if *source == PLATFORM_DEFAULT_SOURCE {
244                match crate::default_config_path() {
245                    Some(path) => {
246                        let _ = writeln!(output, "  {}. {}", index + 1, path.display());
247                    }
248                    None => {
249                        let _ = writeln!(output, "  {}. {source} (unavailable)", index + 1);
250                    }
251                }
252            } else {
253                let _ = writeln!(output, "  {}. {source}", index + 1);
254            }
255        }
256        output.push('\n');
257
258        let mut section = None;
259        for doc in self.docs {
260            if section != Some(doc.section) {
261                if section.is_some() {
262                    output.push('\n');
263                }
264                let _ = writeln!(output, "[{}]", doc.section);
265                section = Some(doc.section);
266            }
267            let _ = writeln!(
268                output,
269                "  {} ({}, default: {}) — {} [{}]",
270                doc.key,
271                doc.value_type,
272                doc.default,
273                doc.description,
274                doc.scope.as_str()
275            );
276        }
277
278        output
279    }
280}
281
282/// The closest candidate to `typo`, if one is close enough to suggest.
283///
284/// One edit always, plus one per four characters: long names tolerate a bigger
285/// slip than short ones, and `dim` never gets confused with `url`. Offering a
286/// wrong guess is worse than offering none — it sends the reader to fix the
287/// wrong line.
288///
289/// The scale is set by the mistakes people actually make. `w_vector` for
290/// `w_vec` is three edits on an eight-character name: the likeliest typo in the
291/// whole catalogue, since the field is a vector weight and nobody abbreviates
292/// on the first try. A tighter budget looks principled and misses it.
293fn nearest(typo: &str, candidates: impl Iterator<Item = &'static str>) -> Option<&'static str> {
294    let budget = 1 + typo.chars().count() / 4;
295    candidates
296        .map(|c| (edit_distance(typo, c), c))
297        .filter(|(d, _)| *d <= budget)
298        .min_by_key(|(d, _)| *d)
299        .map(|(_, c)| c)
300}
301
302/// Levenshtein distance over `char`s, two rows at a time.
303///
304/// Two `Vec<usize>` the width of the shorter name — setting names are a handful
305/// of characters, and this runs only when something is already wrong.
306fn edit_distance(a: &str, b: &str) -> usize {
307    let b: Vec<char> = b.chars().collect();
308    let mut prev: Vec<usize> = (0..=b.len()).collect();
309    let mut row = vec![0; b.len() + 1];
310    for (i, ca) in a.chars().enumerate() {
311        row[0] = i + 1;
312        for (j, cb) in b.iter().enumerate() {
313            let cost = usize::from(ca != *cb);
314            row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
315        }
316        core::mem::swap(&mut prev, &mut row);
317    }
318    prev[b.len()]
319}
320
321const CONFIG_PATH_PRECEDENCE: &[&str] = &[
322    "--config PATH",
323    "$PLUGMEM_CONFIG",
324    "platform default config path",
325    "built-in defaults",
326];
327
328const DOCS: &[SettingDoc] = &[
329    SettingDoc {
330        section: "database",
331        key: "path",
332        value_type: "path string",
333        default: "platform data directory/memory.plugmem",
334        example: "\"/var/lib/plugmem/memory.plugmem\"",
335        description: "Persistent database file; an explicit --db or open path and PLUGMEM_DB override it",
336        scope: SettingScope::Shared,
337    },
338    SettingDoc {
339        section: "workspace",
340        key: "dir",
341        value_type: "path string",
342        default: "unset (one database, no workspace)",
343        example: "\"/var/lib/plugmem/memories\"",
344        description: "Directory of named databases; unset means the single-database default",
345        scope: SettingScope::Shared,
346    },
347    SettingDoc {
348        section: "workspace",
349        key: "max_open",
350        value_type: "positive integer",
351        default: "16",
352        example: "16",
353        description: "Hard limit on open workspace databases; an inactive least-recently-used entry is closed, all-active returns Busy",
354        scope: SettingScope::Shared,
355    },
356    SettingDoc {
357        section: "workspace",
358        key: "idle_timeout_ms",
359        value_type: "non-negative integer",
360        default: "60000",
361        example: "60000",
362        description: "Close a workspace database unused this long, releasing its lock; 0 never closes",
363        scope: SettingScope::Shared,
364    },
365    SettingDoc {
366        section: "engine",
367        key: "dim",
368        value_type: "non-negative integer",
369        default: "0",
370        example: "768",
371        description: "Embedding dimension; 0 disables vector storage",
372        scope: SettingScope::Shared,
373    },
374    SettingDoc {
375        section: "engine",
376        key: "max_bytes",
377        value_type: "non-negative integer",
378        default: "2147483648",
379        example: "2147483648",
380        description: "Ceiling applied to each byte pool separately, not to their sum",
381        scope: SettingScope::Shared,
382    },
383    SettingDoc {
384        section: "engine",
385        key: "max_text",
386        value_type: "non-negative integer",
387        default: "4096",
388        example: "4096",
389        description: "Maximum fact text length in bytes",
390        scope: SettingScope::Shared,
391    },
392    SettingDoc {
393        section: "engine",
394        key: "max_blob",
395        value_type: "non-negative integer",
396        default: "65536",
397        example: "65536",
398        description: "Maximum single blob length in bytes",
399        scope: SettingScope::Shared,
400    },
401    SettingDoc {
402        section: "recall",
403        key: "bm25_k1",
404        value_type: "number > 0",
405        default: "1.2",
406        example: "1.2",
407        description: "BM25 term-frequency saturation: higher lets a repeated word keep counting",
408        scope: SettingScope::Shared,
409    },
410    SettingDoc {
411        section: "recall",
412        key: "bm25_b",
413        value_type: "number in [0, 1]",
414        default: "0.75",
415        example: "0.75",
416        description: "BM25 length normalisation: 0 ignores fact length, 1 penalises long facts fully",
417        scope: SettingScope::Shared,
418    },
419    SettingDoc {
420        section: "recall",
421        key: "rrf_k",
422        value_type: "integer >= 1",
423        default: "60",
424        example: "60",
425        description: "Reciprocal-rank-fusion constant: larger flattens the gap between rank 1 and rank 10",
426        scope: SettingScope::Shared,
427    },
428    SettingDoc {
429        section: "recall",
430        key: "w_bm25",
431        value_type: "number >= 0",
432        default: "1.0",
433        example: "1.0",
434        description: "Weight of the lexical source in the fused score; 0 switches it off",
435        scope: SettingScope::Shared,
436    },
437    SettingDoc {
438        section: "recall",
439        key: "w_vec",
440        value_type: "number >= 0",
441        default: "1.0",
442        example: "1.0",
443        description: "Weight of the vector source; 0 switches it off (and costs nothing when dim = 0)",
444        scope: SettingScope::Shared,
445    },
446    SettingDoc {
447        section: "recall",
448        key: "w_graph",
449        value_type: "number >= 0",
450        default: "1.0",
451        example: "1.0",
452        description: "Weight of the entity-graph source; 0 switches off relational expansion",
453        scope: SettingScope::Shared,
454    },
455    SettingDoc {
456        section: "recall",
457        key: "w_time",
458        value_type: "number >= 0",
459        default: "1.0",
460        example: "1.0",
461        description: "Weight of the temporal source (the recorded_at window); 0 switches it off",
462        scope: SettingScope::Shared,
463    },
464    SettingDoc {
465        section: "recall",
466        key: "w_recency",
467        value_type: "number >= 0",
468        default: "0.25",
469        example: "0.25",
470        description: "How much a fact's age discounts it, on top of the sources above",
471        scope: SettingScope::Shared,
472    },
473    SettingDoc {
474        section: "recall",
475        key: "half_life_days",
476        value_type: "integer >= 1",
477        default: "180",
478        example: "180",
479        description: "Age at which the recency discount has halved; larger keeps old facts competitive",
480        scope: SettingScope::Shared,
481    },
482    SettingDoc {
483        section: "recall",
484        key: "graph_depth",
485        value_type: "non-negative integer",
486        default: "2",
487        example: "2",
488        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",
489        scope: SettingScope::Shared,
490    },
491    SettingDoc {
492        section: "recall",
493        key: "graph_decay",
494        value_type: "number in (0, 1]",
495        default: "0.5",
496        example: "0.5",
497        description: "How much each extra hop discounts a fact reached through the graph",
498        scope: SettingScope::Shared,
499    },
500    SettingDoc {
501        section: "recall",
502        key: "hnsw_ef_search",
503        value_type: "integer >= 1",
504        default: "64",
505        example: "64",
506        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",
507        scope: SettingScope::Shared,
508    },
509    SettingDoc {
510        section: "recall",
511        key: "similar_cos",
512        value_type: "number in [0, 1]",
513        default: "0.85",
514        example: "0.85",
515        description: "Cosine above which remember reports an existing fact as possibly conflicting (it never revises on its own)",
516        scope: SettingScope::Shared,
517    },
518    SettingDoc {
519        section: "recall",
520        key: "similar_jaccard",
521        value_type: "number in [0, 1]",
522        default: "0.5",
523        example: "0.5",
524        description: "Token overlap above which remember reports a possible conflict, for memories with no vectors",
525        scope: SettingScope::Shared,
526    },
527    SettingDoc {
528        section: "index",
529        key: "hnsw_ef_construction",
530        value_type: "integer >= hnsw_m (16 by default)",
531        default: "200",
532        example: "200",
533        description: "Beam width while building the vector graph: higher builds a better index, slower",
534        scope: SettingScope::Shared,
535    },
536    SettingDoc {
537        section: "index",
538        key: "flat_to_hnsw",
539        value_type: "integer >= 1",
540        default: "24000",
541        example: "24000",
542        description: "Vector count at which maintenance stops scanning flat and builds the HNSW graph",
543        scope: SettingScope::Shared,
544    },
545    SettingDoc {
546        section: "embedder",
547        key: "enabled",
548        value_type: "boolean",
549        default: "automatic",
550        example: "true",
551        description: "Enable or disable creation and use of the configured OpenAI-compatible embedder",
552        scope: SettingScope::Shared,
553    },
554    SettingDoc {
555        section: "embedder",
556        key: "url",
557        value_type: "string",
558        default: "unset",
559        example: "\"http://localhost:11434/v1/embeddings\"",
560        description: "OpenAI-compatible /v1/embeddings endpoint",
561        scope: SettingScope::Shared,
562    },
563    SettingDoc {
564        section: "embedder",
565        key: "model",
566        value_type: "string",
567        default: "unset",
568        example: "\"nomic-embed-text\"",
569        description: "Embedding model name",
570        scope: SettingScope::Shared,
571    },
572    SettingDoc {
573        section: "embedder",
574        key: "space_id",
575        value_type: "string",
576        default: "model",
577        example: "\"nomic-embed-text@v1\"",
578        description: "Stable semantic-space identity; change it only for incompatible vectors and reembed explicitly",
579        scope: SettingScope::Shared,
580    },
581    SettingDoc {
582        section: "embedder",
583        key: "api_key_env",
584        value_type: "string",
585        default: "unset",
586        example: "\"OPENAI_API_KEY\"",
587        description: "Environment variable containing the bearer token",
588        scope: SettingScope::Shared,
589    },
590    SettingDoc {
591        section: "embedder",
592        key: "on_error",
593        value_type: "\"fail\" | \"degrade\"",
594        default: "fail",
595        example: "\"fail\"",
596        description: "Unreachable provider: fail the verb, or store/answer without a vector and suspend the embedder",
597        scope: SettingScope::Shared,
598    },
599    SettingDoc {
600        section: "embedder",
601        key: "timeout_ms",
602        value_type: "non-negative integer",
603        default: "10000",
604        example: "10000",
605        description: "Deadline for one embeddings request end to end; 0 waits indefinitely",
606        scope: SettingScope::Shared,
607    },
608    SettingDoc {
609        section: "embedder",
610        key: "retry_after_ms",
611        value_type: "non-negative integer",
612        default: "unset (1s doubling to retry_max_ms)",
613        example: "0",
614        description: "Fixed wait before a suspended embedder is called again; 0 waits for an explicit resume",
615        scope: SettingScope::Shared,
616    },
617    SettingDoc {
618        section: "embedder",
619        key: "retry_max_ms",
620        value_type: "non-negative integer",
621        default: "60000",
622        example: "60000",
623        description: "Ceiling the default doubling retry grows to; ignored when retry_after_ms is set",
624        scope: SettingScope::Shared,
625    },
626    SettingDoc {
627        section: "maintenance",
628        key: "snapshot_every_ops",
629        value_type: "non-negative integer",
630        default: "1024",
631        example: "1024",
632        description: "Snapshot after this many mutations",
633        scope: SettingScope::Shared,
634    },
635    SettingDoc {
636        section: "maintenance",
637        key: "snapshot_journal_bytes",
638        value_type: "non-negative integer",
639        default: "4194304",
640        example: "4194304",
641        description: "Snapshot when the journal reaches this size",
642        scope: SettingScope::Shared,
643    },
644    SettingDoc {
645        section: "maintenance",
646        key: "maintain_every_forgets",
647        value_type: "non-negative integer",
648        default: "off",
649        example: "100",
650        description: "Run policy maintenance after this many forgets",
651        scope: SettingScope::Shared,
652    },
653    SettingDoc {
654        section: "maintenance",
655        key: "fsync",
656        value_type: "\"each_op\" | \"on_snapshot\"",
657        default: "each_op",
658        example: "\"each_op\"",
659        description: "When journal appends reach the disk. \"each_op\": every acknowledged write \
660survives a power cut. \"on_snapshot\": faster, an OS crash may lose the journal tail since the \
661last snapshot",
662        scope: SettingScope::Shared,
663    },
664    SettingDoc {
665        section: "maintenance",
666        key: "batch_size",
667        value_type: "positive integer",
668        default: "128",
669        example: "128",
670        description: "CLI import facts per embedding request and journal fsync",
671        scope: SettingScope::Cli,
672    },
673    SettingDoc {
674        section: "server",
675        key: "workers",
676        value_type: "positive integer",
677        default: "half of available cores",
678        example: "4",
679        description: "MCP worker threads",
680        scope: SettingScope::Mcp,
681    },
682];
683
684static SETTINGS_HELP: SettingsHelp = SettingsHelp {
685    docs: DOCS,
686    config_path_precedence: CONFIG_PATH_PRECEDENCE,
687};
688
689/// Returns the shared settings catalogue used by host, CLI, MCP and NAPI.
690pub const fn settings_help() -> &'static SettingsHelp {
691    &SETTINGS_HELP
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[test]
699    fn edit_distance_holds_at_the_degenerate_ends() {
700        // The empty string against anything is that thing's length, from both
701        // sides: with `a` empty the inner loop never runs and the seeded first
702        // row is the answer; with `b` empty the table is one column wide and
703        // only `row[0]` ever moves. Both are easy to get wrong by one.
704        assert_eq!(edit_distance("", ""), 0);
705        assert_eq!(edit_distance("", "dim"), 3);
706        assert_eq!(edit_distance("dim", ""), 3);
707
708        // One character each way, same and different.
709        assert_eq!(edit_distance("a", "a"), 0);
710        assert_eq!(edit_distance("a", "b"), 1);
711        assert_eq!(edit_distance("a", ""), 1);
712        assert_eq!(edit_distance(" ", ""), 1);
713        assert_eq!(edit_distance(" ", "a"), 1);
714
715        // The three edits, each in isolation.
716        assert_eq!(edit_distance("dim", "dm"), 1, "deletion");
717        assert_eq!(edit_distance("dim", "diim"), 1, "insertion");
718        assert_eq!(edit_distance("dim", "dir"), 1, "substitution");
719
720        // Counted in characters, not bytes: a multi-byte name must not read as
721        // several edits away from itself.
722        assert_eq!(edit_distance("ключ", "ключ"), 0);
723        assert_eq!(edit_distance("ключ", "клуч"), 1);
724        assert_eq!(edit_distance("ключ", ""), 4);
725
726        // Symmetric, which a two-row implementation can quietly break.
727        for (a, b) in [("dim", "max_text"), ("", "fsync"), ("a", "workers")] {
728            assert_eq!(edit_distance(a, b), edit_distance(b, a), "{a} vs {b}");
729        }
730    }
731
732    #[test]
733    fn a_suggestion_is_offered_only_when_it_is_worth_offering() {
734        let engine = || settings_help().keys_in("engine");
735
736        // Close enough to be the obvious intent.
737        assert_eq!(nearest("dm", engine()), Some("dim"));
738        assert_eq!(nearest("max_txt", engine()), Some("max_text"));
739
740        // The one the budget exists for: three edits on an eight-character
741        // name, and the likeliest typo in the catalogue.
742        let recall = || settings_help().keys_in("recall");
743        assert_eq!(nearest("w_vector", recall()), Some("w_vec"));
744        assert_eq!(nearest("similar_cosine", recall()), Some("similar_cos"));
745
746        // A truncation is not chased. `half_life` is five edits from
747        // `half_life_days`, and widening the budget far enough to reach it
748        // would start matching keys that share a prefix and nothing else.
749        // The warning still names the key; only the guess is withheld.
750        assert_eq!(nearest("half_life", recall()), None);
751
752        // Not close to anything: silence beats sending someone to the wrong
753        // line. A single character is the sharpest case — the budget floors at
754        // one edit, so it must not reach a three-character key.
755        assert_eq!(nearest("a", engine()), None);
756        assert_eq!(nearest("", engine()), None);
757        assert_eq!(nearest(" ", engine()), None);
758        assert_eq!(nearest("completely_unrelated", engine()), None);
759    }
760
761    /// A config table from lines, so the fixtures indent with the code instead
762    /// of being pinned to the file's left margin.
763    fn toml_of(lines: &[&str]) -> toml::Table {
764        lines.join("\n").parse().expect("valid TOML fixture")
765    }
766
767    #[test]
768    fn unknown_sections_and_keys_are_reported_with_their_context() {
769        let table = toml_of(&[
770            "[engine]",
771            "dim = 8",
772            "max_txt = 10",
773            "",
774            "[embedder]",
775            "enabled = false",
776            "",
777            "[engin]",
778            "dim = 4",
779        ]);
780
781        let found = settings_help().unknown_in(&table);
782        // A misspelled key inside a real section, and a misspelled section.
783        assert_eq!(
784            found,
785            vec![
786                SettingWarning {
787                    section: String::new(),
788                    key: "engin".to_string(),
789                    did_you_mean: Some("engine"),
790                },
791                SettingWarning {
792                    section: "engine".to_string(),
793                    key: "max_txt".to_string(),
794                    did_you_mean: Some("max_text"),
795                },
796            ]
797        );
798        assert!(
799            found[0]
800                .to_string()
801                .contains("unknown config section [engin]")
802        );
803        assert!(found[1].to_string().contains("[engine].max_txt"));
804    }
805
806    #[test]
807    fn keys_a_wrapper_owns_are_not_warned_about() {
808        // The reason the catalogue is the authority and the parser's own lists
809        // are not: host parses neither of these, and warning about them would
810        // fire on every CLI and MCP run.
811        let table = toml_of(&[
812            "[maintenance]",
813            "batch_size = 256",
814            "",
815            "[server]",
816            "workers = 4",
817        ]);
818        assert_eq!(settings_help().unknown_in(&table), vec![]);
819    }
820
821    #[test]
822    fn a_clean_config_warns_about_nothing() {
823        let mut text = String::new();
824        let mut section = "";
825        for doc in DOCS {
826            if doc.section != section {
827                let _ = writeln!(text, "[{}]", doc.section);
828                section = doc.section;
829            }
830            // The value is irrelevant here: this scan checks names, and the
831            // parser owns types. Every documented key must pass it.
832            let _ = writeln!(text, "{} = 0", doc.key);
833        }
834        let table: toml::Table = text.parse().unwrap();
835        assert_eq!(
836            settings_help().unknown_in(&table),
837            vec![],
838            "the catalogue must accept everything it documents"
839        );
840    }
841
842    #[test]
843    fn every_documented_setting_has_a_complete_description() {
844        assert!(!DOCS.is_empty());
845        for doc in DOCS {
846            assert!(!doc.section.is_empty());
847            assert!(!doc.key.is_empty());
848            assert!(!doc.value_type.is_empty());
849            assert!(!doc.default.is_empty());
850            assert!(!doc.description.is_empty());
851            assert!(!doc.example.is_empty(), "{}.{}", doc.section, doc.key);
852        }
853    }
854
855    /// The committed example, read from the workspace root, with its line
856    /// endings normalised.
857    ///
858    /// The generator writes `\n`, but what a checkout puts on disk is git's
859    /// choice, not ours: with `core.autocrlf=true` — the Windows default —
860    /// every line arrives as `\r\n`, and a byte comparison would call a
861    /// perfectly current file stale. The test is about which keys and defaults
862    /// the file states, so it compares the text and not the platform.
863    fn committed_example() -> String {
864        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
865            .join("..")
866            .join("..")
867            .join("config.example.toml");
868        let text = std::fs::read_to_string(&path)
869            .unwrap_or_else(|e| panic!("reading {}: {e}", path.display()));
870        text.replace("\r\n", "\n")
871    }
872
873    #[test]
874    fn the_committed_config_example_matches_the_catalogue() {
875        // The gate the whole generated-file arrangement exists for: add a
876        // setting, forget the example, and CI says so here rather than a user
877        // finding a key nobody documented. The same idiom the Node binding's
878        // `index.d.ts` and the Python binding's `.pyi` are held to.
879        assert_eq!(
880            committed_example(),
881            settings_help().render_config_example(false),
882            "config.example.toml is stale — run \
883             `cargo run -p plugmem-host --bin config_example` and commit it"
884        );
885    }
886
887    #[test]
888    fn the_example_is_a_config_the_loader_accepts() {
889        // An example nobody can parse is worth less than no example. Every key
890        // active, through the real loader: an unknown key, a value of the wrong
891        // shape, or one the engine's own validation refuses all fail here — and
892        // the catalogue is where each of them would have come from.
893        let table: toml::Table = settings_help()
894            .render_config_example(true)
895            .parse()
896            .expect("the generated example must be valid TOML");
897        let settings = crate::Settings::from_table(Some(&table))
898            .expect("the generated example must load through the real settings loader");
899        assert_eq!(
900            settings.warnings,
901            vec![],
902            "the example names a key no surface claims"
903        );
904        // The values are the ones the catalogue advertises, not merely some
905        // parseable ones.
906        assert_eq!(settings.config.dim, 768);
907        assert_eq!(settings.embed_error_policy, crate::EmbedErrorPolicy::Fail);
908        assert!(settings.embedder.is_some());
909    }
910
911    #[test]
912    fn every_key_of_the_committed_example_is_commented_out() {
913        // A copied example that sets forty keys freezes today's defaults into
914        // somebody's config for good, so the committed file must be inert.
915        let table: toml::Table = committed_example()
916            .parse()
917            .expect("the committed example must be valid TOML");
918        for (section, entries) in &table {
919            assert!(
920                entries.as_table().is_some_and(toml::Table::is_empty),
921                "[{section}] has an active key; the example must be all comments"
922            );
923        }
924    }
925
926    #[test]
927    fn human_help_contains_every_documented_key() {
928        let rendered = settings_help().render_human();
929        for doc in DOCS {
930            assert!(
931                rendered.contains(doc.key),
932                "missing {}.{}",
933                doc.section,
934                doc.key
935            );
936        }
937    }
938}