1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::{Display, Formatter};
3use std::sync::OnceLock;
4
5use crate::config::ConfigError;
6pub(crate) use crate::normalize::{normalize_identifier, normalize_optional_identifier};
7
8#[derive(Debug, Clone, PartialEq)]
10pub struct TomlEditResult {
11 pub previous: Option<ConfigValue>,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub enum ConfigSource {
18 BuiltinDefaults,
20 PresentationDefaults,
22 ConfigFile,
24 Secrets,
27 Environment,
29 Cli,
31 Session,
33 Derived,
35}
36
37impl Display for ConfigSource {
38 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
39 let value = match self {
40 ConfigSource::BuiltinDefaults => "defaults",
41 ConfigSource::PresentationDefaults => "presentation",
42 ConfigSource::ConfigFile => "file",
43 ConfigSource::Secrets => "secrets",
44 ConfigSource::Environment => "env",
45 ConfigSource::Cli => "cli",
46 ConfigSource::Session => "session",
47 ConfigSource::Derived => "derived",
48 };
49 write!(f, "{value}")
50 }
51}
52
53#[derive(Debug, Clone, PartialEq)]
55pub enum ConfigValue {
56 String(String),
58 Bool(bool),
60 Integer(i64),
62 Float(f64),
64 List(Vec<ConfigValue>),
66 Secret(SecretValue),
68}
69
70impl ConfigValue {
71 pub fn is_secret(&self) -> bool {
82 matches!(self, ConfigValue::Secret(_))
83 }
84
85 pub fn reveal(&self) -> &ConfigValue {
96 match self {
97 ConfigValue::Secret(secret) => secret.expose(),
98 other => other,
99 }
100 }
101
102 pub fn into_secret(self) -> ConfigValue {
113 match self {
114 ConfigValue::Secret(_) => self,
115 other => ConfigValue::Secret(SecretValue::new(other)),
116 }
117 }
118
119 pub(crate) fn from_toml(path: &str, value: &toml::Value) -> Result<Self, ConfigError> {
120 match value {
121 toml::Value::String(v) => Ok(Self::String(v.clone())),
122 toml::Value::Integer(v) => Ok(Self::Integer(*v)),
123 toml::Value::Float(v) => Ok(Self::Float(*v)),
124 toml::Value::Boolean(v) => Ok(Self::Bool(*v)),
125 toml::Value::Datetime(v) => Ok(Self::String(v.to_string())),
126 toml::Value::Array(values) => {
127 let mut out = Vec::with_capacity(values.len());
128 for item in values {
129 out.push(Self::from_toml(path, item)?);
130 }
131 Ok(Self::List(out))
132 }
133 toml::Value::Table(_) => Err(ConfigError::UnsupportedTomlValue {
134 path: path.to_string(),
135 kind: "table".to_string(),
136 }),
137 }
138 }
139
140 pub(crate) fn as_interpolation_string(
141 &self,
142 key: &str,
143 placeholder: &str,
144 ) -> Result<String, ConfigError> {
145 match self.reveal() {
146 ConfigValue::String(value) => Ok(value.clone()),
147 ConfigValue::Bool(value) => Ok(value.to_string()),
148 ConfigValue::Integer(value) => Ok(value.to_string()),
149 ConfigValue::Float(value) => Ok(value.to_string()),
150 ConfigValue::List(_) => Err(ConfigError::NonScalarPlaceholder {
151 key: key.to_string(),
152 placeholder: placeholder.to_string(),
153 }),
154 ConfigValue::Secret(_) => Err(ConfigError::NonScalarPlaceholder {
155 key: key.to_string(),
156 placeholder: placeholder.to_string(),
157 }),
158 }
159 }
160}
161
162#[derive(Clone, PartialEq)]
164pub struct SecretValue(Box<ConfigValue>);
165
166impl SecretValue {
167 pub fn new(value: ConfigValue) -> Self {
178 Self(Box::new(value))
179 }
180
181 pub fn expose(&self) -> &ConfigValue {
183 &self.0
184 }
185
186 pub fn into_inner(self) -> ConfigValue {
188 *self.0
189 }
190}
191
192impl std::fmt::Debug for SecretValue {
193 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
194 write!(f, "[REDACTED]")
195 }
196}
197
198impl Display for SecretValue {
199 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
200 write!(f, "[REDACTED]")
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum SchemaValueType {
207 String,
209 Bool,
211 Integer,
213 Float,
215 StringList,
217}
218
219impl Display for SchemaValueType {
220 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
221 let value = match self {
222 SchemaValueType::String => "string",
223 SchemaValueType::Bool => "bool",
224 SchemaValueType::Integer => "integer",
225 SchemaValueType::Float => "float",
226 SchemaValueType::StringList => "list",
227 };
228 write!(f, "{value}")
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum BootstrapPhase {
235 Path,
237 Profile,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum BootstrapScopeRule {
244 GlobalOnly,
246 GlobalOrTerminal,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum BootstrapValueRule {
253 NonEmptyString,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct BootstrapKeySpec {
260 pub key: &'static str,
262 pub phase: BootstrapPhase,
264 pub runtime_visible: bool,
266 pub scope_rule: BootstrapScopeRule,
268}
269
270impl BootstrapKeySpec {
271 fn allows_scope(&self, scope: &Scope) -> bool {
272 match self.scope_rule {
273 BootstrapScopeRule::GlobalOnly => scope.profile.is_none() && scope.terminal.is_none(),
274 BootstrapScopeRule::GlobalOrTerminal => scope.profile.is_none(),
275 }
276 }
277}
278
279#[derive(Debug, Clone)]
281#[must_use]
282pub struct SchemaEntry {
283 canonical_key: Option<&'static str>,
284 doc: Option<&'static str>,
285 value_type: SchemaValueType,
286 required: bool,
287 writable: bool,
288 allowed_values: Option<Vec<String>>,
289 runtime_visible: bool,
290 bootstrap_phase: Option<BootstrapPhase>,
291 bootstrap_scope_rule: Option<BootstrapScopeRule>,
292 bootstrap_value_rule: Option<BootstrapValueRule>,
293}
294
295impl SchemaEntry {
296 pub fn string() -> Self {
308 Self {
309 canonical_key: None,
310 doc: None,
311 value_type: SchemaValueType::String,
312 required: false,
313 writable: true,
314 allowed_values: None,
315 runtime_visible: true,
316 bootstrap_phase: None,
317 bootstrap_scope_rule: None,
318 bootstrap_value_rule: None,
319 }
320 }
321
322 pub fn boolean() -> Self {
324 Self {
325 canonical_key: None,
326 doc: None,
327 value_type: SchemaValueType::Bool,
328 required: false,
329 writable: true,
330 allowed_values: None,
331 runtime_visible: true,
332 bootstrap_phase: None,
333 bootstrap_scope_rule: None,
334 bootstrap_value_rule: None,
335 }
336 }
337
338 pub fn integer() -> Self {
340 Self {
341 canonical_key: None,
342 doc: None,
343 value_type: SchemaValueType::Integer,
344 required: false,
345 writable: true,
346 allowed_values: None,
347 runtime_visible: true,
348 bootstrap_phase: None,
349 bootstrap_scope_rule: None,
350 bootstrap_value_rule: None,
351 }
352 }
353
354 pub fn float() -> Self {
356 Self {
357 canonical_key: None,
358 doc: None,
359 value_type: SchemaValueType::Float,
360 required: false,
361 writable: true,
362 allowed_values: None,
363 runtime_visible: true,
364 bootstrap_phase: None,
365 bootstrap_scope_rule: None,
366 bootstrap_value_rule: None,
367 }
368 }
369
370 pub fn string_list() -> Self {
372 Self {
373 canonical_key: None,
374 doc: None,
375 value_type: SchemaValueType::StringList,
376 required: false,
377 writable: true,
378 allowed_values: None,
379 runtime_visible: true,
380 bootstrap_phase: None,
381 bootstrap_scope_rule: None,
382 bootstrap_value_rule: None,
383 }
384 }
385
386 pub fn required(mut self) -> Self {
388 self.required = true;
389 self
390 }
391
392 pub fn read_only(mut self) -> Self {
394 self.writable = false;
395 self
396 }
397
398 pub fn with_doc(mut self, doc: &'static str) -> Self {
400 self.doc = Some(doc);
401 self
402 }
403
404 pub fn bootstrap_only(mut self, phase: BootstrapPhase, scope_rule: BootstrapScopeRule) -> Self {
406 self.runtime_visible = false;
407 self.bootstrap_phase = Some(phase);
408 self.bootstrap_scope_rule = Some(scope_rule);
409 self
410 }
411
412 pub fn with_bootstrap_value_rule(mut self, rule: BootstrapValueRule) -> Self {
414 self.bootstrap_value_rule = Some(rule);
415 self
416 }
417
418 pub fn with_allowed_values<I, S>(mut self, values: I) -> Self
420 where
421 I: IntoIterator<Item = S>,
422 S: AsRef<str>,
423 {
424 self.allowed_values = Some(
425 values
426 .into_iter()
427 .map(|value| value.as_ref().to_ascii_lowercase())
428 .collect(),
429 );
430 self
431 }
432
433 pub fn value_type(&self) -> SchemaValueType {
435 self.value_type
436 }
437
438 pub fn allowed_values(&self) -> Option<&[String]> {
440 self.allowed_values.as_deref()
441 }
442
443 pub fn doc(&self) -> Option<&'static str> {
445 self.doc
446 }
447
448 pub fn runtime_visible(&self) -> bool {
450 self.runtime_visible
451 }
452
453 pub fn writable(&self) -> bool {
455 self.writable
456 }
457
458 fn with_canonical_key(mut self, key: &'static str) -> Self {
459 self.canonical_key = Some(key);
460 self
461 }
462
463 fn bootstrap_spec(&self) -> Option<BootstrapKeySpec> {
464 Some(BootstrapKeySpec {
465 key: self.canonical_key?,
466 phase: self.bootstrap_phase?,
467 runtime_visible: self.runtime_visible,
468 scope_rule: self.bootstrap_scope_rule?,
469 })
470 }
471
472 fn validate_bootstrap_value(&self, key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
473 match self.bootstrap_value_rule {
474 Some(BootstrapValueRule::NonEmptyString) => match value.reveal() {
475 ConfigValue::String(current) if !current.trim().is_empty() => Ok(()),
476 ConfigValue::String(current) => Err(ConfigError::InvalidBootstrapValue {
477 key: key.to_string(),
478 reason: format!("expected a non-empty string, got {current:?}"),
479 }),
480 other => Err(ConfigError::InvalidBootstrapValue {
481 key: key.to_string(),
482 reason: format!("expected string, got {other:?}"),
483 }),
484 },
485 None => Ok(()),
486 }
487 }
488}
489
490#[derive(Debug, Clone)]
492pub struct ConfigSchema {
493 entries: BTreeMap<String, SchemaEntry>,
494 allow_extensions_namespace: bool,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498enum DynamicSchemaKeyKind {
499 PluginCommandState,
500 PluginCommandProvider,
501}
502
503impl Default for ConfigSchema {
504 fn default() -> Self {
505 builtin_config_schema().clone()
506 }
507}
508
509impl ConfigSchema {
510 fn builtin() -> Self {
511 let mut schema = Self {
512 entries: BTreeMap::new(),
513 allow_extensions_namespace: true,
514 };
515 insert_identity_schema_keys(&mut schema);
516 insert_ui_schema_keys(&mut schema);
517 insert_repl_schema_keys(&mut schema);
518 insert_color_schema_keys(&mut schema);
519 insert_misc_schema_keys(&mut schema);
520
521 schema
522 }
523}
524
525fn insert_builtin_schema_key(
526 schema: &mut ConfigSchema,
527 key: &'static str,
528 entry: SchemaEntry,
529 doc: &'static str,
530) {
531 schema.insert(key, entry.with_doc(doc));
532}
533
534fn insert_identity_schema_keys(schema: &mut ConfigSchema) {
535 insert_builtin_schema_key(
536 schema,
537 "profile.default",
538 SchemaEntry::string()
539 .bootstrap_only(
540 BootstrapPhase::Profile,
541 BootstrapScopeRule::GlobalOrTerminal,
542 )
543 .with_bootstrap_value_rule(BootstrapValueRule::NonEmptyString),
544 "Default profile selected when no override is provided",
545 );
546 insert_builtin_schema_key(
547 schema,
548 "profile.active",
549 SchemaEntry::string().required().read_only(),
550 "Active profile derived during resolution",
551 );
552 insert_builtin_schema_key(
553 schema,
554 "theme.name",
555 SchemaEntry::string(),
556 "Name of the active color theme",
557 );
558 insert_builtin_schema_key(
559 schema,
560 "theme.path",
561 SchemaEntry::string_list(),
562 "Extra theme search paths",
563 );
564 insert_builtin_schema_key(
565 schema,
566 "user.name",
567 SchemaEntry::string(),
568 "Short user name used in prompts and interpolation",
569 );
570 insert_builtin_schema_key(
571 schema,
572 "user.display_name",
573 SchemaEntry::string(),
574 "Preferred display name for the current user",
575 );
576 insert_builtin_schema_key(
577 schema,
578 "user.full_name",
579 SchemaEntry::string(),
580 "Full name for the current user",
581 );
582 insert_builtin_schema_key(
583 schema,
584 "domain",
585 SchemaEntry::string(),
586 "Default domain name used in prompts and interpolation",
587 );
588}
589
590fn insert_ui_schema_keys(schema: &mut ConfigSchema) {
591 insert_builtin_schema_key(
592 schema,
593 "ui.format",
594 SchemaEntry::string()
595 .with_allowed_values(["auto", "guide", "json", "table", "md", "mreg", "value"]),
596 "Default output format",
597 );
598 insert_builtin_schema_key(
599 schema,
600 "ui.mode",
601 SchemaEntry::string().with_allowed_values(["auto", "plain", "rich"]),
602 "Preferred render mode",
603 );
604 insert_builtin_schema_key(
605 schema,
606 "ui.presentation",
607 SchemaEntry::string().with_allowed_values([
608 "expressive",
609 "compact",
610 "austere",
611 "gammel-og-bitter",
612 ]),
613 "UI presentation preset",
614 );
615 insert_builtin_schema_key(
616 schema,
617 "ui.color.mode",
618 SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
619 "Color rendering policy",
620 );
621 insert_builtin_schema_key(
622 schema,
623 "ui.unicode.mode",
624 SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
625 "Unicode rendering policy",
626 );
627 insert_builtin_schema_key(
628 schema,
629 "ui.width",
630 SchemaEntry::integer(),
631 "Default render width hint",
632 );
633 insert_builtin_schema_key(
634 schema,
635 "ui.margin",
636 SchemaEntry::integer(),
637 "Left margin used when rendering output",
638 );
639 insert_builtin_schema_key(
640 schema,
641 "ui.indent",
642 SchemaEntry::integer(),
643 "Indent width for nested output",
644 );
645 insert_builtin_schema_key(
646 schema,
647 "ui.help.level",
648 SchemaEntry::string().with_allowed_values(["inherit", "none", "tiny", "normal", "verbose"]),
649 "Help detail level or inherit",
650 );
651 insert_builtin_schema_key(
652 schema,
653 "ui.guide.default_format",
654 SchemaEntry::string().with_allowed_values(["guide", "inherit", "none"]),
655 "Guide rendering format used by help-like output",
656 );
657 insert_builtin_schema_key(
658 schema,
659 "ui.messages.layout",
660 SchemaEntry::string().with_allowed_values([
661 "full", "compact", "austere", "plain", "none", "grouped", "minimal",
662 ]),
663 "Message layout style",
664 );
665 insert_builtin_schema_key(
666 schema,
667 "ui.chrome.frame",
668 SchemaEntry::string().with_allowed_values([
669 "none",
670 "top",
671 "bottom",
672 "top-bottom",
673 "square",
674 "round",
675 ]),
676 "Section chrome frame style",
677 );
678 insert_builtin_schema_key(
679 schema,
680 "ui.chrome.rule_policy",
681 SchemaEntry::string().with_allowed_values([
682 "per-section",
683 "independent",
684 "separate",
685 "shared",
686 "stacked",
687 "list",
688 ]),
689 "How sibling section rules are shared",
690 );
691 insert_builtin_schema_key(
692 schema,
693 "ui.table.overflow",
694 SchemaEntry::string().with_allowed_values([
695 "clip", "hidden", "crop", "ellipsis", "truncate", "wrap", "none", "visible",
696 ]),
697 "Table overflow behavior",
698 );
699 insert_builtin_schema_key(
700 schema,
701 "ui.table.border",
702 SchemaEntry::string().with_allowed_values(["none", "square", "round"]),
703 "Table border style",
704 );
705 insert_builtin_schema_key(
706 schema,
707 "ui.help.table_chrome",
708 SchemaEntry::string().with_allowed_values(["inherit", "none", "square", "round"]),
709 "Help table chrome style or inherit",
710 );
711 insert_builtin_schema_key(
712 schema,
713 "ui.help.entry_indent",
714 SchemaEntry::string(),
715 "Help entry indent override or inherit",
716 );
717 insert_builtin_schema_key(
718 schema,
719 "ui.help.entry_gap",
720 SchemaEntry::string(),
721 "Help entry gap override or inherit",
722 );
723 insert_builtin_schema_key(
724 schema,
725 "ui.help.section_spacing",
726 SchemaEntry::string(),
727 "Help section spacing override or inherit",
728 );
729 insert_builtin_schema_key(
730 schema,
731 "ui.short_list_max",
732 SchemaEntry::integer(),
733 "Maximum items rendered as a short list",
734 );
735 insert_builtin_schema_key(
736 schema,
737 "ui.medium_list_max",
738 SchemaEntry::integer(),
739 "Maximum items rendered as a medium list",
740 );
741 insert_builtin_schema_key(
742 schema,
743 "ui.grid_padding",
744 SchemaEntry::integer(),
745 "Padding between rendered grid columns",
746 );
747 insert_builtin_schema_key(
748 schema,
749 "ui.grid_columns",
750 SchemaEntry::integer(),
751 "Fixed grid column count when set",
752 );
753 insert_builtin_schema_key(
754 schema,
755 "ui.column_weight",
756 SchemaEntry::integer(),
757 "Relative weight used for adaptive columns",
758 );
759 insert_builtin_schema_key(
760 schema,
761 "ui.mreg.stack_min_col_width",
762 SchemaEntry::integer(),
763 "Minimum column width before MREG stacks columns",
764 );
765 insert_builtin_schema_key(
766 schema,
767 "ui.mreg.stack_overflow_ratio",
768 SchemaEntry::integer(),
769 "Overflow ratio threshold for stacked MREG output",
770 );
771 insert_builtin_schema_key(
772 schema,
773 "ui.message.verbosity",
774 SchemaEntry::string().with_allowed_values(["error", "warning", "success", "info", "trace"]),
775 "Default message verbosity level",
776 );
777 insert_builtin_schema_key(
778 schema,
779 "ui.prompt",
780 SchemaEntry::string(),
781 "Prompt template used by the UI",
782 );
783 insert_builtin_schema_key(
784 schema,
785 "ui.prompt.secrets",
786 SchemaEntry::boolean(),
787 "Whether prompts may reveal secret values",
788 );
789 insert_builtin_schema_key(
790 schema,
791 "extensions.plugins.timeout_ms",
792 SchemaEntry::integer(),
793 "Plugin process timeout in milliseconds",
794 );
795 insert_builtin_schema_key(
796 schema,
797 "extensions.plugins.discovery.path",
798 SchemaEntry::boolean(),
799 "Whether plugin discovery should scan PATH",
800 );
801}
802
803fn insert_repl_schema_keys(schema: &mut ConfigSchema) {
804 insert_builtin_schema_key(
805 schema,
806 "repl.prompt",
807 SchemaEntry::string(),
808 "Prompt template used by the REPL",
809 );
810 insert_builtin_schema_key(
811 schema,
812 "repl.prompt_right",
813 SchemaEntry::string(),
814 "Right-hand prompt template used by the REPL",
815 );
816 insert_builtin_schema_key(
817 schema,
818 "repl.input_mode",
819 SchemaEntry::string().with_allowed_values(["auto", "interactive", "basic"]),
820 "REPL input mode",
821 );
822 insert_builtin_schema_key(
823 schema,
824 "repl.simple_prompt",
825 SchemaEntry::boolean(),
826 "Whether the REPL should use the simple prompt",
827 );
828 insert_builtin_schema_key(
829 schema,
830 "repl.shell_indicator",
831 SchemaEntry::string(),
832 "Template for the current shell indicator",
833 );
834 insert_builtin_schema_key(
835 schema,
836 "repl.intro",
837 SchemaEntry::string().with_allowed_values(["none", "minimal", "compact", "full"]),
838 "REPL intro detail level",
839 );
840 insert_builtin_schema_key(
841 schema,
842 "repl.intro_template.minimal",
843 SchemaEntry::string(),
844 "Template for the minimal REPL intro",
845 );
846 insert_builtin_schema_key(
847 schema,
848 "repl.intro_template.compact",
849 SchemaEntry::string(),
850 "Template for the compact REPL intro",
851 );
852 insert_builtin_schema_key(
853 schema,
854 "repl.intro_template.full",
855 SchemaEntry::string(),
856 "Template for the full REPL intro",
857 );
858 insert_builtin_schema_key(
859 schema,
860 "repl.history.path",
861 SchemaEntry::string(),
862 "Path to the persistent REPL history file",
863 );
864 insert_builtin_schema_key(
865 schema,
866 "repl.history.max_entries",
867 SchemaEntry::integer(),
868 "Maximum number of persisted REPL history entries",
869 );
870 insert_builtin_schema_key(
871 schema,
872 "repl.history.enabled",
873 SchemaEntry::boolean(),
874 "Whether persistent REPL history is enabled",
875 );
876 insert_builtin_schema_key(
877 schema,
878 "repl.history.dedupe",
879 SchemaEntry::boolean(),
880 "Whether duplicate history entries are collapsed",
881 );
882 insert_builtin_schema_key(
883 schema,
884 "repl.history.profile_scoped",
885 SchemaEntry::boolean(),
886 "Whether history files are scoped by profile",
887 );
888 insert_builtin_schema_key(
889 schema,
890 "repl.history.menu_rows",
891 SchemaEntry::integer(),
892 "Maximum rows shown in the history menu",
893 );
894 insert_builtin_schema_key(
895 schema,
896 "repl.history.exclude",
897 SchemaEntry::string_list(),
898 "Commands excluded from persisted history",
899 );
900 insert_builtin_schema_key(
901 schema,
902 "session.cache.max_results",
903 SchemaEntry::integer(),
904 "Maximum cached session results",
905 );
906}
907
908fn insert_color_schema_keys(schema: &mut ConfigSchema) {
909 insert_builtin_schema_key(
910 schema,
911 "color.prompt.text",
912 SchemaEntry::string(),
913 "Prompt text color override",
914 );
915 insert_builtin_schema_key(
916 schema,
917 "color.prompt.command",
918 SchemaEntry::string(),
919 "Prompt command color override",
920 );
921 insert_builtin_schema_key(
922 schema,
923 "color.prompt.completion.text",
924 SchemaEntry::string(),
925 "Completion text color override",
926 );
927 insert_builtin_schema_key(
928 schema,
929 "color.prompt.completion.background",
930 SchemaEntry::string(),
931 "Completion background color override",
932 );
933 insert_builtin_schema_key(
934 schema,
935 "color.prompt.completion.highlight",
936 SchemaEntry::string(),
937 "Completion highlight color override",
938 );
939 insert_builtin_schema_key(
940 schema,
941 "color.text",
942 SchemaEntry::string(),
943 "Primary text color override",
944 );
945 insert_builtin_schema_key(
946 schema,
947 "color.text.muted",
948 SchemaEntry::string(),
949 "Muted text color override",
950 );
951 insert_builtin_schema_key(
952 schema,
953 "color.key",
954 SchemaEntry::string(),
955 "Key label color override",
956 );
957 insert_builtin_schema_key(
958 schema,
959 "color.border",
960 SchemaEntry::string(),
961 "Border color override",
962 );
963 insert_builtin_schema_key(
964 schema,
965 "color.table.header",
966 SchemaEntry::string(),
967 "Table header color override",
968 );
969 insert_builtin_schema_key(
970 schema,
971 "color.mreg.key",
972 SchemaEntry::string(),
973 "MREG key color override",
974 );
975 insert_builtin_schema_key(
976 schema,
977 "color.value",
978 SchemaEntry::string(),
979 "Value color override",
980 );
981 insert_builtin_schema_key(
982 schema,
983 "color.value.number",
984 SchemaEntry::string(),
985 "Numeric value color override",
986 );
987 insert_builtin_schema_key(
988 schema,
989 "color.value.bool_true",
990 SchemaEntry::string(),
991 "True boolean color override",
992 );
993 insert_builtin_schema_key(
994 schema,
995 "color.value.bool_false",
996 SchemaEntry::string(),
997 "False boolean color override",
998 );
999 insert_builtin_schema_key(
1000 schema,
1001 "color.value.null",
1002 SchemaEntry::string(),
1003 "Null value color override",
1004 );
1005 insert_builtin_schema_key(
1006 schema,
1007 "color.value.ipv4",
1008 SchemaEntry::string(),
1009 "IPv4 value color override",
1010 );
1011 insert_builtin_schema_key(
1012 schema,
1013 "color.value.ipv6",
1014 SchemaEntry::string(),
1015 "IPv6 value color override",
1016 );
1017 insert_builtin_schema_key(
1018 schema,
1019 "color.panel.border",
1020 SchemaEntry::string(),
1021 "Panel border color override",
1022 );
1023 insert_builtin_schema_key(
1024 schema,
1025 "color.panel.title",
1026 SchemaEntry::string(),
1027 "Panel title color override",
1028 );
1029 insert_builtin_schema_key(
1030 schema,
1031 "color.code",
1032 SchemaEntry::string(),
1033 "Code block color override",
1034 );
1035 insert_builtin_schema_key(
1036 schema,
1037 "color.json.key",
1038 SchemaEntry::string(),
1039 "JSON key color override",
1040 );
1041 insert_builtin_schema_key(
1042 schema,
1043 "color.message.error",
1044 SchemaEntry::string(),
1045 "Error message color override",
1046 );
1047 insert_builtin_schema_key(
1048 schema,
1049 "color.message.warning",
1050 SchemaEntry::string(),
1051 "Warning message color override",
1052 );
1053 insert_builtin_schema_key(
1054 schema,
1055 "color.message.success",
1056 SchemaEntry::string(),
1057 "Success message color override",
1058 );
1059 insert_builtin_schema_key(
1060 schema,
1061 "color.message.info",
1062 SchemaEntry::string(),
1063 "Info message color override",
1064 );
1065 insert_builtin_schema_key(
1066 schema,
1067 "color.message.trace",
1068 SchemaEntry::string(),
1069 "Trace message color override",
1070 );
1071}
1072
1073fn insert_misc_schema_keys(schema: &mut ConfigSchema) {
1074 insert_builtin_schema_key(
1075 schema,
1076 "auth.visible.builtins",
1077 SchemaEntry::string(),
1078 "Visible builtin auth command allow-list",
1079 );
1080 insert_builtin_schema_key(
1081 schema,
1082 "auth.visible.plugins",
1083 SchemaEntry::string(),
1084 "Visible plugin auth command allow-list",
1085 );
1086 insert_builtin_schema_key(
1087 schema,
1088 "debug.level",
1089 SchemaEntry::integer(),
1090 "Developer debug verbosity",
1091 );
1092 insert_builtin_schema_key(
1093 schema,
1094 "log.file.enabled",
1095 SchemaEntry::boolean(),
1096 "Whether file logging is enabled",
1097 );
1098 insert_builtin_schema_key(
1099 schema,
1100 "log.file.path",
1101 SchemaEntry::string(),
1102 "Path to the runtime log file",
1103 );
1104 insert_builtin_schema_key(
1105 schema,
1106 "log.file.level",
1107 SchemaEntry::string().with_allowed_values(["error", "warn", "info", "debug", "trace"]),
1108 "Minimum log level written to the log file",
1109 );
1110 insert_builtin_schema_key(
1111 schema,
1112 "base.dir",
1113 SchemaEntry::string(),
1114 "Base directory available for interpolation and tooling",
1115 );
1116}
1117
1118impl ConfigSchema {
1119 pub fn insert(&mut self, key: &'static str, entry: SchemaEntry) {
1121 self.entries
1122 .insert(key.to_string(), entry.with_canonical_key(key));
1123 }
1124
1125 pub fn set_allow_extensions_namespace(&mut self, value: bool) {
1127 self.allow_extensions_namespace = value;
1128 }
1129
1130 pub fn is_known_key(&self, key: &str) -> bool {
1132 self.entries.contains_key(key)
1133 || self.is_extension_key(key)
1134 || self.is_alias_key(key)
1135 || dynamic_schema_key_kind(key).is_some()
1136 }
1137
1138 pub fn is_runtime_visible_key(&self, key: &str) -> bool {
1140 self.entries
1141 .get(key)
1142 .is_some_and(SchemaEntry::runtime_visible)
1143 || self.is_extension_key(key)
1144 || dynamic_schema_key_kind(key).is_some()
1145 }
1146
1147 pub fn validate_writable_key(&self, key: &str) -> Result<(), ConfigError> {
1149 let normalized = key.trim().to_ascii_lowercase();
1150 if let Some(entry) = self.entries.get(&normalized)
1151 && !entry.writable()
1152 {
1153 return Err(ConfigError::ReadOnlyConfigKey {
1154 key: normalized,
1155 reason: "derived at runtime".to_string(),
1156 });
1157 }
1158 Ok(())
1159 }
1160
1161 pub fn bootstrap_key_spec(&self, key: &str) -> Option<BootstrapKeySpec> {
1163 let normalized = key.trim().to_ascii_lowercase();
1164 self.entries
1165 .get(&normalized)
1166 .and_then(SchemaEntry::bootstrap_spec)
1167 }
1168
1169 pub fn entries(&self) -> impl Iterator<Item = (&str, &SchemaEntry)> {
1171 self.entries
1172 .iter()
1173 .map(|(key, entry)| (key.as_str(), entry))
1174 }
1175
1176 pub fn doc_for_key(&self, key: &str) -> Option<&'static str> {
1178 let normalized = key.trim().to_ascii_lowercase();
1179 self.entries.get(&normalized).and_then(SchemaEntry::doc)
1180 }
1181
1182 pub fn expected_type(&self, key: &str) -> Option<SchemaValueType> {
1184 self.entries
1185 .get(key)
1186 .map(|entry| entry.value_type)
1187 .or_else(|| dynamic_schema_key_kind(key).map(|_| SchemaValueType::String))
1188 }
1189
1190 pub fn parse_input_value(&self, key: &str, raw: &str) -> Result<ConfigValue, ConfigError> {
1208 if !self.is_known_key(key) {
1209 return Err(ConfigError::UnknownConfigKeys {
1210 keys: vec![key.to_string()],
1211 });
1212 }
1213 self.validate_writable_key(key)?;
1214
1215 let value = match self.expected_type(key) {
1216 Some(SchemaValueType::String) | None => ConfigValue::String(raw.to_string()),
1217 Some(SchemaValueType::Bool) => {
1218 ConfigValue::Bool(
1219 parse_bool(raw).ok_or_else(|| ConfigError::InvalidValueType {
1220 key: key.to_string(),
1221 expected: SchemaValueType::Bool,
1222 actual: "string".to_string(),
1223 })?,
1224 )
1225 }
1226 Some(SchemaValueType::Integer) => {
1227 let parsed =
1228 raw.trim()
1229 .parse::<i64>()
1230 .map_err(|_| ConfigError::InvalidValueType {
1231 key: key.to_string(),
1232 expected: SchemaValueType::Integer,
1233 actual: "string".to_string(),
1234 })?;
1235 ConfigValue::Integer(parsed)
1236 }
1237 Some(SchemaValueType::Float) => {
1238 let parsed =
1239 raw.trim()
1240 .parse::<f64>()
1241 .map_err(|_| ConfigError::InvalidValueType {
1242 key: key.to_string(),
1243 expected: SchemaValueType::Float,
1244 actual: "string".to_string(),
1245 })?;
1246 ConfigValue::Float(parsed)
1247 }
1248 Some(SchemaValueType::StringList) => {
1249 let items = parse_string_list(raw);
1250 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
1251 }
1252 };
1253
1254 if let Some(entry) = self.entries.get(key) {
1255 validate_allowed_values(
1256 key,
1257 &value,
1258 entry
1259 .allowed_values()
1260 .map(|values| values.iter().map(String::as_str).collect::<Vec<_>>())
1261 .as_deref(),
1262 )?;
1263 } else if let Some(DynamicSchemaKeyKind::PluginCommandState) = dynamic_schema_key_kind(key)
1264 {
1265 validate_allowed_values(key, &value, Some(&["enabled", "disabled"]))?;
1266 }
1267
1268 Ok(value)
1269 }
1270
1271 pub(crate) fn validate_and_adapt(
1272 &self,
1273 values: &mut BTreeMap<String, ResolvedValue>,
1274 ) -> Result<(), ConfigError> {
1275 let mut unknown = Vec::new();
1276 for key in values.keys() {
1277 if self.is_runtime_visible_key(key) {
1278 continue;
1279 }
1280 unknown.push(key.clone());
1281 }
1282 if !unknown.is_empty() {
1283 unknown.sort();
1284 return Err(ConfigError::UnknownConfigKeys { keys: unknown });
1285 }
1286
1287 for (key, entry) in &self.entries {
1288 if entry.runtime_visible && entry.required && !values.contains_key(key) {
1289 return Err(ConfigError::MissingRequiredKey { key: key.clone() });
1290 }
1291 }
1292
1293 for (key, resolved) in values.iter_mut() {
1294 if let Some(kind) = dynamic_schema_key_kind(key) {
1295 resolved.value = adapt_dynamic_value_for_schema(key, &resolved.value, kind)?;
1296 continue;
1297 }
1298 let Some(schema_entry) = self.entries.get(key) else {
1299 continue;
1300 };
1301 if !schema_entry.runtime_visible {
1302 continue;
1303 }
1304 resolved.value = adapt_value_for_schema(key, &resolved.value, schema_entry)?;
1305 }
1306
1307 Ok(())
1308 }
1309
1310 fn is_extension_key(&self, key: &str) -> bool {
1311 self.allow_extensions_namespace && key.starts_with("extensions.")
1312 }
1313
1314 fn is_alias_key(&self, key: &str) -> bool {
1315 key.starts_with("alias.")
1316 }
1317
1318 pub fn validate_key_scope(&self, key: &str, scope: &Scope) -> Result<(), ConfigError> {
1320 let normalized_scope = normalize_scope(scope.clone());
1321 if let Some(spec) = self.bootstrap_key_spec(key)
1322 && !spec.allows_scope(&normalized_scope)
1323 {
1324 return Err(ConfigError::InvalidBootstrapScope {
1325 key: spec.key.to_string(),
1326 profile: normalized_scope.profile,
1327 terminal: normalized_scope.terminal,
1328 });
1329 }
1330
1331 Ok(())
1332 }
1333
1334 pub fn validate_bootstrap_value(
1336 &self,
1337 key: &str,
1338 value: &ConfigValue,
1339 ) -> Result<(), ConfigError> {
1340 let normalized = key.trim().to_ascii_lowercase();
1341 let Some(entry) = self.entries.get(&normalized) else {
1342 return Ok(());
1343 };
1344 entry.validate_bootstrap_value(&normalized, value)
1345 }
1346}
1347
1348fn builtin_config_schema() -> &'static ConfigSchema {
1349 static BUILTIN_SCHEMA: OnceLock<ConfigSchema> = OnceLock::new();
1350 BUILTIN_SCHEMA.get_or_init(ConfigSchema::builtin)
1351}
1352
1353impl Display for ConfigValue {
1354 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1355 match self {
1356 ConfigValue::String(v) => write!(f, "{v}"),
1357 ConfigValue::Bool(v) => write!(f, "{v}"),
1358 ConfigValue::Integer(v) => write!(f, "{v}"),
1359 ConfigValue::Float(v) => write!(f, "{v}"),
1360 ConfigValue::List(v) => {
1361 let joined = v
1362 .iter()
1363 .map(ToString::to_string)
1364 .collect::<Vec<String>>()
1365 .join(",");
1366 write!(f, "[{joined}]")
1367 }
1368 ConfigValue::Secret(secret) => write!(f, "{secret}"),
1369 }
1370 }
1371}
1372
1373impl From<&str> for ConfigValue {
1374 fn from(value: &str) -> Self {
1375 ConfigValue::String(value.to_string())
1376 }
1377}
1378
1379impl From<String> for ConfigValue {
1380 fn from(value: String) -> Self {
1381 ConfigValue::String(value)
1382 }
1383}
1384
1385impl From<bool> for ConfigValue {
1386 fn from(value: bool) -> Self {
1387 ConfigValue::Bool(value)
1388 }
1389}
1390
1391impl From<i64> for ConfigValue {
1392 fn from(value: i64) -> Self {
1393 ConfigValue::Integer(value)
1394 }
1395}
1396
1397impl From<f64> for ConfigValue {
1398 fn from(value: f64) -> Self {
1399 ConfigValue::Float(value)
1400 }
1401}
1402
1403impl From<Vec<String>> for ConfigValue {
1404 fn from(values: Vec<String>) -> Self {
1405 ConfigValue::List(values.into_iter().map(ConfigValue::String).collect())
1406 }
1407}
1408
1409#[derive(Debug, Clone, Default, PartialEq, Eq)]
1411pub struct Scope {
1412 pub profile: Option<String>,
1414 pub terminal: Option<String>,
1416}
1417
1418impl Scope {
1419 pub fn global() -> Self {
1429 Self::default()
1430 }
1431
1432 pub fn profile(profile: &str) -> Self {
1444 Self {
1445 profile: Some(normalize_identifier(profile)),
1446 terminal: None,
1447 }
1448 }
1449
1450 pub fn terminal(terminal: &str) -> Self {
1452 Self {
1453 profile: None,
1454 terminal: Some(normalize_identifier(terminal)),
1455 }
1456 }
1457
1458 pub fn profile_terminal(profile: &str, terminal: &str) -> Self {
1460 Self {
1461 profile: Some(normalize_identifier(profile)),
1462 terminal: Some(normalize_identifier(terminal)),
1463 }
1464 }
1465}
1466
1467#[derive(Debug, Clone, PartialEq)]
1469pub struct LayerEntry {
1470 pub key: String,
1472 pub value: ConfigValue,
1474 pub scope: Scope,
1476 pub origin: Option<String>,
1478}
1479
1480#[derive(Debug, Clone, Default)]
1482pub struct ConfigLayer {
1483 pub(crate) entries: Vec<LayerEntry>,
1484}
1485
1486impl ConfigLayer {
1487 pub fn entries(&self) -> &[LayerEntry] {
1489 &self.entries
1490 }
1491
1492 pub fn extend_from_layer(&mut self, other: &ConfigLayer) {
1516 self.entries.extend(other.entries().iter().cloned());
1517 }
1518
1519 pub fn set<K, V>(&mut self, key: K, value: V)
1534 where
1535 K: Into<String>,
1536 V: Into<ConfigValue>,
1537 {
1538 self.insert(key, value, Scope::global());
1539 }
1540
1541 pub fn set_for_profile<K, V>(&mut self, profile: &str, key: K, value: V)
1543 where
1544 K: Into<String>,
1545 V: Into<ConfigValue>,
1546 {
1547 self.insert(key, value, Scope::profile(profile));
1548 }
1549
1550 pub fn set_for_terminal<K, V>(&mut self, terminal: &str, key: K, value: V)
1552 where
1553 K: Into<String>,
1554 V: Into<ConfigValue>,
1555 {
1556 self.insert(key, value, Scope::terminal(terminal));
1557 }
1558
1559 pub fn set_for_profile_terminal<K, V>(
1561 &mut self,
1562 profile: &str,
1563 terminal: &str,
1564 key: K,
1565 value: V,
1566 ) where
1567 K: Into<String>,
1568 V: Into<ConfigValue>,
1569 {
1570 self.insert(key, value, Scope::profile_terminal(profile, terminal));
1571 }
1572
1573 pub fn insert<K, V>(&mut self, key: K, value: V, scope: Scope)
1575 where
1576 K: Into<String>,
1577 V: Into<ConfigValue>,
1578 {
1579 self.entries.push(LayerEntry {
1580 key: key.into(),
1581 value: value.into(),
1582 scope: normalize_scope(scope),
1583 origin: None,
1584 });
1585 }
1586
1587 pub fn insert_with_origin<K, V, O>(&mut self, key: K, value: V, scope: Scope, origin: Option<O>)
1589 where
1590 K: Into<String>,
1591 V: Into<ConfigValue>,
1592 O: Into<String>,
1593 {
1594 self.entries.push(LayerEntry {
1595 key: key.into(),
1596 value: value.into(),
1597 scope: normalize_scope(scope),
1598 origin: origin.map(Into::into),
1599 });
1600 }
1601
1602 pub fn mark_all_secret(&mut self) {
1604 for entry in &mut self.entries {
1605 if !entry.value.is_secret() {
1606 entry.value = entry.value.clone().into_secret();
1607 }
1608 }
1609 }
1610
1611 pub fn remove_scoped(&mut self, key: &str, scope: &Scope) -> Option<ConfigValue> {
1626 let normalized_scope = normalize_scope(scope.clone());
1627 let index = self
1628 .entries
1629 .iter()
1630 .rposition(|entry| entry.key == key && entry.scope == normalized_scope)?;
1631 Some(self.entries.remove(index).value)
1632 }
1633
1634 pub fn from_toml_str(raw: &str) -> Result<Self, ConfigError> {
1652 let parsed = raw
1653 .parse::<toml::Value>()
1654 .map_err(|err| ConfigError::TomlParse(err.to_string()))?;
1655
1656 let root = parsed.as_table().ok_or(ConfigError::TomlRootMustBeTable)?;
1657 let mut layer = ConfigLayer::default();
1658
1659 for (section, value) in root {
1660 match section.as_str() {
1661 "default" => {
1662 let table = value
1663 .as_table()
1664 .ok_or_else(|| ConfigError::InvalidSection {
1665 section: "default".to_string(),
1666 expected: "table".to_string(),
1667 })?;
1668 flatten_table(&mut layer, table, "", &Scope::global())?;
1669 }
1670 "profile" => {
1671 let profiles = value
1672 .as_table()
1673 .ok_or_else(|| ConfigError::InvalidSection {
1674 section: "profile".to_string(),
1675 expected: "table".to_string(),
1676 })?;
1677 for (profile, profile_table_value) in profiles {
1678 let profile_table = profile_table_value.as_table().ok_or_else(|| {
1679 ConfigError::InvalidSection {
1680 section: format!("profile.{profile}"),
1681 expected: "table".to_string(),
1682 }
1683 })?;
1684 flatten_table(&mut layer, profile_table, "", &Scope::profile(profile))?;
1685 }
1686 }
1687 "terminal" => {
1688 let terminals =
1689 value
1690 .as_table()
1691 .ok_or_else(|| ConfigError::InvalidSection {
1692 section: "terminal".to_string(),
1693 expected: "table".to_string(),
1694 })?;
1695
1696 for (terminal, terminal_table_value) in terminals {
1697 let terminal_table = terminal_table_value.as_table().ok_or_else(|| {
1698 ConfigError::InvalidSection {
1699 section: format!("terminal.{terminal}"),
1700 expected: "table".to_string(),
1701 }
1702 })?;
1703
1704 for (key, terminal_value) in terminal_table {
1705 if key == "profile" {
1706 continue;
1707 }
1708
1709 flatten_key_value(
1710 &mut layer,
1711 key,
1712 terminal_value,
1713 &Scope::terminal(terminal),
1714 )?;
1715 }
1716
1717 if let Some(profile_section) = terminal_table.get("profile") {
1718 let profile_tables = profile_section.as_table().ok_or_else(|| {
1719 ConfigError::InvalidSection {
1720 section: format!("terminal.{terminal}.profile"),
1721 expected: "table".to_string(),
1722 }
1723 })?;
1724
1725 for (profile_key, profile_value) in profile_tables {
1726 if let Some(profile_table) = profile_value.as_table() {
1727 flatten_table(
1728 &mut layer,
1729 profile_table,
1730 "",
1731 &Scope::profile_terminal(profile_key, terminal),
1732 )?;
1733 } else {
1734 flatten_key_value(
1735 &mut layer,
1736 &format!("profile.{profile_key}"),
1737 profile_value,
1738 &Scope::terminal(terminal),
1739 )?;
1740 }
1741 }
1742 }
1743 }
1744 }
1745 unknown => {
1746 return Err(ConfigError::UnknownTopLevelSection(unknown.to_string()));
1747 }
1748 }
1749 }
1750
1751 Ok(layer)
1752 }
1753
1754 pub fn from_env_iter<I, K, V>(vars: I) -> Result<Self, ConfigError>
1756 where
1757 I: IntoIterator<Item = (K, V)>,
1758 K: AsRef<str>,
1759 V: AsRef<str>,
1760 {
1761 let mut layer = ConfigLayer::default();
1762
1763 for (name, value) in vars {
1764 let key = name.as_ref();
1765 if !key.starts_with("OSP__") {
1766 continue;
1767 }
1768
1769 let spec = parse_env_key(key)?;
1770 builtin_config_schema().validate_writable_key(&spec.key)?;
1771 validate_key_scope(&spec.key, &spec.scope)?;
1772 let converted = ConfigValue::String(value.as_ref().to_string());
1773 validate_bootstrap_value(&spec.key, &converted)?;
1774 layer.insert_with_origin(spec.key, converted, spec.scope, Some(key.to_string()));
1775 }
1776
1777 Ok(layer)
1778 }
1779
1780 pub(crate) fn validate_entries(&self) -> Result<(), ConfigError> {
1781 for entry in &self.entries {
1782 builtin_config_schema().validate_writable_key(&entry.key)?;
1783 validate_key_scope(&entry.key, &entry.scope)?;
1784 validate_bootstrap_value(&entry.key, &entry.value)?;
1785 }
1786
1787 Ok(())
1788 }
1789}
1790
1791pub(crate) struct EnvKeySpec {
1792 pub(crate) key: String,
1793 pub(crate) scope: Scope,
1794}
1795
1796#[derive(Debug, Clone, Default)]
1798#[must_use]
1799pub struct ResolveOptions {
1800 pub profile_override: Option<String>,
1802 pub terminal: Option<String>,
1804}
1805
1806impl ResolveOptions {
1807 pub fn new() -> Self {
1819 Self::default()
1820 }
1821
1822 pub fn with_profile_override(mut self, profile_override: Option<String>) -> Self {
1824 self.profile_override = normalize_optional_identifier(profile_override);
1825 self
1826 }
1827
1828 pub fn with_profile(mut self, profile: &str) -> Self {
1839 self.profile_override = Some(normalize_identifier(profile));
1840 self
1841 }
1842
1843 pub fn with_terminal(mut self, terminal: &str) -> Self {
1845 self.terminal = Some(normalize_identifier(terminal));
1846 self
1847 }
1848
1849 pub fn with_terminal_override(mut self, terminal: Option<String>) -> Self {
1851 self.terminal = normalize_optional_identifier(terminal);
1852 self
1853 }
1854}
1855
1856#[derive(Debug, Clone, PartialEq)]
1858pub struct ResolvedValue {
1859 pub raw_value: ConfigValue,
1861 pub value: ConfigValue,
1863 pub source: ConfigSource,
1865 pub scope: Scope,
1867 pub origin: Option<String>,
1869}
1870
1871#[derive(Debug, Clone, PartialEq)]
1873pub struct ExplainCandidate {
1874 pub entry_index: usize,
1876 pub value: ConfigValue,
1878 pub scope: Scope,
1880 pub origin: Option<String>,
1882 pub rank: Option<u8>,
1884 pub selected_in_layer: bool,
1886}
1887
1888#[derive(Debug, Clone, PartialEq)]
1890pub struct ExplainLayer {
1891 pub source: ConfigSource,
1893 pub selected_entry_index: Option<usize>,
1895 pub candidates: Vec<ExplainCandidate>,
1897}
1898
1899#[derive(Debug, Clone, PartialEq)]
1901pub struct ExplainInterpolationStep {
1902 pub placeholder: String,
1904 pub raw_value: ConfigValue,
1906 pub value: ConfigValue,
1908 pub source: ConfigSource,
1910 pub scope: Scope,
1912 pub origin: Option<String>,
1914}
1915
1916#[derive(Debug, Clone, PartialEq)]
1918pub struct ExplainInterpolation {
1919 pub template: String,
1921 pub steps: Vec<ExplainInterpolationStep>,
1923}
1924
1925#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1927pub enum ActiveProfileSource {
1928 Override,
1930 DefaultProfile,
1932}
1933
1934impl ActiveProfileSource {
1935 pub fn as_str(self) -> &'static str {
1937 match self {
1938 Self::Override => "override",
1939 Self::DefaultProfile => "profile.default",
1940 }
1941 }
1942}
1943
1944#[derive(Debug, Clone, PartialEq)]
1946pub struct ConfigExplain {
1947 pub key: String,
1949 pub active_profile: String,
1951 pub active_profile_source: ActiveProfileSource,
1953 pub terminal: Option<String>,
1955 pub known_profiles: BTreeSet<String>,
1957 pub layers: Vec<ExplainLayer>,
1959 pub final_entry: Option<ResolvedValue>,
1961 pub interpolation: Option<ExplainInterpolation>,
1963}
1964
1965#[derive(Debug, Clone, PartialEq)]
1967pub struct BootstrapConfigExplain {
1968 pub key: String,
1970 pub active_profile: String,
1972 pub active_profile_source: ActiveProfileSource,
1974 pub terminal: Option<String>,
1976 pub known_profiles: BTreeSet<String>,
1978 pub layers: Vec<ExplainLayer>,
1980 pub final_entry: Option<ResolvedValue>,
1982}
1983
1984#[derive(Debug, Clone, PartialEq)]
1986pub struct ResolvedConfig {
1987 pub(crate) active_profile: String,
1988 pub(crate) terminal: Option<String>,
1989 pub(crate) known_profiles: BTreeSet<String>,
1990 pub(crate) values: BTreeMap<String, ResolvedValue>,
1991 pub(crate) aliases: BTreeMap<String, ResolvedValue>,
1992}
1993
1994impl ResolvedConfig {
1995 pub fn active_profile(&self) -> &str {
1997 &self.active_profile
1998 }
1999
2000 pub fn terminal(&self) -> Option<&str> {
2002 self.terminal.as_deref()
2003 }
2004
2005 pub fn known_profiles(&self) -> &BTreeSet<String> {
2007 &self.known_profiles
2008 }
2009
2010 pub fn values(&self) -> &BTreeMap<String, ResolvedValue> {
2012 &self.values
2013 }
2014
2015 pub fn aliases(&self) -> &BTreeMap<String, ResolvedValue> {
2017 &self.aliases
2018 }
2019
2020 pub fn get(&self, key: &str) -> Option<&ConfigValue> {
2022 self.values.get(key).map(|entry| &entry.value)
2023 }
2024
2025 pub fn get_string(&self, key: &str) -> Option<&str> {
2043 match self.get(key).map(ConfigValue::reveal) {
2044 Some(ConfigValue::String(value)) => Some(value),
2045 _ => None,
2046 }
2047 }
2048
2049 pub fn get_bool(&self, key: &str) -> Option<bool> {
2067 match self.get(key).map(ConfigValue::reveal) {
2068 Some(ConfigValue::Bool(value)) => Some(*value),
2069 _ => None,
2070 }
2071 }
2072
2073 pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
2110 match self.get(key).map(ConfigValue::reveal) {
2111 Some(ConfigValue::List(values)) => Some(
2112 values
2113 .iter()
2114 .filter_map(|value| match value {
2115 ConfigValue::String(text) => Some(text.clone()),
2116 ConfigValue::Secret(secret) => match secret.expose() {
2117 ConfigValue::String(text) => Some(text.clone()),
2118 _ => None,
2119 },
2120 _ => None,
2121 })
2122 .collect(),
2123 ),
2124 Some(ConfigValue::String(value)) => Some(vec![value.clone()]),
2125 Some(ConfigValue::Secret(secret)) => match secret.expose() {
2126 ConfigValue::String(value) => Some(vec![value.clone()]),
2127 _ => None,
2128 },
2129 _ => None,
2130 }
2131 }
2132
2133 pub fn get_value_entry(&self, key: &str) -> Option<&ResolvedValue> {
2135 self.values.get(key)
2136 }
2137
2138 pub fn get_alias_entry(&self, key: &str) -> Option<&ResolvedValue> {
2140 let normalized = if key.trim().to_ascii_lowercase().starts_with("alias.") {
2141 key.trim().to_ascii_lowercase()
2142 } else {
2143 format!("alias.{}", key.trim().to_ascii_lowercase())
2144 };
2145 self.aliases.get(&normalized)
2146 }
2147}
2148
2149fn flatten_table(
2150 layer: &mut ConfigLayer,
2151 table: &toml::value::Table,
2152 prefix: &str,
2153 scope: &Scope,
2154) -> Result<(), ConfigError> {
2155 for (key, value) in table {
2156 let full_key = if prefix.is_empty() {
2157 key.to_string()
2158 } else {
2159 format!("{prefix}.{key}")
2160 };
2161
2162 flatten_key_value(layer, &full_key, value, scope)?;
2163 }
2164
2165 Ok(())
2166}
2167
2168fn flatten_key_value(
2169 layer: &mut ConfigLayer,
2170 key: &str,
2171 value: &toml::Value,
2172 scope: &Scope,
2173) -> Result<(), ConfigError> {
2174 match value {
2175 toml::Value::Table(table) => flatten_table(layer, table, key, scope),
2176 _ => {
2177 let converted = ConfigValue::from_toml(key, value)?;
2178 builtin_config_schema().validate_writable_key(key)?;
2179 validate_key_scope(key, scope)?;
2180 validate_bootstrap_value(key, &converted)?;
2181 layer.insert(key.to_string(), converted, scope.clone());
2182 Ok(())
2183 }
2184 }
2185}
2186
2187pub fn bootstrap_key_spec(key: &str) -> Option<BootstrapKeySpec> {
2189 builtin_config_schema().bootstrap_key_spec(key)
2190}
2191
2192pub fn is_bootstrap_only_key(key: &str) -> bool {
2195 bootstrap_key_spec(key).is_some_and(|spec| !spec.runtime_visible)
2196}
2197
2198pub fn is_alias_key(key: &str) -> bool {
2210 key.trim().to_ascii_lowercase().starts_with("alias.")
2211}
2212
2213pub fn validate_key_scope(key: &str, scope: &Scope) -> Result<(), ConfigError> {
2215 builtin_config_schema().validate_key_scope(key, scope)
2216}
2217
2218pub fn validate_bootstrap_value(key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
2220 builtin_config_schema().validate_bootstrap_value(key, value)
2221}
2222
2223fn adapt_value_for_schema(
2224 key: &str,
2225 value: &ConfigValue,
2226 schema: &SchemaEntry,
2227) -> Result<ConfigValue, ConfigError> {
2228 let (is_secret, value) = match value {
2229 ConfigValue::Secret(secret) => (true, secret.expose()),
2230 other => (false, other),
2231 };
2232
2233 let adapted = match schema.value_type {
2234 SchemaValueType::String => match value {
2235 ConfigValue::String(value) => ConfigValue::String(value.clone()),
2236 other => {
2237 return Err(ConfigError::InvalidValueType {
2238 key: key.to_string(),
2239 expected: SchemaValueType::String,
2240 actual: value_type_name(other).to_string(),
2241 });
2242 }
2243 },
2244 SchemaValueType::Bool => match value {
2245 ConfigValue::Bool(value) => ConfigValue::Bool(*value),
2246 ConfigValue::String(value) => {
2247 ConfigValue::Bool(parse_bool(value).ok_or_else(|| {
2248 ConfigError::InvalidValueType {
2249 key: key.to_string(),
2250 expected: SchemaValueType::Bool,
2251 actual: "string".to_string(),
2252 }
2253 })?)
2254 }
2255 other => {
2256 return Err(ConfigError::InvalidValueType {
2257 key: key.to_string(),
2258 expected: SchemaValueType::Bool,
2259 actual: value_type_name(other).to_string(),
2260 });
2261 }
2262 },
2263 SchemaValueType::Integer => match value {
2264 ConfigValue::Integer(value) => ConfigValue::Integer(*value),
2265 ConfigValue::String(value) => {
2266 let parsed =
2267 value
2268 .trim()
2269 .parse::<i64>()
2270 .map_err(|_| ConfigError::InvalidValueType {
2271 key: key.to_string(),
2272 expected: SchemaValueType::Integer,
2273 actual: "string".to_string(),
2274 })?;
2275 ConfigValue::Integer(parsed)
2276 }
2277 other => {
2278 return Err(ConfigError::InvalidValueType {
2279 key: key.to_string(),
2280 expected: SchemaValueType::Integer,
2281 actual: value_type_name(other).to_string(),
2282 });
2283 }
2284 },
2285 SchemaValueType::Float => match value {
2286 ConfigValue::Float(value) => ConfigValue::Float(*value),
2287 ConfigValue::Integer(value) => ConfigValue::Float(*value as f64),
2288 ConfigValue::String(value) => {
2289 let parsed =
2290 value
2291 .trim()
2292 .parse::<f64>()
2293 .map_err(|_| ConfigError::InvalidValueType {
2294 key: key.to_string(),
2295 expected: SchemaValueType::Float,
2296 actual: "string".to_string(),
2297 })?;
2298 ConfigValue::Float(parsed)
2299 }
2300 other => {
2301 return Err(ConfigError::InvalidValueType {
2302 key: key.to_string(),
2303 expected: SchemaValueType::Float,
2304 actual: value_type_name(other).to_string(),
2305 });
2306 }
2307 },
2308 SchemaValueType::StringList => match value {
2309 ConfigValue::List(values) => {
2310 let mut out = Vec::with_capacity(values.len());
2311 for value in values {
2312 match value {
2313 ConfigValue::String(value) => out.push(ConfigValue::String(value.clone())),
2314 ConfigValue::Secret(secret) => match secret.expose() {
2315 ConfigValue::String(value) => {
2316 out.push(ConfigValue::String(value.clone()))
2317 }
2318 other => {
2319 return Err(ConfigError::InvalidValueType {
2320 key: key.to_string(),
2321 expected: SchemaValueType::StringList,
2322 actual: value_type_name(other).to_string(),
2323 });
2324 }
2325 },
2326 other => {
2327 return Err(ConfigError::InvalidValueType {
2328 key: key.to_string(),
2329 expected: SchemaValueType::StringList,
2330 actual: value_type_name(other).to_string(),
2331 });
2332 }
2333 }
2334 }
2335 ConfigValue::List(out)
2336 }
2337 ConfigValue::String(value) => {
2338 let items = parse_string_list(value);
2339 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
2340 }
2341 ConfigValue::Secret(secret) => match secret.expose() {
2342 ConfigValue::String(value) => {
2343 let items = parse_string_list(value);
2344 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
2345 }
2346 other => {
2347 return Err(ConfigError::InvalidValueType {
2348 key: key.to_string(),
2349 expected: SchemaValueType::StringList,
2350 actual: value_type_name(other).to_string(),
2351 });
2352 }
2353 },
2354 other => {
2355 return Err(ConfigError::InvalidValueType {
2356 key: key.to_string(),
2357 expected: SchemaValueType::StringList,
2358 actual: value_type_name(other).to_string(),
2359 });
2360 }
2361 },
2362 };
2363
2364 let adapted = if is_secret {
2365 adapted.into_secret()
2366 } else {
2367 adapted
2368 };
2369
2370 if let Some(allowed_values) = &schema.allowed_values
2371 && let ConfigValue::String(value) = adapted.reveal()
2372 {
2373 let normalized = value.to_ascii_lowercase();
2374 if !allowed_values.contains(&normalized) {
2375 return Err(ConfigError::InvalidEnumValue {
2376 key: key.to_string(),
2377 value: value.clone(),
2378 allowed: allowed_values.clone(),
2379 });
2380 }
2381 }
2382
2383 Ok(adapted)
2384}
2385
2386fn adapt_dynamic_value_for_schema(
2387 key: &str,
2388 value: &ConfigValue,
2389 kind: DynamicSchemaKeyKind,
2390) -> Result<ConfigValue, ConfigError> {
2391 let adapted = match kind {
2392 DynamicSchemaKeyKind::PluginCommandState | DynamicSchemaKeyKind::PluginCommandProvider => {
2393 adapt_value_for_schema(key, value, &SchemaEntry::string())?
2394 }
2395 };
2396
2397 if matches!(kind, DynamicSchemaKeyKind::PluginCommandState) {
2398 validate_allowed_values(key, &adapted, Some(&["enabled", "disabled"]))?;
2399 }
2400
2401 Ok(adapted)
2402}
2403
2404fn validate_allowed_values(
2405 key: &str,
2406 value: &ConfigValue,
2407 allowed: Option<&[&str]>,
2408) -> Result<(), ConfigError> {
2409 let Some(allowed) = allowed else {
2410 return Ok(());
2411 };
2412 if let ConfigValue::String(current) = value {
2413 let normalized = current.to_ascii_lowercase();
2414 if !allowed.iter().any(|candidate| *candidate == normalized) {
2415 return Err(ConfigError::InvalidEnumValue {
2416 key: key.to_string(),
2417 value: current.clone(),
2418 allowed: allowed.iter().map(|value| (*value).to_string()).collect(),
2419 });
2420 }
2421 }
2422 Ok(())
2423}
2424
2425fn dynamic_schema_key_kind(key: &str) -> Option<DynamicSchemaKeyKind> {
2426 let normalized = key.trim().to_ascii_lowercase();
2427 let remainder = normalized.strip_prefix("plugins.")?;
2428 let (command, field) = remainder.rsplit_once('.')?;
2429 if command.trim().is_empty() {
2430 return None;
2431 }
2432 match field {
2433 "state" => Some(DynamicSchemaKeyKind::PluginCommandState),
2434 "provider" => Some(DynamicSchemaKeyKind::PluginCommandProvider),
2435 _ => None,
2436 }
2437}
2438
2439fn parse_bool(value: &str) -> Option<bool> {
2440 match value.trim().to_ascii_lowercase().as_str() {
2441 "true" => Some(true),
2442 "false" => Some(false),
2443 _ => None,
2444 }
2445}
2446
2447fn parse_string_list(value: &str) -> Vec<String> {
2448 let trimmed = value.trim();
2449 if trimmed.is_empty() {
2450 return Vec::new();
2451 }
2452
2453 let inner = trimmed
2454 .strip_prefix('[')
2455 .and_then(|value| value.strip_suffix(']'))
2456 .unwrap_or(trimmed);
2457
2458 inner
2459 .split(',')
2460 .map(|value| value.trim())
2461 .filter(|value| !value.is_empty())
2462 .map(|value| {
2463 value
2464 .strip_prefix('"')
2465 .and_then(|value| value.strip_suffix('"'))
2466 .or_else(|| {
2467 value
2468 .strip_prefix('\'')
2469 .and_then(|value| value.strip_suffix('\''))
2470 })
2471 .unwrap_or(value)
2472 .to_string()
2473 })
2474 .collect()
2475}
2476
2477fn value_type_name(value: &ConfigValue) -> &'static str {
2478 match value.reveal() {
2479 ConfigValue::String(_) => "string",
2480 ConfigValue::Bool(_) => "bool",
2481 ConfigValue::Integer(_) => "integer",
2482 ConfigValue::Float(_) => "float",
2483 ConfigValue::List(_) => "list",
2484 ConfigValue::Secret(_) => "string",
2485 }
2486}
2487
2488pub(crate) fn parse_env_key(key: &str) -> Result<EnvKeySpec, ConfigError> {
2489 let Some(raw) = key.strip_prefix("OSP__") else {
2490 return Err(ConfigError::InvalidEnvOverride {
2491 key: key.to_string(),
2492 reason: "missing OSP__ prefix".to_string(),
2493 });
2494 };
2495
2496 let parts = raw
2497 .split("__")
2498 .filter(|part| !part.is_empty())
2499 .collect::<Vec<&str>>();
2500
2501 if parts.is_empty() {
2502 return Err(ConfigError::InvalidEnvOverride {
2503 key: key.to_string(),
2504 reason: "missing key path".to_string(),
2505 });
2506 }
2507
2508 let mut cursor = 0usize;
2509 let mut terminal: Option<String> = None;
2510 let mut profile: Option<String> = None;
2511
2512 while cursor < parts.len() {
2513 let part = parts[cursor];
2514 if part.eq_ignore_ascii_case("TERM") {
2515 if terminal.is_some() {
2516 return Err(ConfigError::InvalidEnvOverride {
2517 key: key.to_string(),
2518 reason: "TERM scope specified more than once".to_string(),
2519 });
2520 }
2521 let term = parts
2522 .get(cursor + 1)
2523 .ok_or_else(|| ConfigError::InvalidEnvOverride {
2524 key: key.to_string(),
2525 reason: "TERM requires a terminal name".to_string(),
2526 })?;
2527 terminal = Some(normalize_identifier(term));
2528 cursor += 2;
2529 continue;
2530 }
2531
2532 if part.eq_ignore_ascii_case("PROFILE") {
2533 if remaining_parts_are_bootstrap_profile_default(&parts[cursor..]) {
2536 break;
2537 }
2538 if profile.is_some() {
2539 return Err(ConfigError::InvalidEnvOverride {
2540 key: key.to_string(),
2541 reason: "PROFILE scope specified more than once".to_string(),
2542 });
2543 }
2544 let profile_name =
2545 parts
2546 .get(cursor + 1)
2547 .ok_or_else(|| ConfigError::InvalidEnvOverride {
2548 key: key.to_string(),
2549 reason: "PROFILE requires a profile name".to_string(),
2550 })?;
2551 profile = Some(normalize_identifier(profile_name));
2552 cursor += 2;
2553 continue;
2554 }
2555
2556 break;
2557 }
2558
2559 let key_parts = &parts[cursor..];
2560 if key_parts.is_empty() {
2561 return Err(ConfigError::InvalidEnvOverride {
2562 key: key.to_string(),
2563 reason: "missing final config key".to_string(),
2564 });
2565 }
2566
2567 let dotted_key = key_parts
2568 .iter()
2569 .map(|part| part.to_ascii_lowercase())
2570 .collect::<Vec<String>>()
2571 .join(".");
2572
2573 Ok(EnvKeySpec {
2574 key: dotted_key,
2575 scope: Scope { profile, terminal },
2576 })
2577}
2578
2579fn remaining_parts_are_bootstrap_profile_default(parts: &[&str]) -> bool {
2580 matches!(parts, [profile, default]
2581 if profile.eq_ignore_ascii_case("PROFILE")
2582 && default.eq_ignore_ascii_case("DEFAULT"))
2583}
2584
2585pub(crate) fn normalize_scope(scope: Scope) -> Scope {
2586 Scope {
2587 profile: normalize_optional_identifier(scope.profile),
2588 terminal: normalize_optional_identifier(scope.terminal),
2589 }
2590}
2591
2592#[cfg(test)]
2593mod tests;