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: "Workspace databases kept open at once; the least recently used is closed",
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: "kind",
447 value_type: "string",
448 default: "none",
449 description: "Embedding provider: none, ollama, openai, lmstudio, vllm or llamacpp",
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: "api_key_env",
471 value_type: "string",
472 default: "unset",
473 description: "Environment variable containing the bearer token",
474 scope: SettingScope::Shared,
475 },
476 SettingDoc {
477 section: "maintenance",
478 key: "snapshot_every_ops",
479 value_type: "non-negative integer",
480 default: "1024",
481 description: "Snapshot after this many mutations",
482 scope: SettingScope::Shared,
483 },
484 SettingDoc {
485 section: "maintenance",
486 key: "snapshot_journal_bytes",
487 value_type: "non-negative integer",
488 default: "4194304",
489 description: "Snapshot when the journal reaches this size",
490 scope: SettingScope::Shared,
491 },
492 SettingDoc {
493 section: "maintenance",
494 key: "maintain_every_forgets",
495 value_type: "non-negative integer",
496 default: "off",
497 description: "Run policy maintenance after this many forgets",
498 scope: SettingScope::Shared,
499 },
500 SettingDoc {
501 section: "maintenance",
502 key: "fsync",
503 value_type: "\"each_op\" | \"on_snapshot\"",
504 default: "each_op",
505 description: "When journal appends reach the disk. \"each_op\": every acknowledged write \
506survives a power cut. \"on_snapshot\": faster, an OS crash may lose the journal tail since the \
507last snapshot",
508 scope: SettingScope::Shared,
509 },
510 SettingDoc {
511 section: "maintenance",
512 key: "batch_size",
513 value_type: "positive integer",
514 default: "128",
515 description: "CLI import facts per embedding request and journal fsync",
516 scope: SettingScope::Cli,
517 },
518 SettingDoc {
519 section: "server",
520 key: "workers",
521 value_type: "positive integer",
522 default: "half of available cores",
523 description: "MCP worker threads",
524 scope: SettingScope::Mcp,
525 },
526];
527
528static SETTINGS_HELP: SettingsHelp = SettingsHelp {
529 docs: DOCS,
530 config_path_precedence: CONFIG_PATH_PRECEDENCE,
531};
532
533pub const fn settings_help() -> &'static SettingsHelp {
535 &SETTINGS_HELP
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn edit_distance_holds_at_the_degenerate_ends() {
544 assert_eq!(edit_distance("", ""), 0);
549 assert_eq!(edit_distance("", "dim"), 3);
550 assert_eq!(edit_distance("dim", ""), 3);
551
552 assert_eq!(edit_distance("a", "a"), 0);
554 assert_eq!(edit_distance("a", "b"), 1);
555 assert_eq!(edit_distance("a", ""), 1);
556 assert_eq!(edit_distance(" ", ""), 1);
557 assert_eq!(edit_distance(" ", "a"), 1);
558
559 assert_eq!(edit_distance("dim", "dm"), 1, "deletion");
561 assert_eq!(edit_distance("dim", "diim"), 1, "insertion");
562 assert_eq!(edit_distance("dim", "dir"), 1, "substitution");
563
564 assert_eq!(edit_distance("ключ", "ключ"), 0);
567 assert_eq!(edit_distance("ключ", "клуч"), 1);
568 assert_eq!(edit_distance("ключ", ""), 4);
569
570 for (a, b) in [("dim", "max_text"), ("", "fsync"), ("a", "workers")] {
572 assert_eq!(edit_distance(a, b), edit_distance(b, a), "{a} vs {b}");
573 }
574 }
575
576 #[test]
577 fn a_suggestion_is_offered_only_when_it_is_worth_offering() {
578 let engine = || settings_help().keys_in("engine");
579
580 assert_eq!(nearest("dm", engine()), Some("dim"));
582 assert_eq!(nearest("max_txt", engine()), Some("max_text"));
583
584 let recall = || settings_help().keys_in("recall");
587 assert_eq!(nearest("w_vector", recall()), Some("w_vec"));
588 assert_eq!(nearest("similar_cosine", recall()), Some("similar_cos"));
589
590 assert_eq!(nearest("half_life", recall()), None);
595
596 assert_eq!(nearest("a", engine()), None);
600 assert_eq!(nearest("", engine()), None);
601 assert_eq!(nearest(" ", engine()), None);
602 assert_eq!(nearest("completely_unrelated", engine()), None);
603 }
604
605 fn toml_of(lines: &[&str]) -> toml::Table {
608 lines.join("\n").parse().expect("valid TOML fixture")
609 }
610
611 #[test]
612 fn unknown_sections_and_keys_are_reported_with_their_context() {
613 let table = toml_of(&[
614 "[engine]",
615 "dim = 8",
616 "max_txt = 10",
617 "",
618 "[embedder]",
619 r#"kind = "none""#,
620 "",
621 "[engin]",
622 "dim = 4",
623 ]);
624
625 let found = settings_help().unknown_in(&table);
626 assert_eq!(
628 found,
629 vec![
630 SettingWarning {
631 section: String::new(),
632 key: "engin".to_string(),
633 did_you_mean: Some("engine"),
634 },
635 SettingWarning {
636 section: "engine".to_string(),
637 key: "max_txt".to_string(),
638 did_you_mean: Some("max_text"),
639 },
640 ]
641 );
642 assert!(
643 found[0]
644 .to_string()
645 .contains("unknown config section [engin]")
646 );
647 assert!(found[1].to_string().contains("[engine].max_txt"));
648 }
649
650 #[test]
651 fn keys_a_wrapper_owns_are_not_warned_about() {
652 let table = toml_of(&[
656 "[maintenance]",
657 "batch_size = 256",
658 "",
659 "[server]",
660 "workers = 4",
661 ]);
662 assert_eq!(settings_help().unknown_in(&table), vec![]);
663 }
664
665 #[test]
666 fn a_clean_config_warns_about_nothing() {
667 let mut text = String::new();
668 let mut section = "";
669 for doc in DOCS {
670 if doc.section != section {
671 let _ = writeln!(text, "[{}]", doc.section);
672 section = doc.section;
673 }
674 let _ = writeln!(text, "{} = 0", doc.key);
677 }
678 let table: toml::Table = text.parse().unwrap();
679 assert_eq!(
680 settings_help().unknown_in(&table),
681 vec![],
682 "the catalogue must accept everything it documents"
683 );
684 }
685
686 #[test]
687 fn every_documented_setting_has_a_complete_description() {
688 assert!(!DOCS.is_empty());
689 for doc in DOCS {
690 assert!(!doc.section.is_empty());
691 assert!(!doc.key.is_empty());
692 assert!(!doc.value_type.is_empty());
693 assert!(!doc.default.is_empty());
694 assert!(!doc.description.is_empty());
695 }
696 }
697
698 #[test]
699 fn human_help_contains_every_documented_key() {
700 let rendered = settings_help().render_human();
701 for doc in DOCS {
702 assert!(
703 rendered.contains(doc.key),
704 "missing {}.{}",
705 doc.section,
706 doc.key
707 );
708 }
709 }
710}