1use std::fmt::Write as _;
9
10const PLATFORM_DEFAULT_SOURCE: &str = "platform default config path";
11
12#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct SettingWarning {
21 pub section: String,
24 pub key: String,
26 pub did_you_mean: Option<&'static str>,
28}
29
30impl std::fmt::Display for SettingWarning {
31 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SettingScope {
48 Shared,
50 Cli,
52 Mcp,
54}
55
56impl SettingScope {
57 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub struct SettingDoc {
70 pub section: &'static str,
72 pub key: &'static str,
74 pub value_type: &'static str,
76 pub default: &'static str,
78 pub description: &'static str,
80 pub scope: SettingScope,
82}
83
84#[derive(Clone, Copy, Debug)]
86pub struct SettingsHelp {
87 docs: &'static [SettingDoc],
88 config_path_precedence: &'static [&'static str],
89}
90
91impl SettingsHelp {
92 pub const fn docs(self) -> &'static [SettingDoc] {
94 self.docs
95 }
96
97 pub const fn config_path_precedence(self) -> &'static [&'static str] {
99 self.config_path_precedence
100 }
101
102 fn sections(self) -> impl Iterator<Item = &'static str> {
107 self.docs
108 .iter()
109 .enumerate()
110 .filter(|(i, doc)| *i == 0 || self.docs[i - 1].section != doc.section)
111 .map(|(_, doc)| doc.section)
112 }
113
114 fn keys_in(self, section: &str) -> impl Iterator<Item = &'static str> {
116 self.docs
117 .iter()
118 .filter(move |doc| doc.section == section)
119 .map(|doc| doc.key)
120 }
121
122 pub fn unknown_in(self, table: &toml::Table) -> Vec<SettingWarning> {
132 let mut out = Vec::new();
133 for (name, value) in table {
134 let Some(section) = self.sections().find(|s| s == name) else {
135 out.push(SettingWarning {
136 section: String::new(),
137 key: name.clone(),
138 did_you_mean: nearest(name, self.sections()),
139 });
140 continue;
141 };
142 let Some(entries) = value.as_table() else {
145 continue;
146 };
147 for key in entries.keys() {
148 if self.keys_in(section).any(|k| k == key) {
149 continue;
150 }
151 out.push(SettingWarning {
152 section: section.to_string(),
153 key: key.clone(),
154 did_you_mean: nearest(key, self.keys_in(section)),
155 });
156 }
157 }
158 out
159 }
160
161 pub fn render_human(self) -> String {
163 let mut output = String::from("plugmem settings\n\n");
164 output.push_str("Config file precedence:\n");
165 for (index, source) in self.config_path_precedence.iter().enumerate() {
166 if *source == PLATFORM_DEFAULT_SOURCE {
167 match crate::default_config_path() {
168 Some(path) => {
169 let _ = writeln!(output, " {}. {}", index + 1, path.display());
170 }
171 None => {
172 let _ = writeln!(output, " {}. {source} (unavailable)", index + 1);
173 }
174 }
175 } else {
176 let _ = writeln!(output, " {}. {source}", index + 1);
177 }
178 }
179 output.push('\n');
180
181 let mut section = None;
182 for doc in self.docs {
183 if section != Some(doc.section) {
184 if section.is_some() {
185 output.push('\n');
186 }
187 let _ = writeln!(output, "[{}]", doc.section);
188 section = Some(doc.section);
189 }
190 let _ = writeln!(
191 output,
192 " {} ({}, default: {}) — {} [{}]",
193 doc.key,
194 doc.value_type,
195 doc.default,
196 doc.description,
197 doc.scope.as_str()
198 );
199 }
200
201 output
202 }
203}
204
205fn nearest(typo: &str, candidates: impl Iterator<Item = &'static str>) -> Option<&'static str> {
217 let budget = 1 + typo.chars().count() / 4;
218 candidates
219 .map(|c| (edit_distance(typo, c), c))
220 .filter(|(d, _)| *d <= budget)
221 .min_by_key(|(d, _)| *d)
222 .map(|(_, c)| c)
223}
224
225fn edit_distance(a: &str, b: &str) -> usize {
230 let b: Vec<char> = b.chars().collect();
231 let mut prev: Vec<usize> = (0..=b.len()).collect();
232 let mut row = vec![0; b.len() + 1];
233 for (i, ca) in a.chars().enumerate() {
234 row[0] = i + 1;
235 for (j, cb) in b.iter().enumerate() {
236 let cost = usize::from(ca != *cb);
237 row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
238 }
239 core::mem::swap(&mut prev, &mut row);
240 }
241 prev[b.len()]
242}
243
244const CONFIG_PATH_PRECEDENCE: &[&str] = &[
245 "--config PATH",
246 "$PLUGMEM_CONFIG",
247 "platform default config path",
248 "built-in defaults",
249];
250
251const DOCS: &[SettingDoc] = &[
252 SettingDoc {
253 section: "database",
254 key: "path",
255 value_type: "path string",
256 default: "platform data directory/memory.plugmem",
257 description: "Persistent database file; an explicit --db or open path and PLUGMEM_DB override it",
258 scope: SettingScope::Shared,
259 },
260 SettingDoc {
261 section: "workspace",
262 key: "dir",
263 value_type: "path string",
264 default: "unset (one database, no workspace)",
265 description: "Directory of named databases; unset means the single-database default",
266 scope: SettingScope::Shared,
267 },
268 SettingDoc {
269 section: "workspace",
270 key: "max_open",
271 value_type: "positive integer",
272 default: "16",
273 description: "Hard limit on open workspace databases; an inactive least-recently-used entry is closed, all-active returns Busy",
274 scope: SettingScope::Shared,
275 },
276 SettingDoc {
277 section: "workspace",
278 key: "idle_timeout_ms",
279 value_type: "non-negative integer",
280 default: "60000",
281 description: "Close a workspace database unused this long, releasing its lock; 0 never closes",
282 scope: SettingScope::Shared,
283 },
284 SettingDoc {
285 section: "engine",
286 key: "dim",
287 value_type: "non-negative integer",
288 default: "0",
289 description: "Embedding dimension; 0 disables vector storage",
290 scope: SettingScope::Shared,
291 },
292 SettingDoc {
293 section: "engine",
294 key: "max_bytes",
295 value_type: "non-negative integer",
296 default: "2147483648",
297 description: "Ceiling applied to each byte pool separately, not to their sum",
298 scope: SettingScope::Shared,
299 },
300 SettingDoc {
301 section: "engine",
302 key: "max_text",
303 value_type: "non-negative integer",
304 default: "4096",
305 description: "Maximum fact text length in bytes",
306 scope: SettingScope::Shared,
307 },
308 SettingDoc {
309 section: "engine",
310 key: "max_blob",
311 value_type: "non-negative integer",
312 default: "65536",
313 description: "Maximum single blob length in bytes",
314 scope: SettingScope::Shared,
315 },
316 SettingDoc {
317 section: "recall",
318 key: "bm25_k1",
319 value_type: "number > 0",
320 default: "1.2",
321 description: "BM25 term-frequency saturation: higher lets a repeated word keep counting",
322 scope: SettingScope::Shared,
323 },
324 SettingDoc {
325 section: "recall",
326 key: "bm25_b",
327 value_type: "number in [0, 1]",
328 default: "0.75",
329 description: "BM25 length normalisation: 0 ignores fact length, 1 penalises long facts fully",
330 scope: SettingScope::Shared,
331 },
332 SettingDoc {
333 section: "recall",
334 key: "rrf_k",
335 value_type: "integer >= 1",
336 default: "60",
337 description: "Reciprocal-rank-fusion constant: larger flattens the gap between rank 1 and rank 10",
338 scope: SettingScope::Shared,
339 },
340 SettingDoc {
341 section: "recall",
342 key: "w_bm25",
343 value_type: "number >= 0",
344 default: "1.0",
345 description: "Weight of the lexical source in the fused score; 0 switches it off",
346 scope: SettingScope::Shared,
347 },
348 SettingDoc {
349 section: "recall",
350 key: "w_vec",
351 value_type: "number >= 0",
352 default: "1.0",
353 description: "Weight of the vector source; 0 switches it off (and costs nothing when dim = 0)",
354 scope: SettingScope::Shared,
355 },
356 SettingDoc {
357 section: "recall",
358 key: "w_graph",
359 value_type: "number >= 0",
360 default: "1.0",
361 description: "Weight of the entity-graph source; 0 switches off relational expansion",
362 scope: SettingScope::Shared,
363 },
364 SettingDoc {
365 section: "recall",
366 key: "w_time",
367 value_type: "number >= 0",
368 default: "1.0",
369 description: "Weight of the temporal source (the recorded_at window); 0 switches it off",
370 scope: SettingScope::Shared,
371 },
372 SettingDoc {
373 section: "recall",
374 key: "w_recency",
375 value_type: "number >= 0",
376 default: "0.25",
377 description: "How much a fact's age discounts it, on top of the sources above",
378 scope: SettingScope::Shared,
379 },
380 SettingDoc {
381 section: "recall",
382 key: "half_life_days",
383 value_type: "integer >= 1",
384 default: "180",
385 description: "Age at which the recency discount has halved; larger keeps old facts competitive",
386 scope: SettingScope::Shared,
387 },
388 SettingDoc {
389 section: "recall",
390 key: "graph_depth",
391 value_type: "non-negative integer",
392 default: "2",
393 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",
394 scope: SettingScope::Shared,
395 },
396 SettingDoc {
397 section: "recall",
398 key: "graph_decay",
399 value_type: "number in (0, 1]",
400 default: "0.5",
401 description: "How much each extra hop discounts a fact reached through the graph",
402 scope: SettingScope::Shared,
403 },
404 SettingDoc {
405 section: "recall",
406 key: "hnsw_ef_search",
407 value_type: "integer >= 1",
408 default: "64",
409 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",
410 scope: SettingScope::Shared,
411 },
412 SettingDoc {
413 section: "recall",
414 key: "similar_cos",
415 value_type: "number in [0, 1]",
416 default: "0.85",
417 description: "Cosine above which remember reports an existing fact as possibly conflicting (it never revises on its own)",
418 scope: SettingScope::Shared,
419 },
420 SettingDoc {
421 section: "recall",
422 key: "similar_jaccard",
423 value_type: "number in [0, 1]",
424 default: "0.5",
425 description: "Token overlap above which remember reports a possible conflict, for memories with no vectors",
426 scope: SettingScope::Shared,
427 },
428 SettingDoc {
429 section: "index",
430 key: "hnsw_ef_construction",
431 value_type: "integer >= hnsw_m (16 by default)",
432 default: "200",
433 description: "Beam width while building the vector graph: higher builds a better index, slower",
434 scope: SettingScope::Shared,
435 },
436 SettingDoc {
437 section: "index",
438 key: "flat_to_hnsw",
439 value_type: "integer >= 1",
440 default: "24000",
441 description: "Vector count at which maintenance stops scanning flat and builds the HNSW graph",
442 scope: SettingScope::Shared,
443 },
444 SettingDoc {
445 section: "embedder",
446 key: "enabled",
447 value_type: "boolean",
448 default: "automatic",
449 description: "Enable or disable creation and use of the configured OpenAI-compatible embedder",
450 scope: SettingScope::Shared,
451 },
452 SettingDoc {
453 section: "embedder",
454 key: "url",
455 value_type: "string",
456 default: "unset",
457 description: "OpenAI-compatible /v1/embeddings endpoint",
458 scope: SettingScope::Shared,
459 },
460 SettingDoc {
461 section: "embedder",
462 key: "model",
463 value_type: "string",
464 default: "unset",
465 description: "Embedding model name",
466 scope: SettingScope::Shared,
467 },
468 SettingDoc {
469 section: "embedder",
470 key: "space_id",
471 value_type: "string",
472 default: "model",
473 description: "Stable semantic-space identity; change it only for incompatible vectors and reembed explicitly",
474 scope: SettingScope::Shared,
475 },
476 SettingDoc {
477 section: "embedder",
478 key: "api_key_env",
479 value_type: "string",
480 default: "unset",
481 description: "Environment variable containing the bearer token",
482 scope: SettingScope::Shared,
483 },
484 SettingDoc {
485 section: "maintenance",
486 key: "snapshot_every_ops",
487 value_type: "non-negative integer",
488 default: "1024",
489 description: "Snapshot after this many mutations",
490 scope: SettingScope::Shared,
491 },
492 SettingDoc {
493 section: "maintenance",
494 key: "snapshot_journal_bytes",
495 value_type: "non-negative integer",
496 default: "4194304",
497 description: "Snapshot when the journal reaches this size",
498 scope: SettingScope::Shared,
499 },
500 SettingDoc {
501 section: "maintenance",
502 key: "maintain_every_forgets",
503 value_type: "non-negative integer",
504 default: "off",
505 description: "Run policy maintenance after this many forgets",
506 scope: SettingScope::Shared,
507 },
508 SettingDoc {
509 section: "maintenance",
510 key: "fsync",
511 value_type: "\"each_op\" | \"on_snapshot\"",
512 default: "each_op",
513 description: "When journal appends reach the disk. \"each_op\": every acknowledged write \
514survives a power cut. \"on_snapshot\": faster, an OS crash may lose the journal tail since the \
515last snapshot",
516 scope: SettingScope::Shared,
517 },
518 SettingDoc {
519 section: "maintenance",
520 key: "batch_size",
521 value_type: "positive integer",
522 default: "128",
523 description: "CLI import facts per embedding request and journal fsync",
524 scope: SettingScope::Cli,
525 },
526 SettingDoc {
527 section: "server",
528 key: "workers",
529 value_type: "positive integer",
530 default: "half of available cores",
531 description: "MCP worker threads",
532 scope: SettingScope::Mcp,
533 },
534];
535
536static SETTINGS_HELP: SettingsHelp = SettingsHelp {
537 docs: DOCS,
538 config_path_precedence: CONFIG_PATH_PRECEDENCE,
539};
540
541pub const fn settings_help() -> &'static SettingsHelp {
543 &SETTINGS_HELP
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn edit_distance_holds_at_the_degenerate_ends() {
552 assert_eq!(edit_distance("", ""), 0);
557 assert_eq!(edit_distance("", "dim"), 3);
558 assert_eq!(edit_distance("dim", ""), 3);
559
560 assert_eq!(edit_distance("a", "a"), 0);
562 assert_eq!(edit_distance("a", "b"), 1);
563 assert_eq!(edit_distance("a", ""), 1);
564 assert_eq!(edit_distance(" ", ""), 1);
565 assert_eq!(edit_distance(" ", "a"), 1);
566
567 assert_eq!(edit_distance("dim", "dm"), 1, "deletion");
569 assert_eq!(edit_distance("dim", "diim"), 1, "insertion");
570 assert_eq!(edit_distance("dim", "dir"), 1, "substitution");
571
572 assert_eq!(edit_distance("ключ", "ключ"), 0);
575 assert_eq!(edit_distance("ключ", "клуч"), 1);
576 assert_eq!(edit_distance("ключ", ""), 4);
577
578 for (a, b) in [("dim", "max_text"), ("", "fsync"), ("a", "workers")] {
580 assert_eq!(edit_distance(a, b), edit_distance(b, a), "{a} vs {b}");
581 }
582 }
583
584 #[test]
585 fn a_suggestion_is_offered_only_when_it_is_worth_offering() {
586 let engine = || settings_help().keys_in("engine");
587
588 assert_eq!(nearest("dm", engine()), Some("dim"));
590 assert_eq!(nearest("max_txt", engine()), Some("max_text"));
591
592 let recall = || settings_help().keys_in("recall");
595 assert_eq!(nearest("w_vector", recall()), Some("w_vec"));
596 assert_eq!(nearest("similar_cosine", recall()), Some("similar_cos"));
597
598 assert_eq!(nearest("half_life", recall()), None);
603
604 assert_eq!(nearest("a", engine()), None);
608 assert_eq!(nearest("", engine()), None);
609 assert_eq!(nearest(" ", engine()), None);
610 assert_eq!(nearest("completely_unrelated", engine()), None);
611 }
612
613 fn toml_of(lines: &[&str]) -> toml::Table {
616 lines.join("\n").parse().expect("valid TOML fixture")
617 }
618
619 #[test]
620 fn unknown_sections_and_keys_are_reported_with_their_context() {
621 let table = toml_of(&[
622 "[engine]",
623 "dim = 8",
624 "max_txt = 10",
625 "",
626 "[embedder]",
627 "enabled = false",
628 "",
629 "[engin]",
630 "dim = 4",
631 ]);
632
633 let found = settings_help().unknown_in(&table);
634 assert_eq!(
636 found,
637 vec![
638 SettingWarning {
639 section: String::new(),
640 key: "engin".to_string(),
641 did_you_mean: Some("engine"),
642 },
643 SettingWarning {
644 section: "engine".to_string(),
645 key: "max_txt".to_string(),
646 did_you_mean: Some("max_text"),
647 },
648 ]
649 );
650 assert!(
651 found[0]
652 .to_string()
653 .contains("unknown config section [engin]")
654 );
655 assert!(found[1].to_string().contains("[engine].max_txt"));
656 }
657
658 #[test]
659 fn keys_a_wrapper_owns_are_not_warned_about() {
660 let table = toml_of(&[
664 "[maintenance]",
665 "batch_size = 256",
666 "",
667 "[server]",
668 "workers = 4",
669 ]);
670 assert_eq!(settings_help().unknown_in(&table), vec![]);
671 }
672
673 #[test]
674 fn a_clean_config_warns_about_nothing() {
675 let mut text = String::new();
676 let mut section = "";
677 for doc in DOCS {
678 if doc.section != section {
679 let _ = writeln!(text, "[{}]", doc.section);
680 section = doc.section;
681 }
682 let _ = writeln!(text, "{} = 0", doc.key);
685 }
686 let table: toml::Table = text.parse().unwrap();
687 assert_eq!(
688 settings_help().unknown_in(&table),
689 vec![],
690 "the catalogue must accept everything it documents"
691 );
692 }
693
694 #[test]
695 fn every_documented_setting_has_a_complete_description() {
696 assert!(!DOCS.is_empty());
697 for doc in DOCS {
698 assert!(!doc.section.is_empty());
699 assert!(!doc.key.is_empty());
700 assert!(!doc.value_type.is_empty());
701 assert!(!doc.default.is_empty());
702 assert!(!doc.description.is_empty());
703 }
704 }
705
706 #[test]
707 fn human_help_contains_every_documented_key() {
708 let rendered = settings_help().render_human();
709 for doc in DOCS {
710 assert!(
711 rendered.contains(doc.key),
712 "missing {}.{}",
713 doc.section,
714 doc.key
715 );
716 }
717 }
718}