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)]
8pub struct TomlEditResult {
9 pub previous: Option<ConfigValue>,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
13pub enum ConfigSource {
14 BuiltinDefaults,
15 PresentationDefaults,
16 ConfigFile,
17 Secrets,
18 Environment,
19 Cli,
20 Session,
21 Derived,
22}
23
24impl Display for ConfigSource {
25 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
26 let value = match self {
27 ConfigSource::BuiltinDefaults => "defaults",
28 ConfigSource::PresentationDefaults => "presentation",
29 ConfigSource::ConfigFile => "file",
30 ConfigSource::Secrets => "secrets",
31 ConfigSource::Environment => "env",
32 ConfigSource::Cli => "cli",
33 ConfigSource::Session => "session",
34 ConfigSource::Derived => "derived",
35 };
36 write!(f, "{value}")
37 }
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub enum ConfigValue {
42 String(String),
43 Bool(bool),
44 Integer(i64),
45 Float(f64),
46 List(Vec<ConfigValue>),
47 Secret(SecretValue),
48}
49
50impl ConfigValue {
51 pub fn is_secret(&self) -> bool {
52 matches!(self, ConfigValue::Secret(_))
53 }
54
55 pub fn reveal(&self) -> &ConfigValue {
56 match self {
57 ConfigValue::Secret(secret) => secret.expose(),
58 other => other,
59 }
60 }
61
62 pub fn into_secret(self) -> ConfigValue {
63 match self {
64 ConfigValue::Secret(_) => self,
65 other => ConfigValue::Secret(SecretValue::new(other)),
66 }
67 }
68
69 pub(crate) fn from_toml(path: &str, value: &toml::Value) -> Result<Self, ConfigError> {
70 match value {
71 toml::Value::String(v) => Ok(Self::String(v.clone())),
72 toml::Value::Integer(v) => Ok(Self::Integer(*v)),
73 toml::Value::Float(v) => Ok(Self::Float(*v)),
74 toml::Value::Boolean(v) => Ok(Self::Bool(*v)),
75 toml::Value::Datetime(v) => Ok(Self::String(v.to_string())),
76 toml::Value::Array(values) => {
77 let mut out = Vec::with_capacity(values.len());
78 for item in values {
79 out.push(Self::from_toml(path, item)?);
80 }
81 Ok(Self::List(out))
82 }
83 toml::Value::Table(_) => Err(ConfigError::UnsupportedTomlValue {
84 path: path.to_string(),
85 kind: "table".to_string(),
86 }),
87 }
88 }
89
90 pub(crate) fn as_interpolation_string(
91 &self,
92 key: &str,
93 placeholder: &str,
94 ) -> Result<String, ConfigError> {
95 match self.reveal() {
96 ConfigValue::String(value) => Ok(value.clone()),
97 ConfigValue::Bool(value) => Ok(value.to_string()),
98 ConfigValue::Integer(value) => Ok(value.to_string()),
99 ConfigValue::Float(value) => Ok(value.to_string()),
100 ConfigValue::List(_) => Err(ConfigError::NonScalarPlaceholder {
101 key: key.to_string(),
102 placeholder: placeholder.to_string(),
103 }),
104 ConfigValue::Secret(_) => Err(ConfigError::NonScalarPlaceholder {
105 key: key.to_string(),
106 placeholder: placeholder.to_string(),
107 }),
108 }
109 }
110}
111
112#[derive(Clone, PartialEq)]
113pub struct SecretValue(Box<ConfigValue>);
114
115impl SecretValue {
116 pub fn new(value: ConfigValue) -> Self {
117 Self(Box::new(value))
118 }
119
120 pub fn expose(&self) -> &ConfigValue {
121 &self.0
122 }
123
124 pub fn into_inner(self) -> ConfigValue {
125 *self.0
126 }
127}
128
129impl std::fmt::Debug for SecretValue {
130 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
131 write!(f, "[REDACTED]")
132 }
133}
134
135impl Display for SecretValue {
136 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
137 write!(f, "[REDACTED]")
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum SchemaValueType {
143 String,
144 Bool,
145 Integer,
146 Float,
147 StringList,
148}
149
150impl Display for SchemaValueType {
151 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
152 let value = match self {
153 SchemaValueType::String => "string",
154 SchemaValueType::Bool => "bool",
155 SchemaValueType::Integer => "integer",
156 SchemaValueType::Float => "float",
157 SchemaValueType::StringList => "list",
158 };
159 write!(f, "{value}")
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum BootstrapPhase {
165 Path,
166 Profile,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum BootstrapScopeRule {
171 GlobalOnly,
172 GlobalOrTerminal,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum BootstrapValueRule {
177 NonEmptyString,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct BootstrapKeySpec {
182 pub key: &'static str,
183 pub phase: BootstrapPhase,
184 pub runtime_visible: bool,
185 pub scope_rule: BootstrapScopeRule,
186}
187
188impl BootstrapKeySpec {
189 fn allows_scope(&self, scope: &Scope) -> bool {
190 match self.scope_rule {
191 BootstrapScopeRule::GlobalOnly => scope.profile.is_none() && scope.terminal.is_none(),
192 BootstrapScopeRule::GlobalOrTerminal => scope.profile.is_none(),
193 }
194 }
195}
196
197#[derive(Debug, Clone)]
198pub struct SchemaEntry {
199 canonical_key: Option<&'static str>,
200 value_type: SchemaValueType,
201 required: bool,
202 allowed_values: Option<Vec<String>>,
203 runtime_visible: bool,
204 bootstrap_phase: Option<BootstrapPhase>,
205 bootstrap_scope_rule: Option<BootstrapScopeRule>,
206 bootstrap_value_rule: Option<BootstrapValueRule>,
207}
208
209impl SchemaEntry {
210 pub fn string() -> Self {
211 Self {
212 canonical_key: None,
213 value_type: SchemaValueType::String,
214 required: false,
215 allowed_values: None,
216 runtime_visible: true,
217 bootstrap_phase: None,
218 bootstrap_scope_rule: None,
219 bootstrap_value_rule: None,
220 }
221 }
222
223 pub fn boolean() -> Self {
224 Self {
225 canonical_key: None,
226 value_type: SchemaValueType::Bool,
227 required: false,
228 allowed_values: None,
229 runtime_visible: true,
230 bootstrap_phase: None,
231 bootstrap_scope_rule: None,
232 bootstrap_value_rule: None,
233 }
234 }
235
236 pub fn integer() -> Self {
237 Self {
238 canonical_key: None,
239 value_type: SchemaValueType::Integer,
240 required: false,
241 allowed_values: None,
242 runtime_visible: true,
243 bootstrap_phase: None,
244 bootstrap_scope_rule: None,
245 bootstrap_value_rule: None,
246 }
247 }
248
249 pub fn float() -> Self {
250 Self {
251 canonical_key: None,
252 value_type: SchemaValueType::Float,
253 required: false,
254 allowed_values: None,
255 runtime_visible: true,
256 bootstrap_phase: None,
257 bootstrap_scope_rule: None,
258 bootstrap_value_rule: None,
259 }
260 }
261
262 pub fn string_list() -> Self {
263 Self {
264 canonical_key: None,
265 value_type: SchemaValueType::StringList,
266 required: false,
267 allowed_values: None,
268 runtime_visible: true,
269 bootstrap_phase: None,
270 bootstrap_scope_rule: None,
271 bootstrap_value_rule: None,
272 }
273 }
274
275 pub fn required(mut self) -> Self {
276 self.required = true;
277 self
278 }
279
280 pub fn bootstrap_only(mut self, phase: BootstrapPhase, scope_rule: BootstrapScopeRule) -> Self {
281 self.runtime_visible = false;
282 self.bootstrap_phase = Some(phase);
283 self.bootstrap_scope_rule = Some(scope_rule);
284 self
285 }
286
287 pub fn with_bootstrap_value_rule(mut self, rule: BootstrapValueRule) -> Self {
288 self.bootstrap_value_rule = Some(rule);
289 self
290 }
291
292 pub fn with_allowed_values<I, S>(mut self, values: I) -> Self
293 where
294 I: IntoIterator<Item = S>,
295 S: AsRef<str>,
296 {
297 self.allowed_values = Some(
298 values
299 .into_iter()
300 .map(|value| value.as_ref().to_ascii_lowercase())
301 .collect(),
302 );
303 self
304 }
305
306 pub fn value_type(&self) -> SchemaValueType {
307 self.value_type
308 }
309
310 pub fn allowed_values(&self) -> Option<&[String]> {
311 self.allowed_values.as_deref()
312 }
313
314 pub fn runtime_visible(&self) -> bool {
315 self.runtime_visible
316 }
317
318 fn with_canonical_key(mut self, key: &'static str) -> Self {
319 self.canonical_key = Some(key);
320 self
321 }
322
323 fn bootstrap_spec(&self) -> Option<BootstrapKeySpec> {
324 Some(BootstrapKeySpec {
325 key: self.canonical_key?,
326 phase: self.bootstrap_phase?,
327 runtime_visible: self.runtime_visible,
328 scope_rule: self.bootstrap_scope_rule?,
329 })
330 }
331
332 fn validate_bootstrap_value(&self, key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
333 match self.bootstrap_value_rule {
334 Some(BootstrapValueRule::NonEmptyString) => match value.reveal() {
335 ConfigValue::String(current) if !current.trim().is_empty() => Ok(()),
336 ConfigValue::String(current) => Err(ConfigError::InvalidBootstrapValue {
337 key: key.to_string(),
338 reason: format!("expected a non-empty string, got {current:?}"),
339 }),
340 other => Err(ConfigError::InvalidBootstrapValue {
341 key: key.to_string(),
342 reason: format!("expected string, got {other:?}"),
343 }),
344 },
345 None => Ok(()),
346 }
347 }
348}
349
350#[derive(Debug, Clone)]
351pub struct ConfigSchema {
352 entries: BTreeMap<String, SchemaEntry>,
353 allow_extensions_namespace: bool,
354}
355
356impl Default for ConfigSchema {
357 fn default() -> Self {
358 builtin_config_schema().clone()
359 }
360}
361
362impl ConfigSchema {
363 fn builtin() -> Self {
364 let mut schema = Self {
365 entries: BTreeMap::new(),
366 allow_extensions_namespace: true,
367 };
368
369 schema.insert(
370 "profile.default",
371 SchemaEntry::string()
372 .bootstrap_only(
373 BootstrapPhase::Profile,
374 BootstrapScopeRule::GlobalOrTerminal,
375 )
376 .with_bootstrap_value_rule(BootstrapValueRule::NonEmptyString),
377 );
378 schema.insert("profile.active", SchemaEntry::string().required());
379 schema.insert("theme.name", SchemaEntry::string());
380 schema.insert("theme.path", SchemaEntry::string_list());
381 schema.insert("user.name", SchemaEntry::string());
382 schema.insert("user.display_name", SchemaEntry::string());
383 schema.insert("user.full_name", SchemaEntry::string());
384 schema.insert("domain", SchemaEntry::string());
385
386 schema.insert(
387 "ui.format",
388 SchemaEntry::string()
389 .with_allowed_values(["auto", "json", "table", "md", "mreg", "value"]),
390 );
391 schema.insert(
392 "ui.mode",
393 SchemaEntry::string().with_allowed_values(["auto", "plain", "rich"]),
394 );
395 schema.insert(
396 "ui.presentation",
397 SchemaEntry::string().with_allowed_values([
398 "expressive",
399 "compact",
400 "austere",
401 "gammel-og-bitter",
402 ]),
403 );
404 schema.insert(
405 "ui.color.mode",
406 SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
407 );
408 schema.insert(
409 "ui.unicode.mode",
410 SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
411 );
412 schema.insert("ui.width", SchemaEntry::integer());
413 schema.insert("ui.margin", SchemaEntry::integer());
414 schema.insert("ui.indent", SchemaEntry::integer());
415 schema.insert(
416 "ui.help.layout",
417 SchemaEntry::string().with_allowed_values(["full", "compact", "minimal"]),
418 );
419 schema.insert(
420 "ui.messages.layout",
421 SchemaEntry::string().with_allowed_values(["grouped", "minimal"]),
422 );
423 schema.insert(
424 "ui.chrome.frame",
425 SchemaEntry::string().with_allowed_values([
426 "none",
427 "top",
428 "bottom",
429 "top-bottom",
430 "square",
431 "round",
432 ]),
433 );
434 schema.insert(
435 "ui.table.overflow",
436 SchemaEntry::string().with_allowed_values([
437 "clip", "hidden", "crop", "ellipsis", "truncate", "wrap", "none", "visible",
438 ]),
439 );
440 schema.insert(
441 "ui.table.border",
442 SchemaEntry::string().with_allowed_values(["none", "square", "round"]),
443 );
444 schema.insert("ui.short_list_max", SchemaEntry::integer());
445 schema.insert("ui.medium_list_max", SchemaEntry::integer());
446 schema.insert("ui.grid_padding", SchemaEntry::integer());
447 schema.insert("ui.grid_columns", SchemaEntry::integer());
448 schema.insert("ui.column_weight", SchemaEntry::integer());
449 schema.insert("ui.mreg.stack_min_col_width", SchemaEntry::integer());
450 schema.insert("ui.mreg.stack_overflow_ratio", SchemaEntry::integer());
451 schema.insert(
452 "ui.verbosity.level",
453 SchemaEntry::string()
454 .with_allowed_values(["error", "warning", "success", "info", "trace"]),
455 );
456 schema.insert("ui.prompt", SchemaEntry::string());
457 schema.insert("ui.prompt.secrets", SchemaEntry::boolean());
458 schema.insert("extensions.plugins.timeout_ms", SchemaEntry::integer());
459 schema.insert("extensions.plugins.discovery.path", SchemaEntry::boolean());
460 schema.insert("repl.prompt", SchemaEntry::string());
461 schema.insert(
462 "repl.input_mode",
463 SchemaEntry::string().with_allowed_values(["auto", "interactive", "basic"]),
464 );
465 schema.insert("repl.simple_prompt", SchemaEntry::boolean());
466 schema.insert("repl.shell_indicator", SchemaEntry::string());
467 schema.insert(
468 "repl.intro",
469 SchemaEntry::string().with_allowed_values(["none", "minimal", "compact", "full"]),
470 );
471 schema.insert("repl.history.path", SchemaEntry::string());
472 schema.insert("repl.history.max_entries", SchemaEntry::integer());
473 schema.insert("repl.history.enabled", SchemaEntry::boolean());
474 schema.insert("repl.history.dedupe", SchemaEntry::boolean());
475 schema.insert("repl.history.profile_scoped", SchemaEntry::boolean());
476 schema.insert("repl.history.exclude", SchemaEntry::string_list());
477 schema.insert("session.cache.max_results", SchemaEntry::integer());
478 schema.insert("color.prompt.text", SchemaEntry::string());
479 schema.insert("color.prompt.command", SchemaEntry::string());
480 schema.insert("color.prompt.completion.text", SchemaEntry::string());
481 schema.insert("color.prompt.completion.background", SchemaEntry::string());
482 schema.insert("color.prompt.completion.highlight", SchemaEntry::string());
483 schema.insert("color.text", SchemaEntry::string());
484 schema.insert("color.text.muted", SchemaEntry::string());
485 schema.insert("color.key", SchemaEntry::string());
486 schema.insert("color.border", SchemaEntry::string());
487 schema.insert("color.table.header", SchemaEntry::string());
488 schema.insert("color.mreg.key", SchemaEntry::string());
489 schema.insert("color.value", SchemaEntry::string());
490 schema.insert("color.value.number", SchemaEntry::string());
491 schema.insert("color.value.bool_true", SchemaEntry::string());
492 schema.insert("color.value.bool_false", SchemaEntry::string());
493 schema.insert("color.value.null", SchemaEntry::string());
494 schema.insert("color.value.ipv4", SchemaEntry::string());
495 schema.insert("color.value.ipv6", SchemaEntry::string());
496 schema.insert("color.panel.border", SchemaEntry::string());
497 schema.insert("color.panel.title", SchemaEntry::string());
498 schema.insert("color.code", SchemaEntry::string());
499 schema.insert("color.json.key", SchemaEntry::string());
500 schema.insert("color.message.error", SchemaEntry::string());
501 schema.insert("color.message.warning", SchemaEntry::string());
502 schema.insert("color.message.success", SchemaEntry::string());
503 schema.insert("color.message.info", SchemaEntry::string());
504 schema.insert("color.message.trace", SchemaEntry::string());
505 schema.insert("auth.visible.builtins", SchemaEntry::string());
506 schema.insert("auth.visible.plugins", SchemaEntry::string());
507 schema.insert("debug.level", SchemaEntry::integer());
508 schema.insert("log.file.enabled", SchemaEntry::boolean());
509 schema.insert("log.file.path", SchemaEntry::string());
510 schema.insert(
511 "log.file.level",
512 SchemaEntry::string().with_allowed_values(["error", "warn", "info", "debug", "trace"]),
513 );
514
515 schema.insert("base.dir", SchemaEntry::string());
516
517 schema
518 }
519}
520
521impl ConfigSchema {
522 pub fn insert(&mut self, key: &'static str, entry: SchemaEntry) {
523 self.entries
524 .insert(key.to_string(), entry.with_canonical_key(key));
525 }
526
527 pub fn set_allow_extensions_namespace(&mut self, value: bool) {
528 self.allow_extensions_namespace = value;
529 }
530
531 pub fn is_known_key(&self, key: &str) -> bool {
532 self.entries.contains_key(key) || self.is_extension_key(key) || self.is_alias_key(key)
533 }
534
535 pub fn is_runtime_visible_key(&self, key: &str) -> bool {
536 self.entries
537 .get(key)
538 .is_some_and(SchemaEntry::runtime_visible)
539 || self.is_extension_key(key)
540 }
541
542 pub fn bootstrap_key_spec(&self, key: &str) -> Option<BootstrapKeySpec> {
543 let normalized = key.trim().to_ascii_lowercase();
544 self.entries
545 .get(&normalized)
546 .and_then(SchemaEntry::bootstrap_spec)
547 }
548
549 pub fn entries(&self) -> impl Iterator<Item = (&str, &SchemaEntry)> {
550 self.entries
551 .iter()
552 .map(|(key, entry)| (key.as_str(), entry))
553 }
554
555 pub fn expected_type(&self, key: &str) -> Option<SchemaValueType> {
556 self.entries.get(key).map(|entry| entry.value_type)
557 }
558
559 pub fn parse_input_value(&self, key: &str, raw: &str) -> Result<ConfigValue, ConfigError> {
560 if !self.is_known_key(key) {
561 return Err(ConfigError::UnknownConfigKeys {
562 keys: vec![key.to_string()],
563 });
564 }
565
566 let value = match self.expected_type(key) {
567 Some(SchemaValueType::String) | None => ConfigValue::String(raw.to_string()),
568 Some(SchemaValueType::Bool) => {
569 ConfigValue::Bool(
570 parse_bool(raw).ok_or_else(|| ConfigError::InvalidValueType {
571 key: key.to_string(),
572 expected: SchemaValueType::Bool,
573 actual: "string".to_string(),
574 })?,
575 )
576 }
577 Some(SchemaValueType::Integer) => {
578 let parsed =
579 raw.trim()
580 .parse::<i64>()
581 .map_err(|_| ConfigError::InvalidValueType {
582 key: key.to_string(),
583 expected: SchemaValueType::Integer,
584 actual: "string".to_string(),
585 })?;
586 ConfigValue::Integer(parsed)
587 }
588 Some(SchemaValueType::Float) => {
589 let parsed =
590 raw.trim()
591 .parse::<f64>()
592 .map_err(|_| ConfigError::InvalidValueType {
593 key: key.to_string(),
594 expected: SchemaValueType::Float,
595 actual: "string".to_string(),
596 })?;
597 ConfigValue::Float(parsed)
598 }
599 Some(SchemaValueType::StringList) => {
600 let items = parse_string_list(raw);
601 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
602 }
603 };
604
605 if let Some(entry) = self.entries.get(key)
606 && let Some(allowed) = &entry.allowed_values
607 && let ConfigValue::String(current) = &value
608 {
609 let normalized = current.to_ascii_lowercase();
610 if !allowed.contains(&normalized) {
611 return Err(ConfigError::InvalidEnumValue {
612 key: key.to_string(),
613 value: current.clone(),
614 allowed: allowed.clone(),
615 });
616 }
617 }
618
619 Ok(value)
620 }
621
622 pub(crate) fn validate_and_adapt(
623 &self,
624 values: &mut BTreeMap<String, ResolvedValue>,
625 ) -> Result<(), ConfigError> {
626 let mut unknown = Vec::new();
627 for key in values.keys() {
628 if self.is_runtime_visible_key(key) {
629 continue;
630 }
631 unknown.push(key.clone());
632 }
633 if !unknown.is_empty() {
634 unknown.sort();
635 return Err(ConfigError::UnknownConfigKeys { keys: unknown });
636 }
637
638 for (key, entry) in &self.entries {
639 if entry.runtime_visible && entry.required && !values.contains_key(key) {
640 return Err(ConfigError::MissingRequiredKey { key: key.clone() });
641 }
642 }
643
644 for (key, resolved) in values.iter_mut() {
645 let Some(schema_entry) = self.entries.get(key) else {
646 continue;
647 };
648 if !schema_entry.runtime_visible {
649 continue;
650 }
651 resolved.value = adapt_value_for_schema(key, &resolved.value, schema_entry)?;
652 }
653
654 Ok(())
655 }
656
657 fn is_extension_key(&self, key: &str) -> bool {
658 self.allow_extensions_namespace && key.starts_with("extensions.")
659 }
660
661 fn is_alias_key(&self, key: &str) -> bool {
662 key.starts_with("alias.")
663 }
664
665 pub fn validate_key_scope(&self, key: &str, scope: &Scope) -> Result<(), ConfigError> {
666 let normalized_scope = normalize_scope(scope.clone());
667 if let Some(spec) = self.bootstrap_key_spec(key)
668 && !spec.allows_scope(&normalized_scope)
669 {
670 return Err(ConfigError::InvalidBootstrapScope {
671 key: spec.key.to_string(),
672 profile: normalized_scope.profile,
673 terminal: normalized_scope.terminal,
674 });
675 }
676
677 Ok(())
678 }
679
680 pub fn validate_bootstrap_value(
681 &self,
682 key: &str,
683 value: &ConfigValue,
684 ) -> Result<(), ConfigError> {
685 let normalized = key.trim().to_ascii_lowercase();
686 let Some(entry) = self.entries.get(&normalized) else {
687 return Ok(());
688 };
689 entry.validate_bootstrap_value(&normalized, value)
690 }
691}
692
693fn builtin_config_schema() -> &'static ConfigSchema {
694 static BUILTIN_SCHEMA: OnceLock<ConfigSchema> = OnceLock::new();
695 BUILTIN_SCHEMA.get_or_init(ConfigSchema::builtin)
696}
697
698impl Display for ConfigValue {
699 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
700 match self {
701 ConfigValue::String(v) => write!(f, "{v}"),
702 ConfigValue::Bool(v) => write!(f, "{v}"),
703 ConfigValue::Integer(v) => write!(f, "{v}"),
704 ConfigValue::Float(v) => write!(f, "{v}"),
705 ConfigValue::List(v) => {
706 let joined = v
707 .iter()
708 .map(ToString::to_string)
709 .collect::<Vec<String>>()
710 .join(",");
711 write!(f, "[{joined}]")
712 }
713 ConfigValue::Secret(secret) => write!(f, "{secret}"),
714 }
715 }
716}
717
718impl From<&str> for ConfigValue {
719 fn from(value: &str) -> Self {
720 ConfigValue::String(value.to_string())
721 }
722}
723
724impl From<String> for ConfigValue {
725 fn from(value: String) -> Self {
726 ConfigValue::String(value)
727 }
728}
729
730impl From<bool> for ConfigValue {
731 fn from(value: bool) -> Self {
732 ConfigValue::Bool(value)
733 }
734}
735
736impl From<i64> for ConfigValue {
737 fn from(value: i64) -> Self {
738 ConfigValue::Integer(value)
739 }
740}
741
742impl From<f64> for ConfigValue {
743 fn from(value: f64) -> Self {
744 ConfigValue::Float(value)
745 }
746}
747
748impl From<Vec<String>> for ConfigValue {
749 fn from(values: Vec<String>) -> Self {
750 ConfigValue::List(values.into_iter().map(ConfigValue::String).collect())
751 }
752}
753
754#[derive(Debug, Clone, Default, PartialEq, Eq)]
755pub struct Scope {
756 pub profile: Option<String>,
757 pub terminal: Option<String>,
758}
759
760impl Scope {
761 pub fn global() -> Self {
762 Self::default()
763 }
764
765 pub fn profile(profile: &str) -> Self {
766 Self {
767 profile: Some(normalize_identifier(profile)),
768 terminal: None,
769 }
770 }
771
772 pub fn terminal(terminal: &str) -> Self {
773 Self {
774 profile: None,
775 terminal: Some(normalize_identifier(terminal)),
776 }
777 }
778
779 pub fn profile_terminal(profile: &str, terminal: &str) -> Self {
780 Self {
781 profile: Some(normalize_identifier(profile)),
782 terminal: Some(normalize_identifier(terminal)),
783 }
784 }
785}
786
787#[derive(Debug, Clone, PartialEq)]
788pub struct LayerEntry {
789 pub key: String,
790 pub value: ConfigValue,
791 pub scope: Scope,
792 pub origin: Option<String>,
793}
794
795#[derive(Debug, Clone, Default)]
796pub struct ConfigLayer {
797 pub(crate) entries: Vec<LayerEntry>,
798}
799
800impl ConfigLayer {
801 pub fn entries(&self) -> &[LayerEntry] {
802 &self.entries
803 }
804
805 pub fn set<K, V>(&mut self, key: K, value: V)
806 where
807 K: Into<String>,
808 V: Into<ConfigValue>,
809 {
810 self.insert(key, value, Scope::global());
811 }
812
813 pub fn set_for_profile<K, V>(&mut self, profile: &str, key: K, value: V)
814 where
815 K: Into<String>,
816 V: Into<ConfigValue>,
817 {
818 self.insert(key, value, Scope::profile(profile));
819 }
820
821 pub fn set_for_terminal<K, V>(&mut self, terminal: &str, key: K, value: V)
822 where
823 K: Into<String>,
824 V: Into<ConfigValue>,
825 {
826 self.insert(key, value, Scope::terminal(terminal));
827 }
828
829 pub fn set_for_profile_terminal<K, V>(
830 &mut self,
831 profile: &str,
832 terminal: &str,
833 key: K,
834 value: V,
835 ) where
836 K: Into<String>,
837 V: Into<ConfigValue>,
838 {
839 self.insert(key, value, Scope::profile_terminal(profile, terminal));
840 }
841
842 pub fn insert<K, V>(&mut self, key: K, value: V, scope: Scope)
843 where
844 K: Into<String>,
845 V: Into<ConfigValue>,
846 {
847 self.entries.push(LayerEntry {
848 key: key.into(),
849 value: value.into(),
850 scope: normalize_scope(scope),
851 origin: None,
852 });
853 }
854
855 pub fn insert_with_origin<K, V, O>(&mut self, key: K, value: V, scope: Scope, origin: Option<O>)
856 where
857 K: Into<String>,
858 V: Into<ConfigValue>,
859 O: Into<String>,
860 {
861 self.entries.push(LayerEntry {
862 key: key.into(),
863 value: value.into(),
864 scope: normalize_scope(scope),
865 origin: origin.map(Into::into),
866 });
867 }
868
869 pub fn mark_all_secret(&mut self) {
870 for entry in &mut self.entries {
871 if !entry.value.is_secret() {
872 entry.value = entry.value.clone().into_secret();
873 }
874 }
875 }
876
877 pub fn remove_scoped(&mut self, key: &str, scope: &Scope) -> Option<ConfigValue> {
878 let normalized_scope = normalize_scope(scope.clone());
879 let index = self
880 .entries
881 .iter()
882 .rposition(|entry| entry.key == key && entry.scope == normalized_scope)?;
883 Some(self.entries.remove(index).value)
884 }
885
886 pub fn from_toml_str(raw: &str) -> Result<Self, ConfigError> {
887 let parsed = raw
888 .parse::<toml::Value>()
889 .map_err(|err| ConfigError::TomlParse(err.to_string()))?;
890
891 let root = parsed.as_table().ok_or(ConfigError::TomlRootMustBeTable)?;
892 let mut layer = ConfigLayer::default();
893
894 for (section, value) in root {
895 match section.as_str() {
896 "default" => {
897 let table = value
898 .as_table()
899 .ok_or_else(|| ConfigError::InvalidSection {
900 section: "default".to_string(),
901 expected: "table".to_string(),
902 })?;
903 flatten_table(&mut layer, table, "", &Scope::global())?;
904 }
905 "profile" => {
906 let profiles = value
907 .as_table()
908 .ok_or_else(|| ConfigError::InvalidSection {
909 section: "profile".to_string(),
910 expected: "table".to_string(),
911 })?;
912 for (profile, profile_table_value) in profiles {
913 let profile_table = profile_table_value.as_table().ok_or_else(|| {
914 ConfigError::InvalidSection {
915 section: format!("profile.{profile}"),
916 expected: "table".to_string(),
917 }
918 })?;
919 flatten_table(&mut layer, profile_table, "", &Scope::profile(profile))?;
920 }
921 }
922 "terminal" => {
923 let terminals =
924 value
925 .as_table()
926 .ok_or_else(|| ConfigError::InvalidSection {
927 section: "terminal".to_string(),
928 expected: "table".to_string(),
929 })?;
930
931 for (terminal, terminal_table_value) in terminals {
932 let terminal_table = terminal_table_value.as_table().ok_or_else(|| {
933 ConfigError::InvalidSection {
934 section: format!("terminal.{terminal}"),
935 expected: "table".to_string(),
936 }
937 })?;
938
939 for (key, terminal_value) in terminal_table {
940 if key == "profile" {
941 continue;
942 }
943
944 flatten_key_value(
945 &mut layer,
946 key,
947 terminal_value,
948 &Scope::terminal(terminal),
949 )?;
950 }
951
952 if let Some(profile_section) = terminal_table.get("profile") {
953 let profile_tables = profile_section.as_table().ok_or_else(|| {
954 ConfigError::InvalidSection {
955 section: format!("terminal.{terminal}.profile"),
956 expected: "table".to_string(),
957 }
958 })?;
959
960 for (profile_key, profile_value) in profile_tables {
961 if let Some(profile_table) = profile_value.as_table() {
962 flatten_table(
963 &mut layer,
964 profile_table,
965 "",
966 &Scope::profile_terminal(profile_key, terminal),
967 )?;
968 } else {
969 flatten_key_value(
970 &mut layer,
971 &format!("profile.{profile_key}"),
972 profile_value,
973 &Scope::terminal(terminal),
974 )?;
975 }
976 }
977 }
978 }
979 }
980 unknown => {
981 return Err(ConfigError::UnknownTopLevelSection(unknown.to_string()));
982 }
983 }
984 }
985
986 Ok(layer)
987 }
988
989 pub fn from_env_iter<I, K, V>(vars: I) -> Result<Self, ConfigError>
990 where
991 I: IntoIterator<Item = (K, V)>,
992 K: AsRef<str>,
993 V: AsRef<str>,
994 {
995 let mut layer = ConfigLayer::default();
996
997 for (name, value) in vars {
998 let key = name.as_ref();
999 if !key.starts_with("OSP__") {
1000 continue;
1001 }
1002
1003 let spec = parse_env_key(key)?;
1004 validate_key_scope(&spec.key, &spec.scope)?;
1005 let converted = ConfigValue::String(value.as_ref().to_string());
1006 validate_bootstrap_value(&spec.key, &converted)?;
1007 layer.insert_with_origin(spec.key, converted, spec.scope, Some(key.to_string()));
1008 }
1009
1010 Ok(layer)
1011 }
1012
1013 pub(crate) fn validate_entries(&self) -> Result<(), ConfigError> {
1014 for entry in &self.entries {
1015 validate_key_scope(&entry.key, &entry.scope)?;
1016 validate_bootstrap_value(&entry.key, &entry.value)?;
1017 }
1018
1019 Ok(())
1020 }
1021}
1022
1023pub(crate) struct EnvKeySpec {
1024 pub(crate) key: String,
1025 pub(crate) scope: Scope,
1026}
1027
1028#[derive(Debug, Clone, Default)]
1029pub struct ResolveOptions {
1030 pub profile_override: Option<String>,
1031 pub terminal: Option<String>,
1032}
1033
1034impl ResolveOptions {
1035 pub fn with_profile(mut self, profile: &str) -> Self {
1036 self.profile_override = Some(normalize_identifier(profile));
1037 self
1038 }
1039
1040 pub fn with_terminal(mut self, terminal: &str) -> Self {
1041 self.terminal = Some(normalize_identifier(terminal));
1042 self
1043 }
1044}
1045
1046#[derive(Debug, Clone, PartialEq)]
1047pub struct ResolvedValue {
1048 pub raw_value: ConfigValue,
1049 pub value: ConfigValue,
1050 pub source: ConfigSource,
1051 pub scope: Scope,
1052 pub origin: Option<String>,
1053}
1054
1055#[derive(Debug, Clone, PartialEq)]
1056pub struct ExplainCandidate {
1057 pub entry_index: usize,
1058 pub value: ConfigValue,
1059 pub scope: Scope,
1060 pub origin: Option<String>,
1061 pub rank: Option<u8>,
1062 pub selected_in_layer: bool,
1063}
1064
1065#[derive(Debug, Clone, PartialEq)]
1066pub struct ExplainLayer {
1067 pub source: ConfigSource,
1068 pub selected_entry_index: Option<usize>,
1069 pub candidates: Vec<ExplainCandidate>,
1070}
1071
1072#[derive(Debug, Clone, PartialEq)]
1073pub struct ExplainInterpolationStep {
1074 pub placeholder: String,
1075 pub raw_value: ConfigValue,
1076 pub value: ConfigValue,
1077 pub source: ConfigSource,
1078 pub scope: Scope,
1079 pub origin: Option<String>,
1080}
1081
1082#[derive(Debug, Clone, PartialEq)]
1083pub struct ExplainInterpolation {
1084 pub template: String,
1085 pub steps: Vec<ExplainInterpolationStep>,
1086}
1087
1088#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1089pub enum ActiveProfileSource {
1090 Override,
1091 DefaultProfile,
1092}
1093
1094impl ActiveProfileSource {
1095 pub fn as_str(self) -> &'static str {
1096 match self {
1097 Self::Override => "override",
1098 Self::DefaultProfile => "profile.default",
1099 }
1100 }
1101}
1102
1103#[derive(Debug, Clone, PartialEq)]
1104pub struct ConfigExplain {
1105 pub key: String,
1106 pub active_profile: String,
1107 pub active_profile_source: ActiveProfileSource,
1108 pub terminal: Option<String>,
1109 pub known_profiles: BTreeSet<String>,
1110 pub layers: Vec<ExplainLayer>,
1111 pub final_entry: Option<ResolvedValue>,
1112 pub interpolation: Option<ExplainInterpolation>,
1113}
1114
1115#[derive(Debug, Clone, PartialEq)]
1116pub struct BootstrapConfigExplain {
1117 pub key: String,
1118 pub active_profile: String,
1119 pub active_profile_source: ActiveProfileSource,
1120 pub terminal: Option<String>,
1121 pub known_profiles: BTreeSet<String>,
1122 pub layers: Vec<ExplainLayer>,
1123 pub final_entry: Option<ResolvedValue>,
1124}
1125
1126#[derive(Debug, Clone, PartialEq)]
1127pub struct ResolvedConfig {
1128 pub(crate) active_profile: String,
1129 pub(crate) terminal: Option<String>,
1130 pub(crate) known_profiles: BTreeSet<String>,
1131 pub(crate) values: BTreeMap<String, ResolvedValue>,
1132 pub(crate) aliases: BTreeMap<String, ResolvedValue>,
1133}
1134
1135impl ResolvedConfig {
1136 pub fn active_profile(&self) -> &str {
1137 &self.active_profile
1138 }
1139
1140 pub fn terminal(&self) -> Option<&str> {
1141 self.terminal.as_deref()
1142 }
1143
1144 pub fn known_profiles(&self) -> &BTreeSet<String> {
1145 &self.known_profiles
1146 }
1147
1148 pub fn values(&self) -> &BTreeMap<String, ResolvedValue> {
1149 &self.values
1150 }
1151
1152 pub fn aliases(&self) -> &BTreeMap<String, ResolvedValue> {
1153 &self.aliases
1154 }
1155
1156 pub fn get(&self, key: &str) -> Option<&ConfigValue> {
1157 self.values.get(key).map(|entry| &entry.value)
1158 }
1159
1160 pub fn get_string(&self, key: &str) -> Option<&str> {
1161 match self.get(key).map(ConfigValue::reveal) {
1162 Some(ConfigValue::String(value)) => Some(value),
1163 _ => None,
1164 }
1165 }
1166
1167 pub fn get_bool(&self, key: &str) -> Option<bool> {
1168 match self.get(key).map(ConfigValue::reveal) {
1169 Some(ConfigValue::Bool(value)) => Some(*value),
1170 _ => None,
1171 }
1172 }
1173
1174 pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
1175 match self.get(key).map(ConfigValue::reveal) {
1176 Some(ConfigValue::List(values)) => Some(
1177 values
1178 .iter()
1179 .filter_map(|value| match value {
1180 ConfigValue::String(text) => Some(text.clone()),
1181 ConfigValue::Secret(secret) => match secret.expose() {
1182 ConfigValue::String(text) => Some(text.clone()),
1183 _ => None,
1184 },
1185 _ => None,
1186 })
1187 .collect(),
1188 ),
1189 Some(ConfigValue::String(value)) => Some(vec![value.clone()]),
1190 Some(ConfigValue::Secret(secret)) => match secret.expose() {
1191 ConfigValue::String(value) => Some(vec![value.clone()]),
1192 _ => None,
1193 },
1194 _ => None,
1195 }
1196 }
1197
1198 pub fn get_value_entry(&self, key: &str) -> Option<&ResolvedValue> {
1199 self.values.get(key)
1200 }
1201
1202 pub fn get_alias_entry(&self, key: &str) -> Option<&ResolvedValue> {
1203 let normalized = if key.trim().to_ascii_lowercase().starts_with("alias.") {
1204 key.trim().to_ascii_lowercase()
1205 } else {
1206 format!("alias.{}", key.trim().to_ascii_lowercase())
1207 };
1208 self.aliases.get(&normalized)
1209 }
1210}
1211
1212fn flatten_table(
1213 layer: &mut ConfigLayer,
1214 table: &toml::value::Table,
1215 prefix: &str,
1216 scope: &Scope,
1217) -> Result<(), ConfigError> {
1218 for (key, value) in table {
1219 let full_key = if prefix.is_empty() {
1220 key.to_string()
1221 } else {
1222 format!("{prefix}.{key}")
1223 };
1224
1225 flatten_key_value(layer, &full_key, value, scope)?;
1226 }
1227
1228 Ok(())
1229}
1230
1231fn flatten_key_value(
1232 layer: &mut ConfigLayer,
1233 key: &str,
1234 value: &toml::Value,
1235 scope: &Scope,
1236) -> Result<(), ConfigError> {
1237 match value {
1238 toml::Value::Table(table) => flatten_table(layer, table, key, scope),
1239 _ => {
1240 let converted = ConfigValue::from_toml(key, value)?;
1241 validate_key_scope(key, scope)?;
1242 validate_bootstrap_value(key, &converted)?;
1243 layer.insert(key.to_string(), converted, scope.clone());
1244 Ok(())
1245 }
1246 }
1247}
1248
1249pub fn bootstrap_key_spec(key: &str) -> Option<BootstrapKeySpec> {
1250 builtin_config_schema().bootstrap_key_spec(key)
1251}
1252
1253pub fn is_bootstrap_only_key(key: &str) -> bool {
1254 bootstrap_key_spec(key).is_some_and(|spec| !spec.runtime_visible)
1255}
1256
1257pub fn is_alias_key(key: &str) -> bool {
1258 key.trim().to_ascii_lowercase().starts_with("alias.")
1259}
1260
1261pub fn validate_key_scope(key: &str, scope: &Scope) -> Result<(), ConfigError> {
1262 builtin_config_schema().validate_key_scope(key, scope)
1263}
1264
1265pub fn validate_bootstrap_value(key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
1266 builtin_config_schema().validate_bootstrap_value(key, value)
1267}
1268
1269fn adapt_value_for_schema(
1270 key: &str,
1271 value: &ConfigValue,
1272 schema: &SchemaEntry,
1273) -> Result<ConfigValue, ConfigError> {
1274 let (is_secret, value) = match value {
1275 ConfigValue::Secret(secret) => (true, secret.expose()),
1276 other => (false, other),
1277 };
1278
1279 let adapted = match schema.value_type {
1280 SchemaValueType::String => match value {
1281 ConfigValue::String(value) => ConfigValue::String(value.clone()),
1282 other => {
1283 return Err(ConfigError::InvalidValueType {
1284 key: key.to_string(),
1285 expected: SchemaValueType::String,
1286 actual: value_type_name(other).to_string(),
1287 });
1288 }
1289 },
1290 SchemaValueType::Bool => match value {
1291 ConfigValue::Bool(value) => ConfigValue::Bool(*value),
1292 ConfigValue::String(value) => {
1293 ConfigValue::Bool(parse_bool(value).ok_or_else(|| {
1294 ConfigError::InvalidValueType {
1295 key: key.to_string(),
1296 expected: SchemaValueType::Bool,
1297 actual: "string".to_string(),
1298 }
1299 })?)
1300 }
1301 other => {
1302 return Err(ConfigError::InvalidValueType {
1303 key: key.to_string(),
1304 expected: SchemaValueType::Bool,
1305 actual: value_type_name(other).to_string(),
1306 });
1307 }
1308 },
1309 SchemaValueType::Integer => match value {
1310 ConfigValue::Integer(value) => ConfigValue::Integer(*value),
1311 ConfigValue::String(value) => {
1312 let parsed =
1313 value
1314 .trim()
1315 .parse::<i64>()
1316 .map_err(|_| ConfigError::InvalidValueType {
1317 key: key.to_string(),
1318 expected: SchemaValueType::Integer,
1319 actual: "string".to_string(),
1320 })?;
1321 ConfigValue::Integer(parsed)
1322 }
1323 other => {
1324 return Err(ConfigError::InvalidValueType {
1325 key: key.to_string(),
1326 expected: SchemaValueType::Integer,
1327 actual: value_type_name(other).to_string(),
1328 });
1329 }
1330 },
1331 SchemaValueType::Float => match value {
1332 ConfigValue::Float(value) => ConfigValue::Float(*value),
1333 ConfigValue::Integer(value) => ConfigValue::Float(*value as f64),
1334 ConfigValue::String(value) => {
1335 let parsed =
1336 value
1337 .trim()
1338 .parse::<f64>()
1339 .map_err(|_| ConfigError::InvalidValueType {
1340 key: key.to_string(),
1341 expected: SchemaValueType::Float,
1342 actual: "string".to_string(),
1343 })?;
1344 ConfigValue::Float(parsed)
1345 }
1346 other => {
1347 return Err(ConfigError::InvalidValueType {
1348 key: key.to_string(),
1349 expected: SchemaValueType::Float,
1350 actual: value_type_name(other).to_string(),
1351 });
1352 }
1353 },
1354 SchemaValueType::StringList => match value {
1355 ConfigValue::List(values) => {
1356 let mut out = Vec::with_capacity(values.len());
1357 for value in values {
1358 match value {
1359 ConfigValue::String(value) => out.push(ConfigValue::String(value.clone())),
1360 ConfigValue::Secret(secret) => match secret.expose() {
1361 ConfigValue::String(value) => {
1362 out.push(ConfigValue::String(value.clone()))
1363 }
1364 other => {
1365 return Err(ConfigError::InvalidValueType {
1366 key: key.to_string(),
1367 expected: SchemaValueType::StringList,
1368 actual: value_type_name(other).to_string(),
1369 });
1370 }
1371 },
1372 other => {
1373 return Err(ConfigError::InvalidValueType {
1374 key: key.to_string(),
1375 expected: SchemaValueType::StringList,
1376 actual: value_type_name(other).to_string(),
1377 });
1378 }
1379 }
1380 }
1381 ConfigValue::List(out)
1382 }
1383 ConfigValue::String(value) => {
1384 let items = parse_string_list(value);
1385 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
1386 }
1387 ConfigValue::Secret(secret) => match secret.expose() {
1388 ConfigValue::String(value) => {
1389 let items = parse_string_list(value);
1390 ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
1391 }
1392 other => {
1393 return Err(ConfigError::InvalidValueType {
1394 key: key.to_string(),
1395 expected: SchemaValueType::StringList,
1396 actual: value_type_name(other).to_string(),
1397 });
1398 }
1399 },
1400 other => {
1401 return Err(ConfigError::InvalidValueType {
1402 key: key.to_string(),
1403 expected: SchemaValueType::StringList,
1404 actual: value_type_name(other).to_string(),
1405 });
1406 }
1407 },
1408 };
1409
1410 let adapted = if is_secret {
1411 adapted.into_secret()
1412 } else {
1413 adapted
1414 };
1415
1416 if let Some(allowed_values) = &schema.allowed_values
1417 && let ConfigValue::String(value) = adapted.reveal()
1418 {
1419 let normalized = value.to_ascii_lowercase();
1420 if !allowed_values.contains(&normalized) {
1421 return Err(ConfigError::InvalidEnumValue {
1422 key: key.to_string(),
1423 value: value.clone(),
1424 allowed: allowed_values.clone(),
1425 });
1426 }
1427 }
1428
1429 Ok(adapted)
1430}
1431
1432fn parse_bool(value: &str) -> Option<bool> {
1433 match value.trim().to_ascii_lowercase().as_str() {
1434 "true" => Some(true),
1435 "false" => Some(false),
1436 _ => None,
1437 }
1438}
1439
1440fn parse_string_list(value: &str) -> Vec<String> {
1441 let trimmed = value.trim();
1442 if trimmed.is_empty() {
1443 return Vec::new();
1444 }
1445
1446 let inner = trimmed
1447 .strip_prefix('[')
1448 .and_then(|value| value.strip_suffix(']'))
1449 .unwrap_or(trimmed);
1450
1451 inner
1452 .split(',')
1453 .map(|value| value.trim())
1454 .filter(|value| !value.is_empty())
1455 .map(|value| {
1456 value
1457 .strip_prefix('"')
1458 .and_then(|value| value.strip_suffix('"'))
1459 .or_else(|| {
1460 value
1461 .strip_prefix('\'')
1462 .and_then(|value| value.strip_suffix('\''))
1463 })
1464 .unwrap_or(value)
1465 .to_string()
1466 })
1467 .collect()
1468}
1469
1470fn value_type_name(value: &ConfigValue) -> &'static str {
1471 match value.reveal() {
1472 ConfigValue::String(_) => "string",
1473 ConfigValue::Bool(_) => "bool",
1474 ConfigValue::Integer(_) => "integer",
1475 ConfigValue::Float(_) => "float",
1476 ConfigValue::List(_) => "list",
1477 ConfigValue::Secret(_) => "string",
1478 }
1479}
1480
1481pub(crate) fn parse_env_key(key: &str) -> Result<EnvKeySpec, ConfigError> {
1482 let Some(raw) = key.strip_prefix("OSP__") else {
1483 return Err(ConfigError::InvalidEnvOverride {
1484 key: key.to_string(),
1485 reason: "missing OSP__ prefix".to_string(),
1486 });
1487 };
1488
1489 let parts = raw
1490 .split("__")
1491 .filter(|part| !part.is_empty())
1492 .collect::<Vec<&str>>();
1493
1494 if parts.is_empty() {
1495 return Err(ConfigError::InvalidEnvOverride {
1496 key: key.to_string(),
1497 reason: "missing key path".to_string(),
1498 });
1499 }
1500
1501 let mut cursor = 0usize;
1502 let mut terminal: Option<String> = None;
1503 let mut profile: Option<String> = None;
1504
1505 while cursor < parts.len() {
1506 let part = parts[cursor];
1507 if part.eq_ignore_ascii_case("TERM") {
1508 if terminal.is_some() {
1509 return Err(ConfigError::InvalidEnvOverride {
1510 key: key.to_string(),
1511 reason: "TERM scope specified more than once".to_string(),
1512 });
1513 }
1514 let term = parts
1515 .get(cursor + 1)
1516 .ok_or_else(|| ConfigError::InvalidEnvOverride {
1517 key: key.to_string(),
1518 reason: "TERM requires a terminal name".to_string(),
1519 })?;
1520 terminal = Some(normalize_identifier(term));
1521 cursor += 2;
1522 continue;
1523 }
1524
1525 if part.eq_ignore_ascii_case("PROFILE") {
1526 if remaining_parts_are_bootstrap_profile_default(&parts[cursor..]) {
1529 break;
1530 }
1531 if profile.is_some() {
1532 return Err(ConfigError::InvalidEnvOverride {
1533 key: key.to_string(),
1534 reason: "PROFILE scope specified more than once".to_string(),
1535 });
1536 }
1537 let profile_name =
1538 parts
1539 .get(cursor + 1)
1540 .ok_or_else(|| ConfigError::InvalidEnvOverride {
1541 key: key.to_string(),
1542 reason: "PROFILE requires a profile name".to_string(),
1543 })?;
1544 profile = Some(normalize_identifier(profile_name));
1545 cursor += 2;
1546 continue;
1547 }
1548
1549 break;
1550 }
1551
1552 let key_parts = &parts[cursor..];
1553 if key_parts.is_empty() {
1554 return Err(ConfigError::InvalidEnvOverride {
1555 key: key.to_string(),
1556 reason: "missing final config key".to_string(),
1557 });
1558 }
1559
1560 let dotted_key = key_parts
1561 .iter()
1562 .map(|part| part.to_ascii_lowercase())
1563 .collect::<Vec<String>>()
1564 .join(".");
1565
1566 Ok(EnvKeySpec {
1567 key: dotted_key,
1568 scope: Scope { profile, terminal },
1569 })
1570}
1571
1572fn remaining_parts_are_bootstrap_profile_default(parts: &[&str]) -> bool {
1573 matches!(parts, [profile, default]
1574 if profile.eq_ignore_ascii_case("PROFILE")
1575 && default.eq_ignore_ascii_case("DEFAULT"))
1576}
1577
1578pub(crate) fn normalize_scope(scope: Scope) -> Scope {
1579 Scope {
1580 profile: scope
1581 .profile
1582 .as_deref()
1583 .map(normalize_identifier)
1584 .filter(|value| !value.is_empty()),
1585 terminal: scope
1586 .terminal
1587 .as_deref()
1588 .map(normalize_identifier)
1589 .filter(|value| !value.is_empty()),
1590 }
1591}
1592
1593pub(crate) fn normalize_identifier(value: &str) -> String {
1594 value.trim().to_ascii_lowercase()
1595}
1596
1597#[cfg(test)]
1598mod tests;