Skip to main content

lean_ctx/core/config/
schema.rs

1//! Auto-generated config schema from `Config` struct metadata.
2//!
3//! Used by `lean-ctx config schema` to emit JSON and by
4//! `lean-ctx config validate` to check user config.toml files.
5
6use serde::Serialize;
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct ConfigSchema {
11    pub version: u32,
12    pub sections: BTreeMap<String, SectionSchema>,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct SectionSchema {
17    pub description: String,
18    pub keys: BTreeMap<String, KeySchema>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct KeySchema {
23    #[serde(rename = "type")]
24    pub ty: String,
25    pub default: serde_json::Value,
26    pub description: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub values: Option<Vec<String>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub env_override: Option<String>,
31}
32
33fn clean_f32(v: f32) -> serde_json::Value {
34    let clean: f64 = format!("{v}").parse().unwrap_or(v as f64);
35    serde_json::json!(clean)
36}
37
38fn key(ty: &str, default: serde_json::Value, desc: &str) -> KeySchema {
39    KeySchema {
40        ty: ty.to_string(),
41        default,
42        description: desc.to_string(),
43        values: None,
44        env_override: None,
45    }
46}
47
48fn key_enum(values: &[&str], default: &str, desc: &str) -> KeySchema {
49    KeySchema {
50        ty: "enum".to_string(),
51        default: serde_json::Value::String(default.to_string()),
52        description: desc.to_string(),
53        values: Some(values.iter().map(ToString::to_string).collect()),
54        env_override: None,
55    }
56}
57
58fn key_with_env(ty: &str, default: serde_json::Value, desc: &str, env: &str) -> KeySchema {
59    KeySchema {
60        ty: ty.to_string(),
61        default,
62        description: desc.to_string(),
63        values: None,
64        env_override: Some(env.to_string()),
65    }
66}
67
68fn key_enum_with_env(values: &[&str], default: &str, desc: &str, env: &str) -> KeySchema {
69    KeySchema {
70        ty: "enum".to_string(),
71        default: serde_json::Value::String(default.to_string()),
72        description: desc.to_string(),
73        values: Some(values.iter().map(ToString::to_string).collect()),
74        env_override: Some(env.to_string()),
75    }
76}
77
78impl ConfigSchema {
79    pub fn generate() -> Self {
80        let cfg = super::Config::default();
81        let mut sections = BTreeMap::new();
82
83        let mut root = BTreeMap::new();
84        root.insert(
85            "ultra_compact".into(),
86            key(
87                "bool",
88                serde_json::json!(false),
89                "Legacy flag for maximum compression (use compression_level instead)",
90            ),
91        );
92        root.insert(
93            "tee_mode".into(),
94            key_enum(
95                &["never", "failures", "always"],
96                "failures",
97                "Controls when shell output is tee'd to disk for later retrieval",
98            ),
99        );
100        root.insert(
101            "output_density".into(),
102            key_enum_with_env(
103                &["normal", "terse", "ultra"],
104                "normal",
105                "Controls how dense/compact MCP tool output is formatted",
106                "LEAN_CTX_OUTPUT_DENSITY",
107            ),
108        );
109        root.insert(
110            "checkpoint_interval".into(),
111            key(
112                "u32",
113                serde_json::json!(cfg.checkpoint_interval),
114                "Session checkpoint interval in minutes",
115            ),
116        );
117        root.insert(
118            "excluded_commands".into(),
119            key(
120                "string[]",
121                serde_json::json!(cfg.excluded_commands),
122                "Commands to exclude from shell hook interception",
123            ),
124        );
125        root.insert(
126            "passthrough_urls".into(),
127            key(
128                "string[]",
129                serde_json::json!(cfg.passthrough_urls),
130                "URLs to pass through without proxy interception",
131            ),
132        );
133        root.insert("slow_command_threshold_ms".into(), key("u64", serde_json::json!(cfg.slow_command_threshold_ms), "Commands taking longer than this (ms) are recorded in the slow log. Set to 0 to disable"));
134        root.insert(
135            "theme".into(),
136            key(
137                "string",
138                serde_json::json!(cfg.theme),
139                "Dashboard color theme",
140            ),
141        );
142        root.insert(
143            "buddy_enabled".into(),
144            key(
145                "bool",
146                serde_json::json!(cfg.buddy_enabled),
147                "Enable the buddy system for multi-agent coordination",
148            ),
149        );
150        root.insert(
151            "enable_wakeup_ctx".into(),
152            key(
153                "bool",
154                serde_json::json!(cfg.enable_wakeup_ctx),
155                "Append wakeup briefing (facts, session summary) to ctx_overview output. Set false to reduce context bloat when calling ctx_overview frequently.",
156            ),
157        );
158        root.insert(
159            "redirect_exclude".into(),
160            key(
161                "string[]",
162                serde_json::json!(cfg.redirect_exclude),
163                "URL patterns to exclude from proxy redirection",
164            ),
165        );
166        root.insert(
167            "disabled_tools".into(),
168            key(
169                "string[]",
170                serde_json::json!(cfg.disabled_tools),
171                "Tools to exclude from the MCP tool list",
172            ),
173        );
174        root.insert(
175            "rules_scope".into(),
176            key_enum(
177                &["both", "global", "project"],
178                "both",
179                "Where agent rule files are installed. Override via LEAN_CTX_RULES_SCOPE",
180            ),
181        );
182        root.insert(
183            "extra_ignore_patterns".into(),
184            key(
185                "string[]",
186                serde_json::json!(cfg.extra_ignore_patterns),
187                "Extra glob patterns to ignore in graph/overview/preload",
188            ),
189        );
190        root.insert(
191            "terse_agent".into(),
192            key_enum_with_env(
193                &["off", "lite", "full", "ultra"],
194                "off",
195                "Controls agent output verbosity via instructions injection",
196                "LEAN_CTX_TERSE_AGENT",
197            ),
198        );
199        root.insert(
200            "compression_level".into(),
201            key_enum_with_env(
202                &["off", "lite", "standard", "max"],
203                "off",
204                "Unified compression level for all output",
205                "LEAN_CTX_COMPRESSION",
206            ),
207        );
208        root.insert(
209            "allow_paths".into(),
210            key_with_env(
211                "string[]",
212                serde_json::json!(cfg.allow_paths),
213                "Additional paths allowed by PathJail (absolute)",
214                "LEAN_CTX_ALLOW_PATH",
215            ),
216        );
217        root.insert(
218            "content_defined_chunking".into(),
219            key(
220                "bool",
221                serde_json::json!(false),
222                "Enable Rabin-Karp chunking for cache-optimal output ordering",
223            ),
224        );
225        root.insert(
226            "minimal_overhead".into(),
227            key_with_env(
228                "bool",
229                serde_json::json!(false),
230                "Skip session/knowledge/gotcha blocks in MCP instructions",
231                "LEAN_CTX_MINIMAL",
232            ),
233        );
234        root.insert(
235            "shell_hook_disabled".into(),
236            key_with_env(
237                "bool",
238                serde_json::json!(false),
239                "Disable shell hook injection",
240                "LEAN_CTX_NO_HOOK",
241            ),
242        );
243        root.insert(
244            "shell_activation".into(),
245            key_enum_with_env(
246                &["always", "agents-only", "off"],
247                "always",
248                "Controls when the shell hook auto-activates aliases",
249                "LEAN_CTX_SHELL_ACTIVATION",
250            ),
251        );
252        root.insert(
253            "update_check_disabled".into(),
254            key_with_env(
255                "bool",
256                serde_json::json!(false),
257                "Disable the daily version check",
258                "LEAN_CTX_NO_UPDATE_CHECK",
259            ),
260        );
261        root.insert(
262            "bm25_max_cache_mb".into(),
263            key_with_env(
264                "u64",
265                serde_json::json!(cfg.bm25_max_cache_mb),
266                "Maximum BM25 cache file size in MB",
267                "LEAN_CTX_BM25_MAX_CACHE_MB",
268            ),
269        );
270        root.insert(
271            "graph_index_max_files".into(),
272            key(
273                "u64",
274                serde_json::json!(cfg.graph_index_max_files),
275                "Maximum files in graph index. 0 = unlimited (default). Set >0 to cap for constrained systems",
276            ),
277        );
278        root.insert(
279            "memory_profile".into(),
280            key_enum_with_env(
281                &["low", "balanced", "performance"],
282                "balanced",
283                "Controls RAM vs feature trade-off",
284                "LEAN_CTX_MEMORY_PROFILE",
285            ),
286        );
287        root.insert(
288            "memory_cleanup".into(),
289            key_enum_with_env(
290                &["aggressive", "shared"],
291                "aggressive",
292                "Controls how aggressively memory is freed when idle",
293                "LEAN_CTX_MEMORY_CLEANUP",
294            ),
295        );
296        root.insert(
297            "savings_footer".into(),
298            key_enum_with_env(
299                &["auto", "always", "never"],
300                "never",
301                "Controls visibility of token savings footers: never (default, suppress everywhere), always, auto (context-dependent)",
302                "LEAN_CTX_SAVINGS_FOOTER",
303            ),
304        );
305        root.insert(
306            "max_ram_percent".into(),
307            key_with_env(
308                "u8",
309                serde_json::json!(cfg.max_ram_percent),
310                "Maximum percentage of system RAM that lean-ctx may use (1-50, default 5)",
311                "LEAN_CTX_MAX_RAM_PERCENT",
312            ),
313        );
314        root.insert(
315            "project_root".into(),
316            key_with_env(
317                "string?",
318                serde_json::json!(null),
319                "Explicit project root directory. Prevents accidental home-directory scans",
320                "LEAN_CTX_PROJECT_ROOT",
321            ),
322        );
323        sections.insert(
324            "root".into(),
325            SectionSchema {
326                description: "Top-level configuration keys".into(),
327                keys: root,
328            },
329        );
330
331        sections.insert(
332            "ide_paths".into(),
333            SectionSchema {
334                description: "Per-IDE allowed paths. Keys are agent names (cursor, codex, opencode, antigravity, etc.), values are arrays of paths to index for that agent".into(),
335                keys: BTreeMap::new(),
336            },
337        );
338
339        let mut lsp_keys = BTreeMap::new();
340        lsp_keys.insert(
341            "rust".into(),
342            key(
343                "string?",
344                serde_json::json!(null),
345                "Custom path to rust-analyzer binary",
346            ),
347        );
348        lsp_keys.insert(
349            "typescript".into(),
350            key(
351                "string?",
352                serde_json::json!(null),
353                "Custom path to typescript-language-server binary",
354            ),
355        );
356        lsp_keys.insert(
357            "python".into(),
358            key(
359                "string?",
360                serde_json::json!(null),
361                "Custom path to pylsp binary",
362            ),
363        );
364        lsp_keys.insert(
365            "go".into(),
366            key(
367                "string?",
368                serde_json::json!(null),
369                "Custom path to gopls binary",
370            ),
371        );
372        sections.insert(
373            "lsp".into(),
374            SectionSchema {
375                description: "LSP server binary overrides. Map language name to custom binary path"
376                    .into(),
377                keys: lsp_keys,
378            },
379        );
380
381        let mut archive = BTreeMap::new();
382        archive.insert(
383            "enabled".into(),
384            key(
385                "bool",
386                serde_json::json!(cfg.archive.enabled),
387                "Enable zero-loss compression archive",
388            ),
389        );
390        archive.insert(
391            "threshold_chars".into(),
392            key(
393                "usize",
394                serde_json::json!(cfg.archive.threshold_chars),
395                "Minimum output size (chars) to trigger archiving",
396            ),
397        );
398        archive.insert(
399            "max_age_hours".into(),
400            key(
401                "u64",
402                serde_json::json!(cfg.archive.max_age_hours),
403                "Maximum age of archived entries before cleanup",
404            ),
405        );
406        archive.insert(
407            "max_disk_mb".into(),
408            key(
409                "u64",
410                serde_json::json!(cfg.archive.max_disk_mb),
411                "Maximum total disk usage for the archive",
412            ),
413        );
414        sections.insert("archive".into(), SectionSchema {
415            description: "Settings for the zero-loss compression archive (large tool outputs saved to disk)".into(),
416            keys: archive,
417        });
418
419        let mut autonomy = BTreeMap::new();
420        autonomy.insert(
421            "enabled".into(),
422            key(
423                "bool",
424                serde_json::json!(cfg.autonomy.enabled),
425                "Enable autonomous background behaviors",
426            ),
427        );
428        autonomy.insert(
429            "auto_preload".into(),
430            key(
431                "bool",
432                serde_json::json!(cfg.autonomy.auto_preload),
433                "Auto-preload related files on first read",
434            ),
435        );
436        autonomy.insert(
437            "auto_dedup".into(),
438            key(
439                "bool",
440                serde_json::json!(cfg.autonomy.auto_dedup),
441                "Auto-deduplicate repeated reads",
442            ),
443        );
444        autonomy.insert(
445            "auto_related".into(),
446            key(
447                "bool",
448                serde_json::json!(cfg.autonomy.auto_related),
449                "Auto-load graph-related files",
450            ),
451        );
452        autonomy.insert(
453            "auto_consolidate".into(),
454            key(
455                "bool",
456                serde_json::json!(cfg.autonomy.auto_consolidate),
457                "Auto-consolidate knowledge periodically",
458            ),
459        );
460        autonomy.insert(
461            "silent_preload".into(),
462            key(
463                "bool",
464                serde_json::json!(cfg.autonomy.silent_preload),
465                "Suppress preload notifications in output",
466            ),
467        );
468        autonomy.insert(
469            "dedup_threshold".into(),
470            key(
471                "usize",
472                serde_json::json!(cfg.autonomy.dedup_threshold),
473                "Number of repeated reads before dedup triggers",
474            ),
475        );
476        autonomy.insert(
477            "consolidate_every_calls".into(),
478            key(
479                "u32",
480                serde_json::json!(cfg.autonomy.consolidate_every_calls),
481                "Consolidate knowledge every N tool calls",
482            ),
483        );
484        autonomy.insert(
485            "consolidate_cooldown_secs".into(),
486            key(
487                "u64",
488                serde_json::json!(cfg.autonomy.consolidate_cooldown_secs),
489                "Minimum seconds between consolidation runs",
490            ),
491        );
492        sections.insert(
493            "autonomy".into(),
494            SectionSchema {
495                description:
496                    "Controls autonomous background behaviors (preload, dedup, consolidation)"
497                        .into(),
498                keys: autonomy,
499            },
500        );
501
502        let mut loop_det = BTreeMap::new();
503        loop_det.insert(
504            "normal_threshold".into(),
505            key(
506                "u32",
507                serde_json::json!(cfg.loop_detection.normal_threshold),
508                "Repetitions before reducing output",
509            ),
510        );
511        loop_det.insert(
512            "reduced_threshold".into(),
513            key(
514                "u32",
515                serde_json::json!(cfg.loop_detection.reduced_threshold),
516                "Repetitions before further reducing output",
517            ),
518        );
519        loop_det.insert(
520            "blocked_threshold".into(),
521            key(
522                "u32",
523                serde_json::json!(cfg.loop_detection.blocked_threshold),
524                "Repetitions before blocking. 0 = disabled",
525            ),
526        );
527        loop_det.insert(
528            "window_secs".into(),
529            key(
530                "u64",
531                serde_json::json!(cfg.loop_detection.window_secs),
532                "Time window in seconds for loop detection",
533            ),
534        );
535        loop_det.insert(
536            "search_group_limit".into(),
537            key(
538                "u32",
539                serde_json::json!(cfg.loop_detection.search_group_limit),
540                "Maximum unique searches within a loop window",
541            ),
542        );
543        sections.insert(
544            "loop_detection".into(),
545            SectionSchema {
546                description: "Loop detection settings for preventing repeated identical tool calls"
547                    .into(),
548                keys: loop_det,
549            },
550        );
551
552        let mut cloud = BTreeMap::new();
553        cloud.insert(
554            "contribute_enabled".into(),
555            key(
556                "bool",
557                serde_json::json!(cfg.cloud.contribute_enabled),
558                "Enable contributing anonymized stats to lean-ctx cloud",
559            ),
560        );
561        sections.insert(
562            "cloud".into(),
563            SectionSchema {
564                description: "Cloud feature settings".into(),
565                keys: cloud,
566            },
567        );
568
569        let mut proxy = BTreeMap::new();
570        proxy.insert(
571            "anthropic_upstream".into(),
572            key(
573                "string?",
574                serde_json::json!(cfg.proxy.anthropic_upstream),
575                "Custom upstream URL for Anthropic API proxy",
576            ),
577        );
578        proxy.insert(
579            "openai_upstream".into(),
580            key(
581                "string?",
582                serde_json::json!(cfg.proxy.openai_upstream),
583                "Custom upstream URL for OpenAI API proxy",
584            ),
585        );
586        proxy.insert(
587            "gemini_upstream".into(),
588            key(
589                "string?",
590                serde_json::json!(cfg.proxy.gemini_upstream),
591                "Custom upstream URL for Gemini API proxy",
592            ),
593        );
594        sections.insert(
595            "proxy".into(),
596            SectionSchema {
597                description: "Proxy upstream configuration for API routing".into(),
598                keys: proxy,
599            },
600        );
601
602        let mem = &cfg.memory;
603        let mut mem_knowledge = BTreeMap::new();
604        mem_knowledge.insert(
605            "max_facts".into(),
606            key(
607                "usize",
608                serde_json::json!(mem.knowledge.max_facts),
609                "Maximum number of knowledge facts stored per project",
610            ),
611        );
612        mem_knowledge.insert(
613            "max_patterns".into(),
614            key(
615                "usize",
616                serde_json::json!(mem.knowledge.max_patterns),
617                "Maximum number of patterns stored",
618            ),
619        );
620        mem_knowledge.insert(
621            "max_history".into(),
622            key(
623                "usize",
624                serde_json::json!(mem.knowledge.max_history),
625                "Maximum history entries retained",
626            ),
627        );
628        mem_knowledge.insert(
629            "contradiction_threshold".into(),
630            key(
631                "f32",
632                clean_f32(mem.knowledge.contradiction_threshold),
633                "Confidence threshold for contradiction detection",
634            ),
635        );
636        mem_knowledge.insert(
637            "recall_facts_limit".into(),
638            key(
639                "usize",
640                serde_json::json!(mem.knowledge.recall_facts_limit),
641                "Maximum facts returned per recall query",
642            ),
643        );
644        mem_knowledge.insert(
645            "rooms_limit".into(),
646            key(
647                "usize",
648                serde_json::json!(mem.knowledge.rooms_limit),
649                "Maximum number of rooms returned",
650            ),
651        );
652        mem_knowledge.insert(
653            "timeline_limit".into(),
654            key(
655                "usize",
656                serde_json::json!(mem.knowledge.timeline_limit),
657                "Maximum number of timeline entries returned",
658            ),
659        );
660        mem_knowledge.insert(
661            "relations_limit".into(),
662            key(
663                "usize",
664                serde_json::json!(mem.knowledge.relations_limit),
665                "Maximum number of relations returned",
666            ),
667        );
668        sections.insert(
669            "memory.knowledge".into(),
670            SectionSchema {
671                description: "Knowledge memory budgets (facts, patterns, gotchas)".into(),
672                keys: mem_knowledge,
673            },
674        );
675
676        let mut mem_episodic = BTreeMap::new();
677        mem_episodic.insert(
678            "max_episodes".into(),
679            key(
680                "usize",
681                serde_json::json!(mem.episodic.max_episodes),
682                "Maximum number of episodes retained",
683            ),
684        );
685        mem_episodic.insert(
686            "max_actions_per_episode".into(),
687            key(
688                "usize",
689                serde_json::json!(mem.episodic.max_actions_per_episode),
690                "Maximum actions tracked per episode",
691            ),
692        );
693        mem_episodic.insert(
694            "summary_max_chars".into(),
695            key(
696                "usize",
697                serde_json::json!(mem.episodic.summary_max_chars),
698                "Maximum characters in episode summary",
699            ),
700        );
701        sections.insert(
702            "memory.episodic".into(),
703            SectionSchema {
704                description: "Episodic memory budgets (session episodes)".into(),
705                keys: mem_episodic,
706            },
707        );
708
709        let mut mem_procedural = BTreeMap::new();
710        mem_procedural.insert(
711            "max_procedures".into(),
712            key(
713                "usize",
714                serde_json::json!(mem.procedural.max_procedures),
715                "Maximum number of learned procedures stored",
716            ),
717        );
718        mem_procedural.insert(
719            "min_repetitions".into(),
720            key(
721                "usize",
722                serde_json::json!(mem.procedural.min_repetitions),
723                "Minimum repetitions before a pattern is stored",
724            ),
725        );
726        mem_procedural.insert(
727            "min_sequence_len".into(),
728            key(
729                "usize",
730                serde_json::json!(mem.procedural.min_sequence_len),
731                "Minimum sequence length for procedure detection",
732            ),
733        );
734        mem_procedural.insert(
735            "max_window_size".into(),
736            key(
737                "usize",
738                serde_json::json!(mem.procedural.max_window_size),
739                "Maximum window size for pattern analysis",
740            ),
741        );
742        sections.insert(
743            "memory.procedural".into(),
744            SectionSchema {
745                description: "Procedural memory budgets (learned patterns)".into(),
746                keys: mem_procedural,
747            },
748        );
749
750        let mut mem_lifecycle = BTreeMap::new();
751        mem_lifecycle.insert(
752            "decay_rate".into(),
753            key(
754                "f32",
755                clean_f32(mem.lifecycle.decay_rate),
756                "Rate at which knowledge confidence decays over time",
757            ),
758        );
759        mem_lifecycle.insert(
760            "low_confidence_threshold".into(),
761            key(
762                "f32",
763                clean_f32(mem.lifecycle.low_confidence_threshold),
764                "Threshold below which facts are considered low-confidence",
765            ),
766        );
767        mem_lifecycle.insert(
768            "stale_days".into(),
769            key(
770                "i64",
771                serde_json::json!(mem.lifecycle.stale_days),
772                "Days after which unused facts are considered stale",
773            ),
774        );
775        mem_lifecycle.insert(
776            "similarity_threshold".into(),
777            key(
778                "f32",
779                clean_f32(mem.lifecycle.similarity_threshold),
780                "Similarity threshold for deduplication",
781            ),
782        );
783        sections.insert(
784            "memory.lifecycle".into(),
785            SectionSchema {
786                description: "Knowledge lifecycle policy (decay, staleness, dedup)".into(),
787                keys: mem_lifecycle,
788            },
789        );
790
791        let mut mem_gotcha = BTreeMap::new();
792        mem_gotcha.insert(
793            "max_gotchas_per_project".into(),
794            key(
795                "usize",
796                serde_json::json!(mem.gotcha.max_gotchas_per_project),
797                "Maximum gotchas stored per project",
798            ),
799        );
800        mem_gotcha.insert(
801            "retrieval_budget_per_room".into(),
802            key(
803                "usize",
804                serde_json::json!(mem.gotcha.retrieval_budget_per_room),
805                "Maximum gotchas retrieved per room per query",
806            ),
807        );
808        mem_gotcha.insert(
809            "default_decay_rate".into(),
810            key(
811                "f32",
812                clean_f32(mem.gotcha.default_decay_rate),
813                "Default decay rate for gotcha importance",
814            ),
815        );
816        sections.insert(
817            "memory.gotcha".into(),
818            SectionSchema {
819                description: "Gotcha memory settings (project-specific warnings and pitfalls)"
820                    .into(),
821                keys: mem_gotcha,
822            },
823        );
824
825        let mut mem_embeddings = BTreeMap::new();
826        mem_embeddings.insert(
827            "max_facts".into(),
828            key(
829                "usize",
830                serde_json::json!(mem.embeddings.max_facts),
831                "Maximum number of embedding facts stored",
832            ),
833        );
834        sections.insert(
835            "memory.embeddings".into(),
836            SectionSchema {
837                description: "Embeddings memory settings for semantic search".into(),
838                keys: mem_embeddings,
839            },
840        );
841
842        let mut aliases = BTreeMap::new();
843        aliases.insert(
844            "command".into(),
845            key(
846                "string",
847                serde_json::json!(""),
848                "The command pattern to match (e.g. 'deploy')",
849            ),
850        );
851        aliases.insert(
852            "alias".into(),
853            key(
854                "string",
855                serde_json::json!(""),
856                "The alias definition to execute",
857            ),
858        );
859        sections.insert("custom_aliases".into(), SectionSchema {
860            description: "Custom command aliases (array of {command, alias} entries). Note: field names are 'command' and 'alias' (not 'name')".into(),
861            keys: aliases,
862        });
863
864        if let Some(root_section) = sections.get_mut("root") {
865            root_section.keys.insert(
866                "custom_aliases".into(),
867                key(
868                    "array",
869                    serde_json::json!([]),
870                    "Custom command aliases (array of {command, alias} entries)",
871                ),
872            );
873        }
874
875        ConfigSchema {
876            version: 1,
877            sections,
878        }
879    }
880
881    /// All known TOML keys (dot-separated) for validation.
882    pub fn known_keys(&self) -> Vec<String> {
883        let mut keys = Vec::new();
884        for (section, schema) in &self.sections {
885            if section == "root" {
886                for key_name in schema.keys.keys() {
887                    keys.push(key_name.clone());
888                }
889            } else {
890                if schema.keys.is_empty() {
891                    keys.push(section.clone());
892                }
893                for key_name in schema.keys.keys() {
894                    keys.push(format!("{section}.{key_name}"));
895                }
896            }
897        }
898        keys
899    }
900}