1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::{Display, Formatter};
3use std::sync::OnceLock;
4
5use crate::config::ConfigError;
6
7#[derive(Debug, Clone, PartialEq)]
9pub struct TomlEditResult {
10 pub previous: Option<ConfigValue>,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub enum ConfigSource {
17 BuiltinDefaults,
19 PresentationDefaults,
21 ConfigFile,
23 Secrets,
25 Environment,
27 Cli,
29 Session,
31 Derived,
33}
34
35impl Display for ConfigSource {
36 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
37 let value = match self {
38 ConfigSource::BuiltinDefaults => "defaults",
39 ConfigSource::PresentationDefaults => "presentation",
40 ConfigSource::ConfigFile => "file",
41 ConfigSource::Secrets => "secrets",
42 ConfigSource::Environment => "env",
43 ConfigSource::Cli => "cli",
44 ConfigSource::Session => "session",
45 ConfigSource::Derived => "derived",
46 };
47 write!(f, "{value}")
48 }
49}
50
51#[derive(Debug, Clone, PartialEq)]
53pub enum ConfigValue {
54 String(String),
56 Bool(bool),
58 Integer(i64),
60 Float(f64),
62 List(Vec<ConfigValue>),
64 Secret(SecretValue),
66}
67
68impl ConfigValue {
69 pub fn is_secret(&self) -> bool {
80 matches!(self, ConfigValue::Secret(_))
81 }
82
83 pub fn reveal(&self) -> &ConfigValue {
94 match self {
95 ConfigValue::Secret(secret) => secret.expose(),
96 other => other,
97 }
98 }
99
100 pub fn into_secret(self) -> ConfigValue {
111 match self {
112 ConfigValue::Secret(_) => self,
113 other => ConfigValue::Secret(SecretValue::new(other)),
114 }
115 }
116
117 pub(crate) fn from_toml(path: &str, value: &toml::Value) -> Result<Self, ConfigError> {
118 match value {
119 toml::Value::String(v) => Ok(Self::String(v.clone())),
120 toml::Value::Integer(v) => Ok(Self::Integer(*v)),
121 toml::Value::Float(v) => Ok(Self::Float(*v)),
122 toml::Value::Boolean(v) => Ok(Self::Bool(*v)),
123 toml::Value::Datetime(v) => Ok(Self::String(v.to_string())),
124 toml::Value::Array(values) => {
125 let mut out = Vec::with_capacity(values.len());
126 for item in values {
127 out.push(Self::from_toml(path, item)?);
128 }
129 Ok(Self::List(out))
130 }
131 toml::Value::Table(_) => Err(ConfigError::UnsupportedTomlValue {
132 path: path.to_string(),
133 kind: "table".to_string(),
134 }),
135 }
136 }
137
138 pub(crate) fn as_interpolation_string(
139 &self,
140 key: &str,
141 placeholder: &str,
142 ) -> Result<String, ConfigError> {
143 match self.reveal() {
144 ConfigValue::String(value) => Ok(value.clone()),
145 ConfigValue::Bool(value) => Ok(value.to_string()),
146 ConfigValue::Integer(value) => Ok(value.to_string()),
147 ConfigValue::Float(value) => Ok(value.to_string()),
148 ConfigValue::List(_) => Err(ConfigError::NonScalarPlaceholder {
149 key: key.to_string(),
150 placeholder: placeholder.to_string(),
151 }),
152 ConfigValue::Secret(_) => Err(ConfigError::NonScalarPlaceholder {
153 key: key.to_string(),
154 placeholder: placeholder.to_string(),
155 }),
156 }
157 }
158}
159
160#[derive(Clone, PartialEq)]
162pub struct SecretValue(Box<ConfigValue>);
163
164impl SecretValue {
165 pub fn new(value: ConfigValue) -> Self {
176 Self(Box::new(value))
177 }
178
179 pub fn expose(&self) -> &ConfigValue {
181 &self.0
182 }
183
184 pub fn into_inner(self) -> ConfigValue {
186 *self.0
187 }
188}
189
190impl std::fmt::Debug for SecretValue {
191 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
192 write!(f, "[REDACTED]")
193 }
194}
195
196impl Display for SecretValue {
197 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
198 write!(f, "[REDACTED]")
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum SchemaValueType {
205 String,
207 Bool,
209 Integer,
211 Float,
213 StringList,
215}
216
217impl Display for SchemaValueType {
218 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
219 let value = match self {
220 SchemaValueType::String => "string",
221 SchemaValueType::Bool => "bool",
222 SchemaValueType::Integer => "integer",
223 SchemaValueType::Float => "float",
224 SchemaValueType::StringList => "list",
225 };
226 write!(f, "{value}")
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum BootstrapPhase {
233 Path,
235 Profile,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub enum BootstrapScopeRule {
242 GlobalOnly,
244 GlobalOrTerminal,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum BootstrapValueRule {
251 NonEmptyString,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct BootstrapKeySpec {
258 pub key: &'static str,
260 pub phase: BootstrapPhase,
262 pub runtime_visible: bool,
264 pub scope_rule: BootstrapScopeRule,
266}
267
268impl BootstrapKeySpec {
269 fn allows_scope(&self, scope: &Scope) -> bool {
270 match self.scope_rule {
271 BootstrapScopeRule::GlobalOnly => scope.profile.is_none() && scope.terminal.is_none(),
272 BootstrapScopeRule::GlobalOrTerminal => scope.profile.is_none(),
273 }
274 }
275}
276
277#[derive(Debug, Clone)]
279pub struct SchemaEntry {
280 canonical_key: Option<&'static str>,
281 value_type: SchemaValueType,
282 required: bool,
283 writable: bool,
284 allowed_values: Option<Vec<String>>,
285 runtime_visible: bool,
286 bootstrap_phase: Option<BootstrapPhase>,
287 bootstrap_scope_rule: Option<BootstrapScopeRule>,
288 bootstrap_value_rule: Option<BootstrapValueRule>,
289}
290
291impl SchemaEntry {
292 pub fn string() -> Self {
304 Self {
305 canonical_key: None,
306 value_type: SchemaValueType::String,
307 required: false,
308 writable: true,
309 allowed_values: None,
310 runtime_visible: true,
311 bootstrap_phase: None,
312 bootstrap_scope_rule: None,
313 bootstrap_value_rule: None,
314 }
315 }
316
317 pub fn boolean() -> Self {
319 Self {
320 canonical_key: None,
321 value_type: SchemaValueType::Bool,
322 required: false,
323 writable: true,
324 allowed_values: None,
325 runtime_visible: true,
326 bootstrap_phase: None,
327 bootstrap_scope_rule: None,
328 bootstrap_value_rule: None,
329 }
330 }
331
332 pub fn integer() -> Self {
334 Self {
335 canonical_key: None,
336 value_type: SchemaValueType::Integer,
337 required: false,
338 writable: true,
339 allowed_values: None,
340 runtime_visible: true,
341 bootstrap_phase: None,
342 bootstrap_scope_rule: None,
343 bootstrap_value_rule: None,
344 }
345 }
346
347 pub fn float() -> Self {
349 Self {
350 canonical_key: None,
351 value_type: SchemaValueType::Float,
352 required: false,
353 writable: true,
354 allowed_values: None,
355 runtime_visible: true,
356 bootstrap_phase: None,
357 bootstrap_scope_rule: None,
358 bootstrap_value_rule: None,
359 }
360 }
361
362 pub fn string_list() -> Self {
364 Self {
365 canonical_key: None,
366 value_type: SchemaValueType::StringList,
367 required: false,
368 writable: true,
369 allowed_values: None,
370 runtime_visible: true,
371 bootstrap_phase: None,
372 bootstrap_scope_rule: None,
373 bootstrap_value_rule: None,
374 }
375 }
376
377 pub fn required(mut self) -> Self {
379 self.required = true;
380 self
381 }
382
383 pub fn read_only(mut self) -> Self {
385 self.writable = false;
386 self
387 }
388
389 pub fn bootstrap_only(mut self, phase: BootstrapPhase, scope_rule: BootstrapScopeRule) -> Self {
391 self.runtime_visible = false;
392 self.bootstrap_phase = Some(phase);
393 self.bootstrap_scope_rule = Some(scope_rule);
394 self
395 }
396
397 pub fn with_bootstrap_value_rule(mut self, rule: BootstrapValueRule) -> Self {
399 self.bootstrap_value_rule = Some(rule);
400 self
401 }
402
403 pub fn with_allowed_values<I, S>(mut self, values: I) -> Self
405 where
406 I: IntoIterator<Item = S>,
407 S: AsRef<str>,
408 {
409 self.allowed_values = Some(
410 values
411 .into_iter()
412 .map(|value| value.as_ref().to_ascii_lowercase())
413 .collect(),
414 );
415 self
416 }
417
418 pub fn value_type(&self) -> SchemaValueType {
420 self.value_type
421 }
422
423 pub fn allowed_values(&self) -> Option<&[String]> {
425 self.allowed_values.as_deref()
426 }
427
428 pub fn runtime_visible(&self) -> bool {
430 self.runtime_visible
431 }
432
433 pub fn writable(&self) -> bool {
435 self.writable
436 }
437
438 fn with_canonical_key(mut self, key: &'static str) -> Self {
439 self.canonical_key = Some(key);
440 self
441 }
442
443 fn bootstrap_spec(&self) -> Option<BootstrapKeySpec> {
444 Some(BootstrapKeySpec {
445 key: self.canonical_key?,
446 phase: self.bootstrap_phase?,
447 runtime_visible: self.runtime_visible,
448 scope_rule: self.bootstrap_scope_rule?,
449 })
450 }
451
452 fn validate_bootstrap_value(&self, key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
453 match self.bootstrap_value_rule {
454 Some(BootstrapValueRule::NonEmptyString) => match value.reveal() {
455 ConfigValue::String(current) if !current.trim().is_empty() => Ok(()),
456 ConfigValue::String(current) => Err(ConfigError::InvalidBootstrapValue {
457 key: key.to_string(),
458 reason: format!("expected a non-empty string, got {current:?}"),
459 }),
460 other => Err(ConfigError::InvalidBootstrapValue {
461 key: key.to_string(),
462 reason: format!("expected string, got {other:?}"),
463 }),
464 },
465 None => Ok(()),
466 }
467 }
468}
469
470#[derive(Debug, Clone)]
472pub struct ConfigSchema {
473 entries: BTreeMap<String, SchemaEntry>,
474 allow_extensions_namespace: bool,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478enum DynamicSchemaKeyKind {
479 PluginCommandState,
480 PluginCommandProvider,
481}
482
483impl Default for ConfigSchema {
484 fn default() -> Self {
485 builtin_config_schema().clone()
486 }
487}
488
489impl ConfigSchema {
490 fn builtin() -> Self {
491 let mut schema = Self {
492 entries: BTreeMap::new(),
493 allow_extensions_namespace: true,
494 };
495
496 schema.insert(
497 "profile.default",
498 SchemaEntry::string()
499 .bootstrap_only(
500 BootstrapPhase::Profile,
501 BootstrapScopeRule::GlobalOrTerminal,
502 )
503 .with_bootstrap_value_rule(BootstrapValueRule::NonEmptyString),
504 );
505 schema.insert(
506 "profile.active",
507 SchemaEntry::string().required().read_only(),
508 );
509 schema.insert("theme.name", SchemaEntry::string());
510 schema.insert("theme.path", SchemaEntry::string_list());
511 schema.insert("user.name", SchemaEntry::string());
512 schema.insert("user.display_name", SchemaEntry::string());
513 schema.insert("user.full_name", SchemaEntry::string());
514 schema.insert("domain", SchemaEntry::string());
515
516 schema.insert(
517 "ui.format",
518 SchemaEntry::string()
519 .with_allowed_values(["auto", "guide", "json", "table", "md", "mreg", "value"]),
520 );
521 schema.insert(
522 "ui.mode",
523 SchemaEntry::string().with_allowed_values(["auto", "plain", "rich"]),
524 );
525 schema.insert(
526 "ui.presentation",
527 SchemaEntry::string().with_allowed_values([
528 "expressive",
529 "compact",
530 "austere",
531 "gammel-og-bitter",
532 ]),
533 );
534 schema.insert(
535 "ui.color.mode",
536 SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
537 );
538 schema.insert(
539 "ui.unicode.mode",
540 SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
541 );
542 schema.insert("ui.width", SchemaEntry::integer());
543 schema.insert("ui.margin", SchemaEntry::integer());
544 schema.insert("ui.indent", SchemaEntry::integer());
545 schema.insert(
546 "ui.help.level",
547 SchemaEntry::string()
548 .with_allowed_values(["inherit", "none", "tiny", "normal", "verbose"]),
549 );
550 schema.insert(
551 "ui.guide.default_format",
552 SchemaEntry::string().with_allowed_values(["guide", "inherit", "none"]),
553 );
554 schema.insert(
555 "ui.messages.layout",
556 SchemaEntry::string().with_allowed_values(["grouped", "minimal"]),
557 );
558 schema.insert(
559 "ui.chrome.frame",
560 SchemaEntry::string().with_allowed_values([
561 "none",
562 "top",
563 "bottom",
564 "top-bottom",
565 "square",
566 "round",
567 ]),
568 );
569 schema.insert(
570 "ui.chrome.rule_policy",
571 SchemaEntry::string().with_allowed_values([
572 "per-section",
573 "independent",
574 "separate",
575 "shared",
576 "stacked",
577 "list",
578 ]),
579 );
580 schema.insert(
581 "ui.table.overflow",
582 SchemaEntry::string().with_allowed_values([
583 "clip", "hidden", "crop", "ellipsis", "truncate", "wrap", "none", "visible",
584 ]),
585 );
586 schema.insert(
587 "ui.table.border",
588 SchemaEntry::string().with_allowed_values(["none", "square", "round"]),
589 );
590 schema.insert(
591 "ui.help.table_chrome",
592 SchemaEntry::string().with_allowed_values(["inherit", "none", "square", "round"]),
593 );
594 schema.insert("ui.help.entry_indent", SchemaEntry::string());
595 schema.insert("ui.help.entry_gap", SchemaEntry::string());
596 schema.insert("ui.help.section_spacing", SchemaEntry::string());
597 schema.insert("ui.short_list_max", SchemaEntry::integer());
598 schema.insert("ui.medium_list_max", SchemaEntry::integer());
599 schema.insert("ui.grid_padding", SchemaEntry::integer());
600 schema.insert("ui.grid_columns", SchemaEntry::integer());
601 schema.insert("ui.column_weight", SchemaEntry::integer());
602 schema.insert("ui.mreg.stack_min_col_width", SchemaEntry::integer());
603 schema.insert("ui.mreg.stack_overflow_ratio", SchemaEntry::integer());
604 schema.insert(
605 "ui.message.verbosity",
606 SchemaEntry::string()
607 .with_allowed_values(["error", "warning", "success", "info", "trace"]),
608 );
609 schema.insert("ui.prompt", SchemaEntry::string());
610 schema.insert("ui.prompt.secrets", SchemaEntry::boolean());
611 schema.insert("extensions.plugins.timeout_ms", SchemaEntry::integer());
612 schema.insert("extensions.plugins.discovery.path", SchemaEntry::boolean());
613 schema.insert("repl.prompt", SchemaEntry::string());
614 schema.insert(
615 "repl.input_mode",
616 SchemaEntry::string().with_allowed_values(["auto", "interactive", "basic"]),
617 );
618 schema.insert("repl.simple_prompt", SchemaEntry::boolean());
619 schema.insert("repl.shell_indicator", SchemaEntry::string());
620 schema.insert(
621 "repl.intro",
622 SchemaEntry::string().with_allowed_values(["none", "minimal", "compact", "full"]),
623 );
624 schema.insert("repl.intro_template.minimal", SchemaEntry::string());
625 schema.insert("repl.intro_template.compact", SchemaEntry::string());
626 schema.insert("repl.intro_template.full", SchemaEntry::string());
627 schema.insert("repl.history.path", SchemaEntry::string());
628 schema.insert("repl.history.max_entries", SchemaEntry::integer());
629 schema.insert("repl.history.enabled", SchemaEntry::boolean());
630 schema.insert("repl.history.dedupe", SchemaEntry::boolean());
631 schema.insert("repl.history.profile_scoped", SchemaEntry::boolean());
632 schema.insert("repl.history.menu_rows", SchemaEntry::integer());
633 schema.insert("repl.history.exclude", SchemaEntry::string_list());
634 schema.insert("session.cache.max_results", SchemaEntry::integer());
635 schema.insert("color.prompt.text", SchemaEntry::string());
636 schema.insert("color.prompt.command", SchemaEntry::string());
637 schema.insert("color.prompt.completion.text", SchemaEntry::string());
638 schema.insert("color.prompt.completion.background", SchemaEntry::string());
639 schema.insert("color.prompt.completion.highlight", SchemaEntry::string());
640 schema.insert("color.text", SchemaEntry::string());
641 schema.insert("color.text.muted", SchemaEntry::string());
642 schema.insert("color.key", SchemaEntry::string());
643 schema.insert("color.border", SchemaEntry::string());
644 schema.insert("color.table.header", SchemaEntry::string());
645 schema.insert("color.mreg.key", SchemaEntry::string());
646 schema.insert("color.value", SchemaEntry::string());
647 schema.insert("color.value.number", SchemaEntry::string());
648 schema.insert("color.value.bool_true", SchemaEntry::string());
649 schema.insert("color.value.bool_false", SchemaEntry::string());
650 schema.insert("color.value.null", SchemaEntry::string());
651 schema.insert("color.value.ipv4", SchemaEntry::string());
652 schema.insert("color.value.ipv6", SchemaEntry::string());
653 schema.insert("color.panel.border", SchemaEntry::string());
654 schema.insert("color.panel.title", SchemaEntry::string());
655 schema.insert("color.code", SchemaEntry::string());
656 schema.insert("color.json.key", SchemaEntry::string());
657 schema.insert("color.message.error", SchemaEntry::string());
658 schema.insert("color.message.warning", SchemaEntry::string());
659 schema.insert("color.message.success", SchemaEntry::string());
660 schema.insert("color.message.info", SchemaEntry::string());
661 schema.insert("color.message.trace", SchemaEntry::string());
662 schema.insert("auth.visible.builtins", SchemaEntry::string());
663 schema.insert("auth.visible.plugins", SchemaEntry::string());
664 schema.insert("debug.level", SchemaEntry::integer());
665 schema.insert("log.file.enabled", SchemaEntry::boolean());
666 schema.insert("log.file.path", SchemaEntry::string());
667 schema.insert(
668 "log.file.level",
669 SchemaEntry::string().with_allowed_values(["error", "warn", "info", "debug", "trace"]),
670 );
671
672 schema.insert("base.dir", SchemaEntry::string());
673
674 schema
675 }
676}
677
678impl ConfigSchema {
679 pub fn insert(&mut self, key: &'static str, entry: SchemaEntry) {
681 self.entries
682 .insert(key.to_string(), entry.with_canonical_key(key));
683 }
684
685 pub fn set_allow_extensions_namespace(&mut self, value: bool) {
687 self.allow_extensions_namespace = value;
688 }
689
690 pub fn is_known_key(&self, key: &str) -> bool {
692 self.entries.contains_key(key)
693 || self.is_extension_key(key)
694 || self.is_alias_key(key)
695 || dynamic_schema_key_kind(key).is_some()
696 }
697
698 pub fn is_runtime_visible_key(&self, key: &str) -> bool {
700 self.entries
701 .get(key)
702 .is_some_and(SchemaEntry::runtime_visible)
703 || self.is_extension_key(key)
704 || dynamic_schema_key_kind(key).is_some()
705 }
706
707 pub fn validate_writable_key(&self, key: &str) -> Result<(), ConfigError> {
709 let normalized = key.trim().to_ascii_lowercase();
710 if let Some(entry) = self.entries.get(&normalized)
711 && !entry.writable()
712 {
713 return Err(ConfigError::ReadOnlyConfigKey {
714 key: normalized,
715 reason: "derived at runtime".to_string(),
716 });
717 }
718 Ok(())
719 }
720
721 pub fn bootstrap_key_spec(&self, key: &str) -> Option<BootstrapKeySpec> {
723 let normalized = key.trim().to_ascii_lowercase();
724 self.entries
725 .get(&normalized)
726 .and_then(SchemaEntry::bootstrap_spec)
727 }
728
729 pub fn entries(&self) -> impl Iterator<Item = (&str, &SchemaEntry)> {
731 self.entries
732 .iter()
733 .map(|(key, entry)| (key.as_str(), entry))
734 }
735
736 pub fn expected_type(&self, key: &str) -> Option<SchemaValueType> {
738 self.entries
739 .get(key)
740 .map(|entry| entry.value_type)
741 .or_else(|| dynamic_schema_key_kind(key).map(|_| SchemaValueType::String))
742 }
743
744 pub fn parse_input_value(&self, key: &str, raw: &str) -> Result<ConfigValue, ConfigError> {
762 if !self.is_known_key(key) {
763 return Err(ConfigError::UnknownConfigKeys {
764 keys: vec![key.to_string()],
765 });
766 }
767 self.validate_writable_key(key)?;
768
769 let value = match self.expected_type(key) {
770 Some(SchemaValueType::String) | None => ConfigValue::String(raw.to_string()),
771 Some(SchemaValueType::Bool) => {
772 ConfigValue::Bool(
773 parse_bool(raw).ok_or_else(|| ConfigError::InvalidValueType {
774 key: key.to_string(),
775 expected: SchemaValueType::Bool,
776 actual: "string".to_string(),
777 })?,
778 )
779 }
780 Some(SchemaValueType::Integer) => {
781 let parsed =
782 raw.trim()
783 .parse::<i64>()
784 .map_err(|_| ConfigError::InvalidValueType {
785 key: key.to_string(),
786 expected: SchemaValueType::Integer,
787 actual: "string".to_string(),
788 })?;
789 ConfigValue::Integer(parsed)
790 }
791 Some(SchemaValueType::Float) => {
792 let parsed =
793 raw.trim()
794 .parse::<f64>()
795 .map_err(|_| ConfigError::InvalidValueType {
796 key: key.to_string(),
797 expected: SchemaValueType::Float,
798 actual: "string".to_string(),
799 })?;
800 ConfigValue::Float(parsed)
801 }
802 Some(SchemaValueType::StringList) => {
803 let items = parse_string_list(raw);
804 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
805 }
806 };
807
808 if let Some(entry) = self.entries.get(key) {
809 validate_allowed_values(
810 key,
811 &value,
812 entry
813 .allowed_values()
814 .map(|values| values.iter().map(String::as_str).collect::<Vec<_>>())
815 .as_deref(),
816 )?;
817 } else if let Some(DynamicSchemaKeyKind::PluginCommandState) = dynamic_schema_key_kind(key)
818 {
819 validate_allowed_values(key, &value, Some(&["enabled", "disabled"]))?;
820 }
821
822 Ok(value)
823 }
824
825 pub(crate) fn validate_and_adapt(
826 &self,
827 values: &mut BTreeMap<String, ResolvedValue>,
828 ) -> Result<(), ConfigError> {
829 let mut unknown = Vec::new();
830 for key in values.keys() {
831 if self.is_runtime_visible_key(key) {
832 continue;
833 }
834 unknown.push(key.clone());
835 }
836 if !unknown.is_empty() {
837 unknown.sort();
838 return Err(ConfigError::UnknownConfigKeys { keys: unknown });
839 }
840
841 for (key, entry) in &self.entries {
842 if entry.runtime_visible && entry.required && !values.contains_key(key) {
843 return Err(ConfigError::MissingRequiredKey { key: key.clone() });
844 }
845 }
846
847 for (key, resolved) in values.iter_mut() {
848 if let Some(kind) = dynamic_schema_key_kind(key) {
849 resolved.value = adapt_dynamic_value_for_schema(key, &resolved.value, kind)?;
850 continue;
851 }
852 let Some(schema_entry) = self.entries.get(key) else {
853 continue;
854 };
855 if !schema_entry.runtime_visible {
856 continue;
857 }
858 resolved.value = adapt_value_for_schema(key, &resolved.value, schema_entry)?;
859 }
860
861 Ok(())
862 }
863
864 fn is_extension_key(&self, key: &str) -> bool {
865 self.allow_extensions_namespace && key.starts_with("extensions.")
866 }
867
868 fn is_alias_key(&self, key: &str) -> bool {
869 key.starts_with("alias.")
870 }
871
872 pub fn validate_key_scope(&self, key: &str, scope: &Scope) -> Result<(), ConfigError> {
874 let normalized_scope = normalize_scope(scope.clone());
875 if let Some(spec) = self.bootstrap_key_spec(key)
876 && !spec.allows_scope(&normalized_scope)
877 {
878 return Err(ConfigError::InvalidBootstrapScope {
879 key: spec.key.to_string(),
880 profile: normalized_scope.profile,
881 terminal: normalized_scope.terminal,
882 });
883 }
884
885 Ok(())
886 }
887
888 pub fn validate_bootstrap_value(
890 &self,
891 key: &str,
892 value: &ConfigValue,
893 ) -> Result<(), ConfigError> {
894 let normalized = key.trim().to_ascii_lowercase();
895 let Some(entry) = self.entries.get(&normalized) else {
896 return Ok(());
897 };
898 entry.validate_bootstrap_value(&normalized, value)
899 }
900}
901
902fn builtin_config_schema() -> &'static ConfigSchema {
903 static BUILTIN_SCHEMA: OnceLock<ConfigSchema> = OnceLock::new();
904 BUILTIN_SCHEMA.get_or_init(ConfigSchema::builtin)
905}
906
907impl Display for ConfigValue {
908 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
909 match self {
910 ConfigValue::String(v) => write!(f, "{v}"),
911 ConfigValue::Bool(v) => write!(f, "{v}"),
912 ConfigValue::Integer(v) => write!(f, "{v}"),
913 ConfigValue::Float(v) => write!(f, "{v}"),
914 ConfigValue::List(v) => {
915 let joined = v
916 .iter()
917 .map(ToString::to_string)
918 .collect::<Vec<String>>()
919 .join(",");
920 write!(f, "[{joined}]")
921 }
922 ConfigValue::Secret(secret) => write!(f, "{secret}"),
923 }
924 }
925}
926
927impl From<&str> for ConfigValue {
928 fn from(value: &str) -> Self {
929 ConfigValue::String(value.to_string())
930 }
931}
932
933impl From<String> for ConfigValue {
934 fn from(value: String) -> Self {
935 ConfigValue::String(value)
936 }
937}
938
939impl From<bool> for ConfigValue {
940 fn from(value: bool) -> Self {
941 ConfigValue::Bool(value)
942 }
943}
944
945impl From<i64> for ConfigValue {
946 fn from(value: i64) -> Self {
947 ConfigValue::Integer(value)
948 }
949}
950
951impl From<f64> for ConfigValue {
952 fn from(value: f64) -> Self {
953 ConfigValue::Float(value)
954 }
955}
956
957impl From<Vec<String>> for ConfigValue {
958 fn from(values: Vec<String>) -> Self {
959 ConfigValue::List(values.into_iter().map(ConfigValue::String).collect())
960 }
961}
962
963#[derive(Debug, Clone, Default, PartialEq, Eq)]
965pub struct Scope {
966 pub profile: Option<String>,
968 pub terminal: Option<String>,
970}
971
972impl Scope {
973 pub fn global() -> Self {
983 Self::default()
984 }
985
986 pub fn profile(profile: &str) -> Self {
998 Self {
999 profile: Some(normalize_identifier(profile)),
1000 terminal: None,
1001 }
1002 }
1003
1004 pub fn terminal(terminal: &str) -> Self {
1006 Self {
1007 profile: None,
1008 terminal: Some(normalize_identifier(terminal)),
1009 }
1010 }
1011
1012 pub fn profile_terminal(profile: &str, terminal: &str) -> Self {
1014 Self {
1015 profile: Some(normalize_identifier(profile)),
1016 terminal: Some(normalize_identifier(terminal)),
1017 }
1018 }
1019}
1020
1021#[derive(Debug, Clone, PartialEq)]
1023pub struct LayerEntry {
1024 pub key: String,
1026 pub value: ConfigValue,
1028 pub scope: Scope,
1030 pub origin: Option<String>,
1032}
1033
1034#[derive(Debug, Clone, Default)]
1036pub struct ConfigLayer {
1037 pub(crate) entries: Vec<LayerEntry>,
1038}
1039
1040impl ConfigLayer {
1041 pub fn entries(&self) -> &[LayerEntry] {
1043 &self.entries
1044 }
1045
1046 pub fn set<K, V>(&mut self, key: K, value: V)
1061 where
1062 K: Into<String>,
1063 V: Into<ConfigValue>,
1064 {
1065 self.insert(key, value, Scope::global());
1066 }
1067
1068 pub fn set_for_profile<K, V>(&mut self, profile: &str, key: K, value: V)
1070 where
1071 K: Into<String>,
1072 V: Into<ConfigValue>,
1073 {
1074 self.insert(key, value, Scope::profile(profile));
1075 }
1076
1077 pub fn set_for_terminal<K, V>(&mut self, terminal: &str, key: K, value: V)
1079 where
1080 K: Into<String>,
1081 V: Into<ConfigValue>,
1082 {
1083 self.insert(key, value, Scope::terminal(terminal));
1084 }
1085
1086 pub fn set_for_profile_terminal<K, V>(
1088 &mut self,
1089 profile: &str,
1090 terminal: &str,
1091 key: K,
1092 value: V,
1093 ) where
1094 K: Into<String>,
1095 V: Into<ConfigValue>,
1096 {
1097 self.insert(key, value, Scope::profile_terminal(profile, terminal));
1098 }
1099
1100 pub fn insert<K, V>(&mut self, key: K, value: V, scope: Scope)
1102 where
1103 K: Into<String>,
1104 V: Into<ConfigValue>,
1105 {
1106 self.entries.push(LayerEntry {
1107 key: key.into(),
1108 value: value.into(),
1109 scope: normalize_scope(scope),
1110 origin: None,
1111 });
1112 }
1113
1114 pub fn insert_with_origin<K, V, O>(&mut self, key: K, value: V, scope: Scope, origin: Option<O>)
1116 where
1117 K: Into<String>,
1118 V: Into<ConfigValue>,
1119 O: Into<String>,
1120 {
1121 self.entries.push(LayerEntry {
1122 key: key.into(),
1123 value: value.into(),
1124 scope: normalize_scope(scope),
1125 origin: origin.map(Into::into),
1126 });
1127 }
1128
1129 pub fn mark_all_secret(&mut self) {
1131 for entry in &mut self.entries {
1132 if !entry.value.is_secret() {
1133 entry.value = entry.value.clone().into_secret();
1134 }
1135 }
1136 }
1137
1138 pub fn remove_scoped(&mut self, key: &str, scope: &Scope) -> Option<ConfigValue> {
1153 let normalized_scope = normalize_scope(scope.clone());
1154 let index = self
1155 .entries
1156 .iter()
1157 .rposition(|entry| entry.key == key && entry.scope == normalized_scope)?;
1158 Some(self.entries.remove(index).value)
1159 }
1160
1161 pub fn from_toml_str(raw: &str) -> Result<Self, ConfigError> {
1179 let parsed = raw
1180 .parse::<toml::Value>()
1181 .map_err(|err| ConfigError::TomlParse(err.to_string()))?;
1182
1183 let root = parsed.as_table().ok_or(ConfigError::TomlRootMustBeTable)?;
1184 let mut layer = ConfigLayer::default();
1185
1186 for (section, value) in root {
1187 match section.as_str() {
1188 "default" => {
1189 let table = value
1190 .as_table()
1191 .ok_or_else(|| ConfigError::InvalidSection {
1192 section: "default".to_string(),
1193 expected: "table".to_string(),
1194 })?;
1195 flatten_table(&mut layer, table, "", &Scope::global())?;
1196 }
1197 "profile" => {
1198 let profiles = value
1199 .as_table()
1200 .ok_or_else(|| ConfigError::InvalidSection {
1201 section: "profile".to_string(),
1202 expected: "table".to_string(),
1203 })?;
1204 for (profile, profile_table_value) in profiles {
1205 let profile_table = profile_table_value.as_table().ok_or_else(|| {
1206 ConfigError::InvalidSection {
1207 section: format!("profile.{profile}"),
1208 expected: "table".to_string(),
1209 }
1210 })?;
1211 flatten_table(&mut layer, profile_table, "", &Scope::profile(profile))?;
1212 }
1213 }
1214 "terminal" => {
1215 let terminals =
1216 value
1217 .as_table()
1218 .ok_or_else(|| ConfigError::InvalidSection {
1219 section: "terminal".to_string(),
1220 expected: "table".to_string(),
1221 })?;
1222
1223 for (terminal, terminal_table_value) in terminals {
1224 let terminal_table = terminal_table_value.as_table().ok_or_else(|| {
1225 ConfigError::InvalidSection {
1226 section: format!("terminal.{terminal}"),
1227 expected: "table".to_string(),
1228 }
1229 })?;
1230
1231 for (key, terminal_value) in terminal_table {
1232 if key == "profile" {
1233 continue;
1234 }
1235
1236 flatten_key_value(
1237 &mut layer,
1238 key,
1239 terminal_value,
1240 &Scope::terminal(terminal),
1241 )?;
1242 }
1243
1244 if let Some(profile_section) = terminal_table.get("profile") {
1245 let profile_tables = profile_section.as_table().ok_or_else(|| {
1246 ConfigError::InvalidSection {
1247 section: format!("terminal.{terminal}.profile"),
1248 expected: "table".to_string(),
1249 }
1250 })?;
1251
1252 for (profile_key, profile_value) in profile_tables {
1253 if let Some(profile_table) = profile_value.as_table() {
1254 flatten_table(
1255 &mut layer,
1256 profile_table,
1257 "",
1258 &Scope::profile_terminal(profile_key, terminal),
1259 )?;
1260 } else {
1261 flatten_key_value(
1262 &mut layer,
1263 &format!("profile.{profile_key}"),
1264 profile_value,
1265 &Scope::terminal(terminal),
1266 )?;
1267 }
1268 }
1269 }
1270 }
1271 }
1272 unknown => {
1273 return Err(ConfigError::UnknownTopLevelSection(unknown.to_string()));
1274 }
1275 }
1276 }
1277
1278 Ok(layer)
1279 }
1280
1281 pub fn from_env_iter<I, K, V>(vars: I) -> Result<Self, ConfigError>
1283 where
1284 I: IntoIterator<Item = (K, V)>,
1285 K: AsRef<str>,
1286 V: AsRef<str>,
1287 {
1288 let mut layer = ConfigLayer::default();
1289
1290 for (name, value) in vars {
1291 let key = name.as_ref();
1292 if !key.starts_with("OSP__") {
1293 continue;
1294 }
1295
1296 let spec = parse_env_key(key)?;
1297 builtin_config_schema().validate_writable_key(&spec.key)?;
1298 validate_key_scope(&spec.key, &spec.scope)?;
1299 let converted = ConfigValue::String(value.as_ref().to_string());
1300 validate_bootstrap_value(&spec.key, &converted)?;
1301 layer.insert_with_origin(spec.key, converted, spec.scope, Some(key.to_string()));
1302 }
1303
1304 Ok(layer)
1305 }
1306
1307 pub(crate) fn validate_entries(&self) -> Result<(), ConfigError> {
1308 for entry in &self.entries {
1309 builtin_config_schema().validate_writable_key(&entry.key)?;
1310 validate_key_scope(&entry.key, &entry.scope)?;
1311 validate_bootstrap_value(&entry.key, &entry.value)?;
1312 }
1313
1314 Ok(())
1315 }
1316}
1317
1318pub(crate) struct EnvKeySpec {
1319 pub(crate) key: String,
1320 pub(crate) scope: Scope,
1321}
1322
1323#[derive(Debug, Clone, Default)]
1325pub struct ResolveOptions {
1326 pub profile_override: Option<String>,
1328 pub terminal: Option<String>,
1330}
1331
1332impl ResolveOptions {
1333 pub fn new() -> Self {
1345 Self::default()
1346 }
1347
1348 pub fn with_profile_override(mut self, profile_override: Option<String>) -> Self {
1350 self.profile_override = profile_override
1351 .map(|value| normalize_identifier(&value))
1352 .filter(|value| !value.is_empty());
1353 self
1354 }
1355
1356 pub fn with_profile(mut self, profile: &str) -> Self {
1367 self.profile_override = Some(normalize_identifier(profile));
1368 self
1369 }
1370
1371 pub fn with_terminal(mut self, terminal: &str) -> Self {
1373 self.terminal = Some(normalize_identifier(terminal));
1374 self
1375 }
1376
1377 pub fn with_terminal_override(mut self, terminal: Option<String>) -> Self {
1379 self.terminal = terminal
1380 .map(|value| normalize_identifier(&value))
1381 .filter(|value| !value.is_empty());
1382 self
1383 }
1384}
1385
1386#[derive(Debug, Clone, PartialEq)]
1388pub struct ResolvedValue {
1389 pub raw_value: ConfigValue,
1391 pub value: ConfigValue,
1393 pub source: ConfigSource,
1395 pub scope: Scope,
1397 pub origin: Option<String>,
1399}
1400
1401#[derive(Debug, Clone, PartialEq)]
1403pub struct ExplainCandidate {
1404 pub entry_index: usize,
1406 pub value: ConfigValue,
1408 pub scope: Scope,
1410 pub origin: Option<String>,
1412 pub rank: Option<u8>,
1414 pub selected_in_layer: bool,
1416}
1417
1418#[derive(Debug, Clone, PartialEq)]
1420pub struct ExplainLayer {
1421 pub source: ConfigSource,
1423 pub selected_entry_index: Option<usize>,
1425 pub candidates: Vec<ExplainCandidate>,
1427}
1428
1429#[derive(Debug, Clone, PartialEq)]
1431pub struct ExplainInterpolationStep {
1432 pub placeholder: String,
1434 pub raw_value: ConfigValue,
1436 pub value: ConfigValue,
1438 pub source: ConfigSource,
1440 pub scope: Scope,
1442 pub origin: Option<String>,
1444}
1445
1446#[derive(Debug, Clone, PartialEq)]
1448pub struct ExplainInterpolation {
1449 pub template: String,
1451 pub steps: Vec<ExplainInterpolationStep>,
1453}
1454
1455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1457pub enum ActiveProfileSource {
1458 Override,
1460 DefaultProfile,
1462}
1463
1464impl ActiveProfileSource {
1465 pub fn as_str(self) -> &'static str {
1467 match self {
1468 Self::Override => "override",
1469 Self::DefaultProfile => "profile.default",
1470 }
1471 }
1472}
1473
1474#[derive(Debug, Clone, PartialEq)]
1476pub struct ConfigExplain {
1477 pub key: String,
1479 pub active_profile: String,
1481 pub active_profile_source: ActiveProfileSource,
1483 pub terminal: Option<String>,
1485 pub known_profiles: BTreeSet<String>,
1487 pub layers: Vec<ExplainLayer>,
1489 pub final_entry: Option<ResolvedValue>,
1491 pub interpolation: Option<ExplainInterpolation>,
1493}
1494
1495#[derive(Debug, Clone, PartialEq)]
1497pub struct BootstrapConfigExplain {
1498 pub key: String,
1500 pub active_profile: String,
1502 pub active_profile_source: ActiveProfileSource,
1504 pub terminal: Option<String>,
1506 pub known_profiles: BTreeSet<String>,
1508 pub layers: Vec<ExplainLayer>,
1510 pub final_entry: Option<ResolvedValue>,
1512}
1513
1514#[derive(Debug, Clone, PartialEq)]
1516pub struct ResolvedConfig {
1517 pub(crate) active_profile: String,
1518 pub(crate) terminal: Option<String>,
1519 pub(crate) known_profiles: BTreeSet<String>,
1520 pub(crate) values: BTreeMap<String, ResolvedValue>,
1521 pub(crate) aliases: BTreeMap<String, ResolvedValue>,
1522}
1523
1524impl ResolvedConfig {
1525 pub fn active_profile(&self) -> &str {
1527 &self.active_profile
1528 }
1529
1530 pub fn terminal(&self) -> Option<&str> {
1532 self.terminal.as_deref()
1533 }
1534
1535 pub fn known_profiles(&self) -> &BTreeSet<String> {
1537 &self.known_profiles
1538 }
1539
1540 pub fn values(&self) -> &BTreeMap<String, ResolvedValue> {
1542 &self.values
1543 }
1544
1545 pub fn aliases(&self) -> &BTreeMap<String, ResolvedValue> {
1547 &self.aliases
1548 }
1549
1550 pub fn get(&self, key: &str) -> Option<&ConfigValue> {
1552 self.values.get(key).map(|entry| &entry.value)
1553 }
1554
1555 pub fn get_string(&self, key: &str) -> Option<&str> {
1573 match self.get(key).map(ConfigValue::reveal) {
1574 Some(ConfigValue::String(value)) => Some(value),
1575 _ => None,
1576 }
1577 }
1578
1579 pub fn get_bool(&self, key: &str) -> Option<bool> {
1597 match self.get(key).map(ConfigValue::reveal) {
1598 Some(ConfigValue::Bool(value)) => Some(*value),
1599 _ => None,
1600 }
1601 }
1602
1603 pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
1624 match self.get(key).map(ConfigValue::reveal) {
1625 Some(ConfigValue::List(values)) => Some(
1626 values
1627 .iter()
1628 .filter_map(|value| match value {
1629 ConfigValue::String(text) => Some(text.clone()),
1630 ConfigValue::Secret(secret) => match secret.expose() {
1631 ConfigValue::String(text) => Some(text.clone()),
1632 _ => None,
1633 },
1634 _ => None,
1635 })
1636 .collect(),
1637 ),
1638 Some(ConfigValue::String(value)) => Some(vec![value.clone()]),
1639 Some(ConfigValue::Secret(secret)) => match secret.expose() {
1640 ConfigValue::String(value) => Some(vec![value.clone()]),
1641 _ => None,
1642 },
1643 _ => None,
1644 }
1645 }
1646
1647 pub fn get_value_entry(&self, key: &str) -> Option<&ResolvedValue> {
1649 self.values.get(key)
1650 }
1651
1652 pub fn get_alias_entry(&self, key: &str) -> Option<&ResolvedValue> {
1654 let normalized = if key.trim().to_ascii_lowercase().starts_with("alias.") {
1655 key.trim().to_ascii_lowercase()
1656 } else {
1657 format!("alias.{}", key.trim().to_ascii_lowercase())
1658 };
1659 self.aliases.get(&normalized)
1660 }
1661}
1662
1663fn flatten_table(
1664 layer: &mut ConfigLayer,
1665 table: &toml::value::Table,
1666 prefix: &str,
1667 scope: &Scope,
1668) -> Result<(), ConfigError> {
1669 for (key, value) in table {
1670 let full_key = if prefix.is_empty() {
1671 key.to_string()
1672 } else {
1673 format!("{prefix}.{key}")
1674 };
1675
1676 flatten_key_value(layer, &full_key, value, scope)?;
1677 }
1678
1679 Ok(())
1680}
1681
1682fn flatten_key_value(
1683 layer: &mut ConfigLayer,
1684 key: &str,
1685 value: &toml::Value,
1686 scope: &Scope,
1687) -> Result<(), ConfigError> {
1688 match value {
1689 toml::Value::Table(table) => flatten_table(layer, table, key, scope),
1690 _ => {
1691 let converted = ConfigValue::from_toml(key, value)?;
1692 builtin_config_schema().validate_writable_key(key)?;
1693 validate_key_scope(key, scope)?;
1694 validate_bootstrap_value(key, &converted)?;
1695 layer.insert(key.to_string(), converted, scope.clone());
1696 Ok(())
1697 }
1698 }
1699}
1700
1701pub fn bootstrap_key_spec(key: &str) -> Option<BootstrapKeySpec> {
1703 builtin_config_schema().bootstrap_key_spec(key)
1704}
1705
1706pub fn is_bootstrap_only_key(key: &str) -> bool {
1709 bootstrap_key_spec(key).is_some_and(|spec| !spec.runtime_visible)
1710}
1711
1712pub fn is_alias_key(key: &str) -> bool {
1724 key.trim().to_ascii_lowercase().starts_with("alias.")
1725}
1726
1727pub fn validate_key_scope(key: &str, scope: &Scope) -> Result<(), ConfigError> {
1729 builtin_config_schema().validate_key_scope(key, scope)
1730}
1731
1732pub fn validate_bootstrap_value(key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
1734 builtin_config_schema().validate_bootstrap_value(key, value)
1735}
1736
1737fn adapt_value_for_schema(
1738 key: &str,
1739 value: &ConfigValue,
1740 schema: &SchemaEntry,
1741) -> Result<ConfigValue, ConfigError> {
1742 let (is_secret, value) = match value {
1743 ConfigValue::Secret(secret) => (true, secret.expose()),
1744 other => (false, other),
1745 };
1746
1747 let adapted = match schema.value_type {
1748 SchemaValueType::String => match value {
1749 ConfigValue::String(value) => ConfigValue::String(value.clone()),
1750 other => {
1751 return Err(ConfigError::InvalidValueType {
1752 key: key.to_string(),
1753 expected: SchemaValueType::String,
1754 actual: value_type_name(other).to_string(),
1755 });
1756 }
1757 },
1758 SchemaValueType::Bool => match value {
1759 ConfigValue::Bool(value) => ConfigValue::Bool(*value),
1760 ConfigValue::String(value) => {
1761 ConfigValue::Bool(parse_bool(value).ok_or_else(|| {
1762 ConfigError::InvalidValueType {
1763 key: key.to_string(),
1764 expected: SchemaValueType::Bool,
1765 actual: "string".to_string(),
1766 }
1767 })?)
1768 }
1769 other => {
1770 return Err(ConfigError::InvalidValueType {
1771 key: key.to_string(),
1772 expected: SchemaValueType::Bool,
1773 actual: value_type_name(other).to_string(),
1774 });
1775 }
1776 },
1777 SchemaValueType::Integer => match value {
1778 ConfigValue::Integer(value) => ConfigValue::Integer(*value),
1779 ConfigValue::String(value) => {
1780 let parsed =
1781 value
1782 .trim()
1783 .parse::<i64>()
1784 .map_err(|_| ConfigError::InvalidValueType {
1785 key: key.to_string(),
1786 expected: SchemaValueType::Integer,
1787 actual: "string".to_string(),
1788 })?;
1789 ConfigValue::Integer(parsed)
1790 }
1791 other => {
1792 return Err(ConfigError::InvalidValueType {
1793 key: key.to_string(),
1794 expected: SchemaValueType::Integer,
1795 actual: value_type_name(other).to_string(),
1796 });
1797 }
1798 },
1799 SchemaValueType::Float => match value {
1800 ConfigValue::Float(value) => ConfigValue::Float(*value),
1801 ConfigValue::Integer(value) => ConfigValue::Float(*value as f64),
1802 ConfigValue::String(value) => {
1803 let parsed =
1804 value
1805 .trim()
1806 .parse::<f64>()
1807 .map_err(|_| ConfigError::InvalidValueType {
1808 key: key.to_string(),
1809 expected: SchemaValueType::Float,
1810 actual: "string".to_string(),
1811 })?;
1812 ConfigValue::Float(parsed)
1813 }
1814 other => {
1815 return Err(ConfigError::InvalidValueType {
1816 key: key.to_string(),
1817 expected: SchemaValueType::Float,
1818 actual: value_type_name(other).to_string(),
1819 });
1820 }
1821 },
1822 SchemaValueType::StringList => match value {
1823 ConfigValue::List(values) => {
1824 let mut out = Vec::with_capacity(values.len());
1825 for value in values {
1826 match value {
1827 ConfigValue::String(value) => out.push(ConfigValue::String(value.clone())),
1828 ConfigValue::Secret(secret) => match secret.expose() {
1829 ConfigValue::String(value) => {
1830 out.push(ConfigValue::String(value.clone()))
1831 }
1832 other => {
1833 return Err(ConfigError::InvalidValueType {
1834 key: key.to_string(),
1835 expected: SchemaValueType::StringList,
1836 actual: value_type_name(other).to_string(),
1837 });
1838 }
1839 },
1840 other => {
1841 return Err(ConfigError::InvalidValueType {
1842 key: key.to_string(),
1843 expected: SchemaValueType::StringList,
1844 actual: value_type_name(other).to_string(),
1845 });
1846 }
1847 }
1848 }
1849 ConfigValue::List(out)
1850 }
1851 ConfigValue::String(value) => {
1852 let items = parse_string_list(value);
1853 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
1854 }
1855 ConfigValue::Secret(secret) => match secret.expose() {
1856 ConfigValue::String(value) => {
1857 let items = parse_string_list(value);
1858 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
1859 }
1860 other => {
1861 return Err(ConfigError::InvalidValueType {
1862 key: key.to_string(),
1863 expected: SchemaValueType::StringList,
1864 actual: value_type_name(other).to_string(),
1865 });
1866 }
1867 },
1868 other => {
1869 return Err(ConfigError::InvalidValueType {
1870 key: key.to_string(),
1871 expected: SchemaValueType::StringList,
1872 actual: value_type_name(other).to_string(),
1873 });
1874 }
1875 },
1876 };
1877
1878 let adapted = if is_secret {
1879 adapted.into_secret()
1880 } else {
1881 adapted
1882 };
1883
1884 if let Some(allowed_values) = &schema.allowed_values
1885 && let ConfigValue::String(value) = adapted.reveal()
1886 {
1887 let normalized = value.to_ascii_lowercase();
1888 if !allowed_values.contains(&normalized) {
1889 return Err(ConfigError::InvalidEnumValue {
1890 key: key.to_string(),
1891 value: value.clone(),
1892 allowed: allowed_values.clone(),
1893 });
1894 }
1895 }
1896
1897 Ok(adapted)
1898}
1899
1900fn adapt_dynamic_value_for_schema(
1901 key: &str,
1902 value: &ConfigValue,
1903 kind: DynamicSchemaKeyKind,
1904) -> Result<ConfigValue, ConfigError> {
1905 let adapted = match kind {
1906 DynamicSchemaKeyKind::PluginCommandState | DynamicSchemaKeyKind::PluginCommandProvider => {
1907 adapt_value_for_schema(key, value, &SchemaEntry::string())?
1908 }
1909 };
1910
1911 if matches!(kind, DynamicSchemaKeyKind::PluginCommandState) {
1912 validate_allowed_values(key, &adapted, Some(&["enabled", "disabled"]))?;
1913 }
1914
1915 Ok(adapted)
1916}
1917
1918fn validate_allowed_values(
1919 key: &str,
1920 value: &ConfigValue,
1921 allowed: Option<&[&str]>,
1922) -> Result<(), ConfigError> {
1923 let Some(allowed) = allowed else {
1924 return Ok(());
1925 };
1926 if let ConfigValue::String(current) = value {
1927 let normalized = current.to_ascii_lowercase();
1928 if !allowed.iter().any(|candidate| *candidate == normalized) {
1929 return Err(ConfigError::InvalidEnumValue {
1930 key: key.to_string(),
1931 value: current.clone(),
1932 allowed: allowed.iter().map(|value| (*value).to_string()).collect(),
1933 });
1934 }
1935 }
1936 Ok(())
1937}
1938
1939fn dynamic_schema_key_kind(key: &str) -> Option<DynamicSchemaKeyKind> {
1940 let normalized = key.trim().to_ascii_lowercase();
1941 let remainder = normalized.strip_prefix("plugins.")?;
1942 let (command, field) = remainder.rsplit_once('.')?;
1943 if command.trim().is_empty() {
1944 return None;
1945 }
1946 match field {
1947 "state" => Some(DynamicSchemaKeyKind::PluginCommandState),
1948 "provider" => Some(DynamicSchemaKeyKind::PluginCommandProvider),
1949 _ => None,
1950 }
1951}
1952
1953fn parse_bool(value: &str) -> Option<bool> {
1954 match value.trim().to_ascii_lowercase().as_str() {
1955 "true" => Some(true),
1956 "false" => Some(false),
1957 _ => None,
1958 }
1959}
1960
1961fn parse_string_list(value: &str) -> Vec<String> {
1962 let trimmed = value.trim();
1963 if trimmed.is_empty() {
1964 return Vec::new();
1965 }
1966
1967 let inner = trimmed
1968 .strip_prefix('[')
1969 .and_then(|value| value.strip_suffix(']'))
1970 .unwrap_or(trimmed);
1971
1972 inner
1973 .split(',')
1974 .map(|value| value.trim())
1975 .filter(|value| !value.is_empty())
1976 .map(|value| {
1977 value
1978 .strip_prefix('"')
1979 .and_then(|value| value.strip_suffix('"'))
1980 .or_else(|| {
1981 value
1982 .strip_prefix('\'')
1983 .and_then(|value| value.strip_suffix('\''))
1984 })
1985 .unwrap_or(value)
1986 .to_string()
1987 })
1988 .collect()
1989}
1990
1991fn value_type_name(value: &ConfigValue) -> &'static str {
1992 match value.reveal() {
1993 ConfigValue::String(_) => "string",
1994 ConfigValue::Bool(_) => "bool",
1995 ConfigValue::Integer(_) => "integer",
1996 ConfigValue::Float(_) => "float",
1997 ConfigValue::List(_) => "list",
1998 ConfigValue::Secret(_) => "string",
1999 }
2000}
2001
2002pub(crate) fn parse_env_key(key: &str) -> Result<EnvKeySpec, ConfigError> {
2003 let Some(raw) = key.strip_prefix("OSP__") else {
2004 return Err(ConfigError::InvalidEnvOverride {
2005 key: key.to_string(),
2006 reason: "missing OSP__ prefix".to_string(),
2007 });
2008 };
2009
2010 let parts = raw
2011 .split("__")
2012 .filter(|part| !part.is_empty())
2013 .collect::<Vec<&str>>();
2014
2015 if parts.is_empty() {
2016 return Err(ConfigError::InvalidEnvOverride {
2017 key: key.to_string(),
2018 reason: "missing key path".to_string(),
2019 });
2020 }
2021
2022 let mut cursor = 0usize;
2023 let mut terminal: Option<String> = None;
2024 let mut profile: Option<String> = None;
2025
2026 while cursor < parts.len() {
2027 let part = parts[cursor];
2028 if part.eq_ignore_ascii_case("TERM") {
2029 if terminal.is_some() {
2030 return Err(ConfigError::InvalidEnvOverride {
2031 key: key.to_string(),
2032 reason: "TERM scope specified more than once".to_string(),
2033 });
2034 }
2035 let term = parts
2036 .get(cursor + 1)
2037 .ok_or_else(|| ConfigError::InvalidEnvOverride {
2038 key: key.to_string(),
2039 reason: "TERM requires a terminal name".to_string(),
2040 })?;
2041 terminal = Some(normalize_identifier(term));
2042 cursor += 2;
2043 continue;
2044 }
2045
2046 if part.eq_ignore_ascii_case("PROFILE") {
2047 if remaining_parts_are_bootstrap_profile_default(&parts[cursor..]) {
2050 break;
2051 }
2052 if profile.is_some() {
2053 return Err(ConfigError::InvalidEnvOverride {
2054 key: key.to_string(),
2055 reason: "PROFILE scope specified more than once".to_string(),
2056 });
2057 }
2058 let profile_name =
2059 parts
2060 .get(cursor + 1)
2061 .ok_or_else(|| ConfigError::InvalidEnvOverride {
2062 key: key.to_string(),
2063 reason: "PROFILE requires a profile name".to_string(),
2064 })?;
2065 profile = Some(normalize_identifier(profile_name));
2066 cursor += 2;
2067 continue;
2068 }
2069
2070 break;
2071 }
2072
2073 let key_parts = &parts[cursor..];
2074 if key_parts.is_empty() {
2075 return Err(ConfigError::InvalidEnvOverride {
2076 key: key.to_string(),
2077 reason: "missing final config key".to_string(),
2078 });
2079 }
2080
2081 let dotted_key = key_parts
2082 .iter()
2083 .map(|part| part.to_ascii_lowercase())
2084 .collect::<Vec<String>>()
2085 .join(".");
2086
2087 Ok(EnvKeySpec {
2088 key: dotted_key,
2089 scope: Scope { profile, terminal },
2090 })
2091}
2092
2093fn remaining_parts_are_bootstrap_profile_default(parts: &[&str]) -> bool {
2094 matches!(parts, [profile, default]
2095 if profile.eq_ignore_ascii_case("PROFILE")
2096 && default.eq_ignore_ascii_case("DEFAULT"))
2097}
2098
2099pub(crate) fn normalize_scope(scope: Scope) -> Scope {
2100 Scope {
2101 profile: scope
2102 .profile
2103 .as_deref()
2104 .map(normalize_identifier)
2105 .filter(|value| !value.is_empty()),
2106 terminal: scope
2107 .terminal
2108 .as_deref()
2109 .map(normalize_identifier)
2110 .filter(|value| !value.is_empty()),
2111 }
2112}
2113
2114pub(crate) fn normalize_identifier(value: &str) -> String {
2115 value.trim().to_ascii_lowercase()
2116}
2117
2118#[cfg(test)]
2119mod tests;