Skip to main content

osp_cli/config/
core.rs

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            // `profile.default` is a bootstrap key, not a profile scope. Keep
1527            // the exception isolated here so the scope parser stays readable.
1528            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;