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/// Result details for an in-place TOML edit operation.
8#[derive(Debug, Clone, PartialEq)]
9pub struct TomlEditResult {
10    /// Previous value removed or replaced by the edit, if one existed.
11    pub previous: Option<ConfigValue>,
12}
13
14/// Origin of a resolved configuration value.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub enum ConfigSource {
17    /// Built-in defaults compiled into the CLI.
18    BuiltinDefaults,
19    /// Presentation defaults derived from the active UI preset.
20    PresentationDefaults,
21    /// Values loaded from user configuration files.
22    ConfigFile,
23    /// Values loaded from the secrets file.
24    Secrets,
25    /// Values supplied through `OSP__...` environment variables.
26    Environment,
27    /// Values supplied on the current command line.
28    Cli,
29    /// Values recorded for the current interactive session.
30    Session,
31    /// Values derived internally during resolution.
32    Derived,
33}
34
35impl Display for ConfigSource {
36    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
37        let value = match self {
38            ConfigSource::BuiltinDefaults => "defaults",
39            ConfigSource::PresentationDefaults => "presentation",
40            ConfigSource::ConfigFile => "file",
41            ConfigSource::Secrets => "secrets",
42            ConfigSource::Environment => "env",
43            ConfigSource::Cli => "cli",
44            ConfigSource::Session => "session",
45            ConfigSource::Derived => "derived",
46        };
47        write!(f, "{value}")
48    }
49}
50
51/// Typed value stored in config layers and resolved output.
52#[derive(Debug, Clone, PartialEq)]
53pub enum ConfigValue {
54    /// UTF-8 string value.
55    String(String),
56    /// Boolean value.
57    Bool(bool),
58    /// Signed 64-bit integer value.
59    Integer(i64),
60    /// 64-bit floating-point value.
61    Float(f64),
62    /// Ordered list of nested config values.
63    List(Vec<ConfigValue>),
64    /// Value wrapped for redacted display and debug output.
65    Secret(SecretValue),
66}
67
68impl ConfigValue {
69    /// Returns `true` when the value is wrapped as a secret.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use osp_cli::config::ConfigValue;
75    ///
76    /// assert!(!ConfigValue::String("alice".to_string()).is_secret());
77    /// assert!(ConfigValue::String("alice".to_string()).into_secret().is_secret());
78    /// ```
79    pub fn is_secret(&self) -> bool {
80        matches!(self, ConfigValue::Secret(_))
81    }
82
83    /// Returns the underlying value, unwrapping one secret layer if present.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// use osp_cli::config::ConfigValue;
89    ///
90    /// let secret = ConfigValue::String("alice".to_string()).into_secret();
91    /// assert_eq!(secret.reveal(), &ConfigValue::String("alice".to_string()));
92    /// ```
93    pub fn reveal(&self) -> &ConfigValue {
94        match self {
95            ConfigValue::Secret(secret) => secret.expose(),
96            other => other,
97        }
98    }
99
100    /// Wraps the value as a secret unless it is already secret.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use osp_cli::config::ConfigValue;
106    ///
107    /// let wrapped = ConfigValue::String("token".to_string()).into_secret();
108    /// assert!(wrapped.is_secret());
109    /// ```
110    pub fn into_secret(self) -> ConfigValue {
111        match self {
112            ConfigValue::Secret(_) => self,
113            other => ConfigValue::Secret(SecretValue::new(other)),
114        }
115    }
116
117    pub(crate) fn from_toml(path: &str, value: &toml::Value) -> Result<Self, ConfigError> {
118        match value {
119            toml::Value::String(v) => Ok(Self::String(v.clone())),
120            toml::Value::Integer(v) => Ok(Self::Integer(*v)),
121            toml::Value::Float(v) => Ok(Self::Float(*v)),
122            toml::Value::Boolean(v) => Ok(Self::Bool(*v)),
123            toml::Value::Datetime(v) => Ok(Self::String(v.to_string())),
124            toml::Value::Array(values) => {
125                let mut out = Vec::with_capacity(values.len());
126                for item in values {
127                    out.push(Self::from_toml(path, item)?);
128                }
129                Ok(Self::List(out))
130            }
131            toml::Value::Table(_) => Err(ConfigError::UnsupportedTomlValue {
132                path: path.to_string(),
133                kind: "table".to_string(),
134            }),
135        }
136    }
137
138    pub(crate) fn as_interpolation_string(
139        &self,
140        key: &str,
141        placeholder: &str,
142    ) -> Result<String, ConfigError> {
143        match self.reveal() {
144            ConfigValue::String(value) => Ok(value.clone()),
145            ConfigValue::Bool(value) => Ok(value.to_string()),
146            ConfigValue::Integer(value) => Ok(value.to_string()),
147            ConfigValue::Float(value) => Ok(value.to_string()),
148            ConfigValue::List(_) => Err(ConfigError::NonScalarPlaceholder {
149                key: key.to_string(),
150                placeholder: placeholder.to_string(),
151            }),
152            ConfigValue::Secret(_) => Err(ConfigError::NonScalarPlaceholder {
153                key: key.to_string(),
154                placeholder: placeholder.to_string(),
155            }),
156        }
157    }
158}
159
160/// Secret config value that redacts its display and debug output.
161#[derive(Clone, PartialEq)]
162pub struct SecretValue(Box<ConfigValue>);
163
164impl SecretValue {
165    /// Wraps a config value in a secret container.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use osp_cli::config::{ConfigValue, SecretValue};
171    ///
172    /// let secret = SecretValue::new(ConfigValue::String("hidden".to_string()));
173    /// assert_eq!(secret.expose(), &ConfigValue::String("hidden".to_string()));
174    /// ```
175    pub fn new(value: ConfigValue) -> Self {
176        Self(Box::new(value))
177    }
178
179    /// Returns the underlying unredacted value.
180    pub fn expose(&self) -> &ConfigValue {
181        &self.0
182    }
183
184    /// Consumes the wrapper and returns the inner value.
185    pub fn into_inner(self) -> ConfigValue {
186        *self.0
187    }
188}
189
190impl std::fmt::Debug for SecretValue {
191    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
192        write!(f, "[REDACTED]")
193    }
194}
195
196impl Display for SecretValue {
197    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
198        write!(f, "[REDACTED]")
199    }
200}
201
202/// Schema-level type used for parsing and validation.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum SchemaValueType {
205    /// Scalar string value.
206    String,
207    /// Scalar boolean value.
208    Bool,
209    /// Scalar signed integer value.
210    Integer,
211    /// Scalar floating-point value.
212    Float,
213    /// List of string values.
214    StringList,
215}
216
217impl Display for SchemaValueType {
218    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
219        let value = match self {
220            SchemaValueType::String => "string",
221            SchemaValueType::Bool => "bool",
222            SchemaValueType::Integer => "integer",
223            SchemaValueType::Float => "float",
224            SchemaValueType::StringList => "list",
225        };
226        write!(f, "{value}")
227    }
228}
229
230/// Bootstrap stage in which a key must be resolved.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum BootstrapPhase {
233    /// The key is needed before path-dependent config can be loaded.
234    Path,
235    /// The key is needed before the active profile can be finalized.
236    Profile,
237}
238
239/// Scope restriction for bootstrap-only keys.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub enum BootstrapScopeRule {
242    /// The key is valid only in the global scope.
243    GlobalOnly,
244    /// The key is valid globally or in a terminal-only scope.
245    GlobalOrTerminal,
246}
247
248/// Additional validation rule for bootstrap values.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum BootstrapValueRule {
251    /// The value must be a string containing at least one non-whitespace character.
252    NonEmptyString,
253}
254
255/// Bootstrap metadata derived from a schema entry.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct BootstrapKeySpec {
258    /// Canonical dotted config key.
259    pub key: &'static str,
260    /// Bootstrap phase in which the key is consulted.
261    pub phase: BootstrapPhase,
262    /// Whether the key also appears in the runtime-resolved config.
263    pub runtime_visible: bool,
264    /// Scope restriction enforced for the key.
265    pub scope_rule: BootstrapScopeRule,
266}
267
268impl BootstrapKeySpec {
269    fn allows_scope(&self, scope: &Scope) -> bool {
270        match self.scope_rule {
271            BootstrapScopeRule::GlobalOnly => scope.profile.is_none() && scope.terminal.is_none(),
272            BootstrapScopeRule::GlobalOrTerminal => scope.profile.is_none(),
273        }
274    }
275}
276
277/// Schema definition for a single config key.
278#[derive(Debug, Clone)]
279pub struct SchemaEntry {
280    canonical_key: Option<&'static str>,
281    value_type: SchemaValueType,
282    required: bool,
283    writable: bool,
284    allowed_values: Option<Vec<String>>,
285    runtime_visible: bool,
286    bootstrap_phase: Option<BootstrapPhase>,
287    bootstrap_scope_rule: Option<BootstrapScopeRule>,
288    bootstrap_value_rule: Option<BootstrapValueRule>,
289}
290
291impl SchemaEntry {
292    /// Starts a schema entry for string values.
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// use osp_cli::config::{SchemaEntry, SchemaValueType};
298    ///
299    /// let entry = SchemaEntry::string().required();
300    /// assert_eq!(entry.value_type(), SchemaValueType::String);
301    /// assert!(entry.runtime_visible());
302    /// ```
303    pub fn string() -> Self {
304        Self {
305            canonical_key: None,
306            value_type: SchemaValueType::String,
307            required: false,
308            writable: true,
309            allowed_values: None,
310            runtime_visible: true,
311            bootstrap_phase: None,
312            bootstrap_scope_rule: None,
313            bootstrap_value_rule: None,
314        }
315    }
316
317    /// Starts a schema entry for boolean values.
318    pub fn boolean() -> Self {
319        Self {
320            canonical_key: None,
321            value_type: SchemaValueType::Bool,
322            required: false,
323            writable: true,
324            allowed_values: None,
325            runtime_visible: true,
326            bootstrap_phase: None,
327            bootstrap_scope_rule: None,
328            bootstrap_value_rule: None,
329        }
330    }
331
332    /// Starts a schema entry for integer values.
333    pub fn integer() -> Self {
334        Self {
335            canonical_key: None,
336            value_type: SchemaValueType::Integer,
337            required: false,
338            writable: true,
339            allowed_values: None,
340            runtime_visible: true,
341            bootstrap_phase: None,
342            bootstrap_scope_rule: None,
343            bootstrap_value_rule: None,
344        }
345    }
346
347    /// Starts a schema entry for floating-point values.
348    pub fn float() -> Self {
349        Self {
350            canonical_key: None,
351            value_type: SchemaValueType::Float,
352            required: false,
353            writable: true,
354            allowed_values: None,
355            runtime_visible: true,
356            bootstrap_phase: None,
357            bootstrap_scope_rule: None,
358            bootstrap_value_rule: None,
359        }
360    }
361
362    /// Starts a schema entry for lists of strings.
363    pub fn string_list() -> Self {
364        Self {
365            canonical_key: None,
366            value_type: SchemaValueType::StringList,
367            required: false,
368            writable: true,
369            allowed_values: None,
370            runtime_visible: true,
371            bootstrap_phase: None,
372            bootstrap_scope_rule: None,
373            bootstrap_value_rule: None,
374        }
375    }
376
377    /// Marks the key as required in the resolved runtime view.
378    pub fn required(mut self) -> Self {
379        self.required = true;
380        self
381    }
382
383    /// Marks the key as read-only for user-provided config sources.
384    pub fn read_only(mut self) -> Self {
385        self.writable = false;
386        self
387    }
388
389    /// Marks the key as bootstrap-only with the given phase and scope rule.
390    pub fn bootstrap_only(mut self, phase: BootstrapPhase, scope_rule: BootstrapScopeRule) -> Self {
391        self.runtime_visible = false;
392        self.bootstrap_phase = Some(phase);
393        self.bootstrap_scope_rule = Some(scope_rule);
394        self
395    }
396
397    /// Adds a bootstrap-only value validation rule.
398    pub fn with_bootstrap_value_rule(mut self, rule: BootstrapValueRule) -> Self {
399        self.bootstrap_value_rule = Some(rule);
400        self
401    }
402
403    /// Restricts accepted values using a case-insensitive allow-list.
404    pub fn with_allowed_values<I, S>(mut self, values: I) -> Self
405    where
406        I: IntoIterator<Item = S>,
407        S: AsRef<str>,
408    {
409        self.allowed_values = Some(
410            values
411                .into_iter()
412                .map(|value| value.as_ref().to_ascii_lowercase())
413                .collect(),
414        );
415        self
416    }
417
418    /// Returns the declared schema type for the key.
419    pub fn value_type(&self) -> SchemaValueType {
420        self.value_type
421    }
422
423    /// Returns the normalized allow-list, if the key is enumerated.
424    pub fn allowed_values(&self) -> Option<&[String]> {
425        self.allowed_values.as_deref()
426    }
427
428    /// Returns whether the key is visible in resolved runtime config.
429    pub fn runtime_visible(&self) -> bool {
430        self.runtime_visible
431    }
432
433    /// Returns whether the key can be written by user-controlled sources.
434    pub fn writable(&self) -> bool {
435        self.writable
436    }
437
438    fn with_canonical_key(mut self, key: &'static str) -> Self {
439        self.canonical_key = Some(key);
440        self
441    }
442
443    fn bootstrap_spec(&self) -> Option<BootstrapKeySpec> {
444        Some(BootstrapKeySpec {
445            key: self.canonical_key?,
446            phase: self.bootstrap_phase?,
447            runtime_visible: self.runtime_visible,
448            scope_rule: self.bootstrap_scope_rule?,
449        })
450    }
451
452    fn validate_bootstrap_value(&self, key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
453        match self.bootstrap_value_rule {
454            Some(BootstrapValueRule::NonEmptyString) => match value.reveal() {
455                ConfigValue::String(current) if !current.trim().is_empty() => Ok(()),
456                ConfigValue::String(current) => Err(ConfigError::InvalidBootstrapValue {
457                    key: key.to_string(),
458                    reason: format!("expected a non-empty string, got {current:?}"),
459                }),
460                other => Err(ConfigError::InvalidBootstrapValue {
461                    key: key.to_string(),
462                    reason: format!("expected string, got {other:?}"),
463                }),
464            },
465            None => Ok(()),
466        }
467    }
468}
469
470/// Config schema used for validation, parsing, and runtime filtering.
471#[derive(Debug, Clone)]
472pub struct ConfigSchema {
473    entries: BTreeMap<String, SchemaEntry>,
474    allow_extensions_namespace: bool,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478enum DynamicSchemaKeyKind {
479    PluginCommandState,
480    PluginCommandProvider,
481}
482
483impl Default for ConfigSchema {
484    fn default() -> Self {
485        builtin_config_schema().clone()
486    }
487}
488
489impl ConfigSchema {
490    fn builtin() -> Self {
491        let mut schema = Self {
492            entries: BTreeMap::new(),
493            allow_extensions_namespace: true,
494        };
495
496        schema.insert(
497            "profile.default",
498            SchemaEntry::string()
499                .bootstrap_only(
500                    BootstrapPhase::Profile,
501                    BootstrapScopeRule::GlobalOrTerminal,
502                )
503                .with_bootstrap_value_rule(BootstrapValueRule::NonEmptyString),
504        );
505        schema.insert(
506            "profile.active",
507            SchemaEntry::string().required().read_only(),
508        );
509        schema.insert("theme.name", SchemaEntry::string());
510        schema.insert("theme.path", SchemaEntry::string_list());
511        schema.insert("user.name", SchemaEntry::string());
512        schema.insert("user.display_name", SchemaEntry::string());
513        schema.insert("user.full_name", SchemaEntry::string());
514        schema.insert("domain", SchemaEntry::string());
515
516        schema.insert(
517            "ui.format",
518            SchemaEntry::string()
519                .with_allowed_values(["auto", "guide", "json", "table", "md", "mreg", "value"]),
520        );
521        schema.insert(
522            "ui.mode",
523            SchemaEntry::string().with_allowed_values(["auto", "plain", "rich"]),
524        );
525        schema.insert(
526            "ui.presentation",
527            SchemaEntry::string().with_allowed_values([
528                "expressive",
529                "compact",
530                "austere",
531                "gammel-og-bitter",
532            ]),
533        );
534        schema.insert(
535            "ui.color.mode",
536            SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
537        );
538        schema.insert(
539            "ui.unicode.mode",
540            SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
541        );
542        schema.insert("ui.width", SchemaEntry::integer());
543        schema.insert("ui.margin", SchemaEntry::integer());
544        schema.insert("ui.indent", SchemaEntry::integer());
545        schema.insert(
546            "ui.help.level",
547            SchemaEntry::string()
548                .with_allowed_values(["inherit", "none", "tiny", "normal", "verbose"]),
549        );
550        schema.insert(
551            "ui.guide.default_format",
552            SchemaEntry::string().with_allowed_values(["guide", "inherit", "none"]),
553        );
554        schema.insert(
555            "ui.messages.layout",
556            SchemaEntry::string().with_allowed_values(["grouped", "minimal"]),
557        );
558        schema.insert(
559            "ui.chrome.frame",
560            SchemaEntry::string().with_allowed_values([
561                "none",
562                "top",
563                "bottom",
564                "top-bottom",
565                "square",
566                "round",
567            ]),
568        );
569        schema.insert(
570            "ui.chrome.rule_policy",
571            SchemaEntry::string().with_allowed_values([
572                "per-section",
573                "independent",
574                "separate",
575                "shared",
576                "stacked",
577                "list",
578            ]),
579        );
580        schema.insert(
581            "ui.table.overflow",
582            SchemaEntry::string().with_allowed_values([
583                "clip", "hidden", "crop", "ellipsis", "truncate", "wrap", "none", "visible",
584            ]),
585        );
586        schema.insert(
587            "ui.table.border",
588            SchemaEntry::string().with_allowed_values(["none", "square", "round"]),
589        );
590        schema.insert(
591            "ui.help.table_chrome",
592            SchemaEntry::string().with_allowed_values(["inherit", "none", "square", "round"]),
593        );
594        schema.insert("ui.help.entry_indent", SchemaEntry::string());
595        schema.insert("ui.help.entry_gap", SchemaEntry::string());
596        schema.insert("ui.help.section_spacing", SchemaEntry::string());
597        schema.insert("ui.short_list_max", SchemaEntry::integer());
598        schema.insert("ui.medium_list_max", SchemaEntry::integer());
599        schema.insert("ui.grid_padding", SchemaEntry::integer());
600        schema.insert("ui.grid_columns", SchemaEntry::integer());
601        schema.insert("ui.column_weight", SchemaEntry::integer());
602        schema.insert("ui.mreg.stack_min_col_width", SchemaEntry::integer());
603        schema.insert("ui.mreg.stack_overflow_ratio", SchemaEntry::integer());
604        schema.insert(
605            "ui.message.verbosity",
606            SchemaEntry::string()
607                .with_allowed_values(["error", "warning", "success", "info", "trace"]),
608        );
609        schema.insert("ui.prompt", SchemaEntry::string());
610        schema.insert("ui.prompt.secrets", SchemaEntry::boolean());
611        schema.insert("extensions.plugins.timeout_ms", SchemaEntry::integer());
612        schema.insert("extensions.plugins.discovery.path", SchemaEntry::boolean());
613        schema.insert("repl.prompt", SchemaEntry::string());
614        schema.insert(
615            "repl.input_mode",
616            SchemaEntry::string().with_allowed_values(["auto", "interactive", "basic"]),
617        );
618        schema.insert("repl.simple_prompt", SchemaEntry::boolean());
619        schema.insert("repl.shell_indicator", SchemaEntry::string());
620        schema.insert(
621            "repl.intro",
622            SchemaEntry::string().with_allowed_values(["none", "minimal", "compact", "full"]),
623        );
624        schema.insert("repl.intro_template.minimal", SchemaEntry::string());
625        schema.insert("repl.intro_template.compact", SchemaEntry::string());
626        schema.insert("repl.intro_template.full", SchemaEntry::string());
627        schema.insert("repl.history.path", SchemaEntry::string());
628        schema.insert("repl.history.max_entries", SchemaEntry::integer());
629        schema.insert("repl.history.enabled", SchemaEntry::boolean());
630        schema.insert("repl.history.dedupe", SchemaEntry::boolean());
631        schema.insert("repl.history.profile_scoped", SchemaEntry::boolean());
632        schema.insert("repl.history.menu_rows", SchemaEntry::integer());
633        schema.insert("repl.history.exclude", SchemaEntry::string_list());
634        schema.insert("session.cache.max_results", SchemaEntry::integer());
635        schema.insert("color.prompt.text", SchemaEntry::string());
636        schema.insert("color.prompt.command", SchemaEntry::string());
637        schema.insert("color.prompt.completion.text", SchemaEntry::string());
638        schema.insert("color.prompt.completion.background", SchemaEntry::string());
639        schema.insert("color.prompt.completion.highlight", SchemaEntry::string());
640        schema.insert("color.text", SchemaEntry::string());
641        schema.insert("color.text.muted", SchemaEntry::string());
642        schema.insert("color.key", SchemaEntry::string());
643        schema.insert("color.border", SchemaEntry::string());
644        schema.insert("color.table.header", SchemaEntry::string());
645        schema.insert("color.mreg.key", SchemaEntry::string());
646        schema.insert("color.value", SchemaEntry::string());
647        schema.insert("color.value.number", SchemaEntry::string());
648        schema.insert("color.value.bool_true", SchemaEntry::string());
649        schema.insert("color.value.bool_false", SchemaEntry::string());
650        schema.insert("color.value.null", SchemaEntry::string());
651        schema.insert("color.value.ipv4", SchemaEntry::string());
652        schema.insert("color.value.ipv6", SchemaEntry::string());
653        schema.insert("color.panel.border", SchemaEntry::string());
654        schema.insert("color.panel.title", SchemaEntry::string());
655        schema.insert("color.code", SchemaEntry::string());
656        schema.insert("color.json.key", SchemaEntry::string());
657        schema.insert("color.message.error", SchemaEntry::string());
658        schema.insert("color.message.warning", SchemaEntry::string());
659        schema.insert("color.message.success", SchemaEntry::string());
660        schema.insert("color.message.info", SchemaEntry::string());
661        schema.insert("color.message.trace", SchemaEntry::string());
662        schema.insert("auth.visible.builtins", SchemaEntry::string());
663        schema.insert("auth.visible.plugins", SchemaEntry::string());
664        schema.insert("debug.level", SchemaEntry::integer());
665        schema.insert("log.file.enabled", SchemaEntry::boolean());
666        schema.insert("log.file.path", SchemaEntry::string());
667        schema.insert(
668            "log.file.level",
669            SchemaEntry::string().with_allowed_values(["error", "warn", "info", "debug", "trace"]),
670        );
671
672        schema.insert("base.dir", SchemaEntry::string());
673
674        schema
675    }
676}
677
678impl ConfigSchema {
679    /// Registers or replaces a schema entry for a canonical key.
680    pub fn insert(&mut self, key: &'static str, entry: SchemaEntry) {
681        self.entries
682            .insert(key.to_string(), entry.with_canonical_key(key));
683    }
684
685    /// Enables or disables the `extensions.*` namespace shortcut.
686    pub fn set_allow_extensions_namespace(&mut self, value: bool) {
687        self.allow_extensions_namespace = value;
688    }
689
690    /// Returns whether the key is recognized by the schema.
691    pub fn is_known_key(&self, key: &str) -> bool {
692        self.entries.contains_key(key)
693            || self.is_extension_key(key)
694            || self.is_alias_key(key)
695            || dynamic_schema_key_kind(key).is_some()
696    }
697
698    /// Returns whether the key can appear in resolved runtime output.
699    pub fn is_runtime_visible_key(&self, key: &str) -> bool {
700        self.entries
701            .get(key)
702            .is_some_and(SchemaEntry::runtime_visible)
703            || self.is_extension_key(key)
704            || dynamic_schema_key_kind(key).is_some()
705    }
706
707    /// Rejects read-only keys for user-supplied config input.
708    pub fn validate_writable_key(&self, key: &str) -> Result<(), ConfigError> {
709        let normalized = key.trim().to_ascii_lowercase();
710        if let Some(entry) = self.entries.get(&normalized)
711            && !entry.writable()
712        {
713            return Err(ConfigError::ReadOnlyConfigKey {
714                key: normalized,
715                reason: "derived at runtime".to_string(),
716            });
717        }
718        Ok(())
719    }
720
721    /// Returns bootstrap metadata for the key, if it has bootstrap semantics.
722    pub fn bootstrap_key_spec(&self, key: &str) -> Option<BootstrapKeySpec> {
723        let normalized = key.trim().to_ascii_lowercase();
724        self.entries
725            .get(&normalized)
726            .and_then(SchemaEntry::bootstrap_spec)
727    }
728
729    /// Iterates over canonical schema entries.
730    pub fn entries(&self) -> impl Iterator<Item = (&str, &SchemaEntry)> {
731        self.entries
732            .iter()
733            .map(|(key, entry)| (key.as_str(), entry))
734    }
735
736    /// Returns the expected runtime type for a key.
737    pub fn expected_type(&self, key: &str) -> Option<SchemaValueType> {
738        self.entries
739            .get(key)
740            .map(|entry| entry.value_type)
741            .or_else(|| dynamic_schema_key_kind(key).map(|_| SchemaValueType::String))
742    }
743
744    /// Parses a raw string into the schema's typed config representation.
745    ///
746    /// # Examples
747    ///
748    /// ```
749    /// use osp_cli::config::{ConfigSchema, ConfigValue};
750    ///
751    /// let schema = ConfigSchema::default();
752    /// assert_eq!(
753    ///     schema.parse_input_value("repl.history.enabled", "true").unwrap(),
754    ///     ConfigValue::Bool(true)
755    /// );
756    /// assert_eq!(
757    ///     schema.parse_input_value("theme.name", "dracula").unwrap(),
758    ///     ConfigValue::String("dracula".to_string())
759    /// );
760    /// ```
761    pub fn parse_input_value(&self, key: &str, raw: &str) -> Result<ConfigValue, ConfigError> {
762        if !self.is_known_key(key) {
763            return Err(ConfigError::UnknownConfigKeys {
764                keys: vec![key.to_string()],
765            });
766        }
767        self.validate_writable_key(key)?;
768
769        let value = match self.expected_type(key) {
770            Some(SchemaValueType::String) | None => ConfigValue::String(raw.to_string()),
771            Some(SchemaValueType::Bool) => {
772                ConfigValue::Bool(
773                    parse_bool(raw).ok_or_else(|| ConfigError::InvalidValueType {
774                        key: key.to_string(),
775                        expected: SchemaValueType::Bool,
776                        actual: "string".to_string(),
777                    })?,
778                )
779            }
780            Some(SchemaValueType::Integer) => {
781                let parsed =
782                    raw.trim()
783                        .parse::<i64>()
784                        .map_err(|_| ConfigError::InvalidValueType {
785                            key: key.to_string(),
786                            expected: SchemaValueType::Integer,
787                            actual: "string".to_string(),
788                        })?;
789                ConfigValue::Integer(parsed)
790            }
791            Some(SchemaValueType::Float) => {
792                let parsed =
793                    raw.trim()
794                        .parse::<f64>()
795                        .map_err(|_| ConfigError::InvalidValueType {
796                            key: key.to_string(),
797                            expected: SchemaValueType::Float,
798                            actual: "string".to_string(),
799                        })?;
800                ConfigValue::Float(parsed)
801            }
802            Some(SchemaValueType::StringList) => {
803                let items = parse_string_list(raw);
804                ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
805            }
806        };
807
808        if let Some(entry) = self.entries.get(key) {
809            validate_allowed_values(
810                key,
811                &value,
812                entry
813                    .allowed_values()
814                    .map(|values| values.iter().map(String::as_str).collect::<Vec<_>>())
815                    .as_deref(),
816            )?;
817        } else if let Some(DynamicSchemaKeyKind::PluginCommandState) = dynamic_schema_key_kind(key)
818        {
819            validate_allowed_values(key, &value, Some(&["enabled", "disabled"]))?;
820        }
821
822        Ok(value)
823    }
824
825    pub(crate) fn validate_and_adapt(
826        &self,
827        values: &mut BTreeMap<String, ResolvedValue>,
828    ) -> Result<(), ConfigError> {
829        let mut unknown = Vec::new();
830        for key in values.keys() {
831            if self.is_runtime_visible_key(key) {
832                continue;
833            }
834            unknown.push(key.clone());
835        }
836        if !unknown.is_empty() {
837            unknown.sort();
838            return Err(ConfigError::UnknownConfigKeys { keys: unknown });
839        }
840
841        for (key, entry) in &self.entries {
842            if entry.runtime_visible && entry.required && !values.contains_key(key) {
843                return Err(ConfigError::MissingRequiredKey { key: key.clone() });
844            }
845        }
846
847        for (key, resolved) in values.iter_mut() {
848            if let Some(kind) = dynamic_schema_key_kind(key) {
849                resolved.value = adapt_dynamic_value_for_schema(key, &resolved.value, kind)?;
850                continue;
851            }
852            let Some(schema_entry) = self.entries.get(key) else {
853                continue;
854            };
855            if !schema_entry.runtime_visible {
856                continue;
857            }
858            resolved.value = adapt_value_for_schema(key, &resolved.value, schema_entry)?;
859        }
860
861        Ok(())
862    }
863
864    fn is_extension_key(&self, key: &str) -> bool {
865        self.allow_extensions_namespace && key.starts_with("extensions.")
866    }
867
868    fn is_alias_key(&self, key: &str) -> bool {
869        key.starts_with("alias.")
870    }
871
872    /// Validates that a key is allowed in the provided scope.
873    pub fn validate_key_scope(&self, key: &str, scope: &Scope) -> Result<(), ConfigError> {
874        let normalized_scope = normalize_scope(scope.clone());
875        if let Some(spec) = self.bootstrap_key_spec(key)
876            && !spec.allows_scope(&normalized_scope)
877        {
878            return Err(ConfigError::InvalidBootstrapScope {
879                key: spec.key.to_string(),
880                profile: normalized_scope.profile,
881                terminal: normalized_scope.terminal,
882            });
883        }
884
885        Ok(())
886    }
887
888    /// Validates bootstrap-only value rules for a key.
889    pub fn validate_bootstrap_value(
890        &self,
891        key: &str,
892        value: &ConfigValue,
893    ) -> Result<(), ConfigError> {
894        let normalized = key.trim().to_ascii_lowercase();
895        let Some(entry) = self.entries.get(&normalized) else {
896            return Ok(());
897        };
898        entry.validate_bootstrap_value(&normalized, value)
899    }
900}
901
902fn builtin_config_schema() -> &'static ConfigSchema {
903    static BUILTIN_SCHEMA: OnceLock<ConfigSchema> = OnceLock::new();
904    BUILTIN_SCHEMA.get_or_init(ConfigSchema::builtin)
905}
906
907impl Display for ConfigValue {
908    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
909        match self {
910            ConfigValue::String(v) => write!(f, "{v}"),
911            ConfigValue::Bool(v) => write!(f, "{v}"),
912            ConfigValue::Integer(v) => write!(f, "{v}"),
913            ConfigValue::Float(v) => write!(f, "{v}"),
914            ConfigValue::List(v) => {
915                let joined = v
916                    .iter()
917                    .map(ToString::to_string)
918                    .collect::<Vec<String>>()
919                    .join(",");
920                write!(f, "[{joined}]")
921            }
922            ConfigValue::Secret(secret) => write!(f, "{secret}"),
923        }
924    }
925}
926
927impl From<&str> for ConfigValue {
928    fn from(value: &str) -> Self {
929        ConfigValue::String(value.to_string())
930    }
931}
932
933impl From<String> for ConfigValue {
934    fn from(value: String) -> Self {
935        ConfigValue::String(value)
936    }
937}
938
939impl From<bool> for ConfigValue {
940    fn from(value: bool) -> Self {
941        ConfigValue::Bool(value)
942    }
943}
944
945impl From<i64> for ConfigValue {
946    fn from(value: i64) -> Self {
947        ConfigValue::Integer(value)
948    }
949}
950
951impl From<f64> for ConfigValue {
952    fn from(value: f64) -> Self {
953        ConfigValue::Float(value)
954    }
955}
956
957impl From<Vec<String>> for ConfigValue {
958    fn from(values: Vec<String>) -> Self {
959        ConfigValue::List(values.into_iter().map(ConfigValue::String).collect())
960    }
961}
962
963/// Scope selector used when storing or resolving config entries.
964#[derive(Debug, Clone, Default, PartialEq, Eq)]
965pub struct Scope {
966    /// Profile selector, normalized to the canonical profile identifier.
967    pub profile: Option<String>,
968    /// Terminal selector, normalized to the canonical terminal identifier.
969    pub terminal: Option<String>,
970}
971
972impl Scope {
973    /// Creates an unscoped selector.
974    ///
975    /// # Examples
976    ///
977    /// ```
978    /// use osp_cli::config::Scope;
979    ///
980    /// assert_eq!(Scope::global(), Scope::default());
981    /// ```
982    pub fn global() -> Self {
983        Self::default()
984    }
985
986    /// Creates a selector scoped to one profile.
987    ///
988    /// # Examples
989    ///
990    /// ```
991    /// use osp_cli::config::Scope;
992    ///
993    /// let scope = Scope::profile("TSD");
994    /// assert_eq!(scope.profile.as_deref(), Some("tsd"));
995    /// assert_eq!(scope.terminal, None);
996    /// ```
997    pub fn profile(profile: &str) -> Self {
998        Self {
999            profile: Some(normalize_identifier(profile)),
1000            terminal: None,
1001        }
1002    }
1003
1004    /// Creates a selector scoped to one terminal kind.
1005    pub fn terminal(terminal: &str) -> Self {
1006        Self {
1007            profile: None,
1008            terminal: Some(normalize_identifier(terminal)),
1009        }
1010    }
1011
1012    /// Creates a selector scoped to both profile and terminal.
1013    pub fn profile_terminal(profile: &str, terminal: &str) -> Self {
1014        Self {
1015            profile: Some(normalize_identifier(profile)),
1016            terminal: Some(normalize_identifier(terminal)),
1017        }
1018    }
1019}
1020
1021/// Single entry stored inside a config layer.
1022#[derive(Debug, Clone, PartialEq)]
1023pub struct LayerEntry {
1024    /// Canonical config key.
1025    pub key: String,
1026    /// Stored value for the key in this layer.
1027    pub value: ConfigValue,
1028    /// Scope attached to the entry.
1029    pub scope: Scope,
1030    /// External origin label such as an environment variable name.
1031    pub origin: Option<String>,
1032}
1033
1034/// Ordered collection of config entries from one source layer.
1035#[derive(Debug, Clone, Default)]
1036pub struct ConfigLayer {
1037    pub(crate) entries: Vec<LayerEntry>,
1038}
1039
1040impl ConfigLayer {
1041    /// Returns the entries in insertion order.
1042    pub fn entries(&self) -> &[LayerEntry] {
1043        &self.entries
1044    }
1045
1046    /// Inserts a global entry.
1047    ///
1048    /// # Examples
1049    ///
1050    /// ```
1051    /// use osp_cli::config::{ConfigLayer, Scope};
1052    ///
1053    /// let mut layer = ConfigLayer::default();
1054    /// layer.set("theme.name", "dracula");
1055    ///
1056    /// let entry = &layer.entries()[0];
1057    /// assert_eq!(entry.key, "theme.name");
1058    /// assert_eq!(entry.scope, Scope::global());
1059    /// ```
1060    pub fn set<K, V>(&mut self, key: K, value: V)
1061    where
1062        K: Into<String>,
1063        V: Into<ConfigValue>,
1064    {
1065        self.insert(key, value, Scope::global());
1066    }
1067
1068    /// Inserts an entry scoped to a profile.
1069    pub fn set_for_profile<K, V>(&mut self, profile: &str, key: K, value: V)
1070    where
1071        K: Into<String>,
1072        V: Into<ConfigValue>,
1073    {
1074        self.insert(key, value, Scope::profile(profile));
1075    }
1076
1077    /// Inserts an entry scoped to a terminal.
1078    pub fn set_for_terminal<K, V>(&mut self, terminal: &str, key: K, value: V)
1079    where
1080        K: Into<String>,
1081        V: Into<ConfigValue>,
1082    {
1083        self.insert(key, value, Scope::terminal(terminal));
1084    }
1085
1086    /// Inserts an entry scoped to both profile and terminal.
1087    pub fn set_for_profile_terminal<K, V>(
1088        &mut self,
1089        profile: &str,
1090        terminal: &str,
1091        key: K,
1092        value: V,
1093    ) where
1094        K: Into<String>,
1095        V: Into<ConfigValue>,
1096    {
1097        self.insert(key, value, Scope::profile_terminal(profile, terminal));
1098    }
1099
1100    /// Inserts an entry with an explicit scope.
1101    pub fn insert<K, V>(&mut self, key: K, value: V, scope: Scope)
1102    where
1103        K: Into<String>,
1104        V: Into<ConfigValue>,
1105    {
1106        self.entries.push(LayerEntry {
1107            key: key.into(),
1108            value: value.into(),
1109            scope: normalize_scope(scope),
1110            origin: None,
1111        });
1112    }
1113
1114    /// Inserts an entry and records its external origin.
1115    pub fn insert_with_origin<K, V, O>(&mut self, key: K, value: V, scope: Scope, origin: Option<O>)
1116    where
1117        K: Into<String>,
1118        V: Into<ConfigValue>,
1119        O: Into<String>,
1120    {
1121        self.entries.push(LayerEntry {
1122            key: key.into(),
1123            value: value.into(),
1124            scope: normalize_scope(scope),
1125            origin: origin.map(Into::into),
1126        });
1127    }
1128
1129    /// Marks every entry in the layer as secret.
1130    pub fn mark_all_secret(&mut self) {
1131        for entry in &mut self.entries {
1132            if !entry.value.is_secret() {
1133                entry.value = entry.value.clone().into_secret();
1134            }
1135        }
1136    }
1137
1138    /// Removes the last matching entry for a key and scope.
1139    ///
1140    /// # Examples
1141    ///
1142    /// ```
1143    /// use osp_cli::config::{ConfigLayer, ConfigValue, Scope};
1144    ///
1145    /// let mut layer = ConfigLayer::default();
1146    /// layer.set("theme.name", "catppuccin");
1147    /// layer.set("theme.name", "dracula");
1148    ///
1149    /// let removed = layer.remove_scoped("theme.name", &Scope::global());
1150    /// assert_eq!(removed, Some(ConfigValue::String("dracula".to_string())));
1151    /// ```
1152    pub fn remove_scoped(&mut self, key: &str, scope: &Scope) -> Option<ConfigValue> {
1153        let normalized_scope = normalize_scope(scope.clone());
1154        let index = self
1155            .entries
1156            .iter()
1157            .rposition(|entry| entry.key == key && entry.scope == normalized_scope)?;
1158        Some(self.entries.remove(index).value)
1159    }
1160
1161    /// Parses a config layer from the project's TOML layout.
1162    ///
1163    /// # Examples
1164    ///
1165    /// ```
1166    /// use osp_cli::config::ConfigLayer;
1167    ///
1168    /// let layer = ConfigLayer::from_toml_str(r#"
1169    /// [default]
1170    /// theme.name = "dracula"
1171    ///
1172    /// [profile.tsd]
1173    /// ui.format = "json"
1174    /// "#).unwrap();
1175    ///
1176    /// assert_eq!(layer.entries().len(), 2);
1177    /// ```
1178    pub fn from_toml_str(raw: &str) -> Result<Self, ConfigError> {
1179        let parsed = raw
1180            .parse::<toml::Value>()
1181            .map_err(|err| ConfigError::TomlParse(err.to_string()))?;
1182
1183        let root = parsed.as_table().ok_or(ConfigError::TomlRootMustBeTable)?;
1184        let mut layer = ConfigLayer::default();
1185
1186        for (section, value) in root {
1187            match section.as_str() {
1188                "default" => {
1189                    let table = value
1190                        .as_table()
1191                        .ok_or_else(|| ConfigError::InvalidSection {
1192                            section: "default".to_string(),
1193                            expected: "table".to_string(),
1194                        })?;
1195                    flatten_table(&mut layer, table, "", &Scope::global())?;
1196                }
1197                "profile" => {
1198                    let profiles = value
1199                        .as_table()
1200                        .ok_or_else(|| ConfigError::InvalidSection {
1201                            section: "profile".to_string(),
1202                            expected: "table".to_string(),
1203                        })?;
1204                    for (profile, profile_table_value) in profiles {
1205                        let profile_table = profile_table_value.as_table().ok_or_else(|| {
1206                            ConfigError::InvalidSection {
1207                                section: format!("profile.{profile}"),
1208                                expected: "table".to_string(),
1209                            }
1210                        })?;
1211                        flatten_table(&mut layer, profile_table, "", &Scope::profile(profile))?;
1212                    }
1213                }
1214                "terminal" => {
1215                    let terminals =
1216                        value
1217                            .as_table()
1218                            .ok_or_else(|| ConfigError::InvalidSection {
1219                                section: "terminal".to_string(),
1220                                expected: "table".to_string(),
1221                            })?;
1222
1223                    for (terminal, terminal_table_value) in terminals {
1224                        let terminal_table = terminal_table_value.as_table().ok_or_else(|| {
1225                            ConfigError::InvalidSection {
1226                                section: format!("terminal.{terminal}"),
1227                                expected: "table".to_string(),
1228                            }
1229                        })?;
1230
1231                        for (key, terminal_value) in terminal_table {
1232                            if key == "profile" {
1233                                continue;
1234                            }
1235
1236                            flatten_key_value(
1237                                &mut layer,
1238                                key,
1239                                terminal_value,
1240                                &Scope::terminal(terminal),
1241                            )?;
1242                        }
1243
1244                        if let Some(profile_section) = terminal_table.get("profile") {
1245                            let profile_tables = profile_section.as_table().ok_or_else(|| {
1246                                ConfigError::InvalidSection {
1247                                    section: format!("terminal.{terminal}.profile"),
1248                                    expected: "table".to_string(),
1249                                }
1250                            })?;
1251
1252                            for (profile_key, profile_value) in profile_tables {
1253                                if let Some(profile_table) = profile_value.as_table() {
1254                                    flatten_table(
1255                                        &mut layer,
1256                                        profile_table,
1257                                        "",
1258                                        &Scope::profile_terminal(profile_key, terminal),
1259                                    )?;
1260                                } else {
1261                                    flatten_key_value(
1262                                        &mut layer,
1263                                        &format!("profile.{profile_key}"),
1264                                        profile_value,
1265                                        &Scope::terminal(terminal),
1266                                    )?;
1267                                }
1268                            }
1269                        }
1270                    }
1271                }
1272                unknown => {
1273                    return Err(ConfigError::UnknownTopLevelSection(unknown.to_string()));
1274                }
1275            }
1276        }
1277
1278        Ok(layer)
1279    }
1280
1281    /// Builds a config layer from `OSP__...` environment variables.
1282    pub fn from_env_iter<I, K, V>(vars: I) -> Result<Self, ConfigError>
1283    where
1284        I: IntoIterator<Item = (K, V)>,
1285        K: AsRef<str>,
1286        V: AsRef<str>,
1287    {
1288        let mut layer = ConfigLayer::default();
1289
1290        for (name, value) in vars {
1291            let key = name.as_ref();
1292            if !key.starts_with("OSP__") {
1293                continue;
1294            }
1295
1296            let spec = parse_env_key(key)?;
1297            builtin_config_schema().validate_writable_key(&spec.key)?;
1298            validate_key_scope(&spec.key, &spec.scope)?;
1299            let converted = ConfigValue::String(value.as_ref().to_string());
1300            validate_bootstrap_value(&spec.key, &converted)?;
1301            layer.insert_with_origin(spec.key, converted, spec.scope, Some(key.to_string()));
1302        }
1303
1304        Ok(layer)
1305    }
1306
1307    pub(crate) fn validate_entries(&self) -> Result<(), ConfigError> {
1308        for entry in &self.entries {
1309            builtin_config_schema().validate_writable_key(&entry.key)?;
1310            validate_key_scope(&entry.key, &entry.scope)?;
1311            validate_bootstrap_value(&entry.key, &entry.value)?;
1312        }
1313
1314        Ok(())
1315    }
1316}
1317
1318pub(crate) struct EnvKeySpec {
1319    pub(crate) key: String,
1320    pub(crate) scope: Scope,
1321}
1322
1323/// Options that affect profile and terminal selection during resolution.
1324#[derive(Debug, Clone, Default)]
1325pub struct ResolveOptions {
1326    /// Explicit profile to use instead of the configured default profile.
1327    pub profile_override: Option<String>,
1328    /// Terminal selector used to include terminal-scoped entries.
1329    pub terminal: Option<String>,
1330}
1331
1332impl ResolveOptions {
1333    /// Creates empty resolution options with no explicit profile or terminal.
1334    ///
1335    /// # Examples
1336    ///
1337    /// ```
1338    /// use osp_cli::config::ResolveOptions;
1339    ///
1340    /// let options = ResolveOptions::new();
1341    /// assert_eq!(options.profile_override, None);
1342    /// assert_eq!(options.terminal, None);
1343    /// ```
1344    pub fn new() -> Self {
1345        Self::default()
1346    }
1347
1348    /// Replaces the optional normalized profile override.
1349    pub fn with_profile_override(mut self, profile_override: Option<String>) -> Self {
1350        self.profile_override = profile_override
1351            .map(|value| normalize_identifier(&value))
1352            .filter(|value| !value.is_empty());
1353        self
1354    }
1355
1356    /// Forces resolution to use the provided profile.
1357    ///
1358    /// # Examples
1359    ///
1360    /// ```
1361    /// use osp_cli::config::ResolveOptions;
1362    ///
1363    /// let options = ResolveOptions::new().with_profile("TSD");
1364    /// assert_eq!(options.profile_override.as_deref(), Some("tsd"));
1365    /// ```
1366    pub fn with_profile(mut self, profile: &str) -> Self {
1367        self.profile_override = Some(normalize_identifier(profile));
1368        self
1369    }
1370
1371    /// Resolves values for the provided terminal selector.
1372    pub fn with_terminal(mut self, terminal: &str) -> Self {
1373        self.terminal = Some(normalize_identifier(terminal));
1374        self
1375    }
1376
1377    /// Replaces the optional normalized terminal selector.
1378    pub fn with_terminal_override(mut self, terminal: Option<String>) -> Self {
1379        self.terminal = terminal
1380            .map(|value| normalize_identifier(&value))
1381            .filter(|value| !value.is_empty());
1382        self
1383    }
1384}
1385
1386/// Fully resolved value together with selection metadata.
1387#[derive(Debug, Clone, PartialEq)]
1388pub struct ResolvedValue {
1389    /// Value before schema adaptation or interpolation.
1390    pub raw_value: ConfigValue,
1391    /// Final runtime value after adaptation and interpolation.
1392    pub value: ConfigValue,
1393    /// Source layer that contributed the selected value.
1394    pub source: ConfigSource,
1395    /// Scope of the selected entry.
1396    pub scope: Scope,
1397    /// External origin label for the selected entry, if tracked.
1398    pub origin: Option<String>,
1399}
1400
1401/// Candidate entry considered while explaining a key.
1402#[derive(Debug, Clone, PartialEq)]
1403pub struct ExplainCandidate {
1404    /// Zero-based index of the entry within its layer.
1405    pub entry_index: usize,
1406    /// Candidate value before final selection.
1407    pub value: ConfigValue,
1408    /// Scope attached to the candidate entry.
1409    pub scope: Scope,
1410    /// External origin label for the candidate entry, if tracked.
1411    pub origin: Option<String>,
1412    /// Selection rank used by resolution, if one was assigned.
1413    pub rank: Option<u8>,
1414    /// Whether this candidate won selection within its layer.
1415    pub selected_in_layer: bool,
1416}
1417
1418/// Per-layer explanation for a resolved or bootstrap key.
1419#[derive(Debug, Clone, PartialEq)]
1420pub struct ExplainLayer {
1421    /// Source represented by this explanation layer.
1422    pub source: ConfigSource,
1423    /// Index of the selected candidate within `candidates`, if any.
1424    pub selected_entry_index: Option<usize>,
1425    /// Candidate entries contributed by the layer.
1426    pub candidates: Vec<ExplainCandidate>,
1427}
1428
1429/// Single placeholder expansion step captured by `config explain`.
1430#[derive(Debug, Clone, PartialEq)]
1431pub struct ExplainInterpolationStep {
1432    /// Placeholder name referenced by the template.
1433    pub placeholder: String,
1434    /// Placeholder value before schema adaptation or interpolation.
1435    pub raw_value: ConfigValue,
1436    /// Placeholder value after schema adaptation and interpolation.
1437    pub value: ConfigValue,
1438    /// Source layer that provided the placeholder value.
1439    pub source: ConfigSource,
1440    /// Scope of the entry that supplied the placeholder.
1441    pub scope: Scope,
1442    /// External origin label for the placeholder entry, if tracked.
1443    pub origin: Option<String>,
1444}
1445
1446/// Interpolation trace for a resolved string value.
1447#[derive(Debug, Clone, PartialEq)]
1448pub struct ExplainInterpolation {
1449    /// Original string template before placeholder substitution.
1450    pub template: String,
1451    /// Placeholder expansion steps applied to the template.
1452    pub steps: Vec<ExplainInterpolationStep>,
1453}
1454
1455/// Source used to determine the active profile.
1456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1457pub enum ActiveProfileSource {
1458    /// The active profile came from an explicit override.
1459    Override,
1460    /// The active profile came from `profile.default`.
1461    DefaultProfile,
1462}
1463
1464impl ActiveProfileSource {
1465    /// Returns the stable string label used in explain output.
1466    pub fn as_str(self) -> &'static str {
1467        match self {
1468            Self::Override => "override",
1469            Self::DefaultProfile => "profile.default",
1470        }
1471    }
1472}
1473
1474/// Human-readable explanation of runtime resolution for a single key.
1475#[derive(Debug, Clone, PartialEq)]
1476pub struct ConfigExplain {
1477    /// Canonical key being explained.
1478    pub key: String,
1479    /// Profile used during resolution.
1480    pub active_profile: String,
1481    /// Source used to determine `active_profile`.
1482    pub active_profile_source: ActiveProfileSource,
1483    /// Terminal selector used during resolution, if any.
1484    pub terminal: Option<String>,
1485    /// Profiles discovered across the evaluated layers.
1486    pub known_profiles: BTreeSet<String>,
1487    /// Per-layer candidate and selection details.
1488    pub layers: Vec<ExplainLayer>,
1489    /// Final resolved entry, if the key resolved successfully.
1490    pub final_entry: Option<ResolvedValue>,
1491    /// Interpolation trace for string results, if interpolation occurred.
1492    pub interpolation: Option<ExplainInterpolation>,
1493}
1494
1495/// Human-readable explanation of bootstrap resolution for a single key.
1496#[derive(Debug, Clone, PartialEq)]
1497pub struct BootstrapConfigExplain {
1498    /// Canonical key being explained.
1499    pub key: String,
1500    /// Profile used during bootstrap resolution.
1501    pub active_profile: String,
1502    /// Source used to determine `active_profile`.
1503    pub active_profile_source: ActiveProfileSource,
1504    /// Terminal selector used during bootstrap resolution, if any.
1505    pub terminal: Option<String>,
1506    /// Profiles discovered across the evaluated layers.
1507    pub known_profiles: BTreeSet<String>,
1508    /// Per-layer candidate and selection details.
1509    pub layers: Vec<ExplainLayer>,
1510    /// Final bootstrap-resolved entry, if one was selected.
1511    pub final_entry: Option<ResolvedValue>,
1512}
1513
1514/// Final resolved configuration view used at runtime.
1515#[derive(Debug, Clone, PartialEq)]
1516pub struct ResolvedConfig {
1517    pub(crate) active_profile: String,
1518    pub(crate) terminal: Option<String>,
1519    pub(crate) known_profiles: BTreeSet<String>,
1520    pub(crate) values: BTreeMap<String, ResolvedValue>,
1521    pub(crate) aliases: BTreeMap<String, ResolvedValue>,
1522}
1523
1524impl ResolvedConfig {
1525    /// Returns the profile selected for resolution.
1526    pub fn active_profile(&self) -> &str {
1527        &self.active_profile
1528    }
1529
1530    /// Returns the terminal selector used during resolution, if any.
1531    pub fn terminal(&self) -> Option<&str> {
1532        self.terminal.as_deref()
1533    }
1534
1535    /// Returns the set of profiles discovered across config layers.
1536    pub fn known_profiles(&self) -> &BTreeSet<String> {
1537        &self.known_profiles
1538    }
1539
1540    /// Returns all resolved runtime-visible values.
1541    pub fn values(&self) -> &BTreeMap<String, ResolvedValue> {
1542        &self.values
1543    }
1544
1545    /// Returns resolved alias entries excluded from normal runtime values.
1546    pub fn aliases(&self) -> &BTreeMap<String, ResolvedValue> {
1547        &self.aliases
1548    }
1549
1550    /// Returns the resolved value for a key.
1551    pub fn get(&self, key: &str) -> Option<&ConfigValue> {
1552        self.values.get(key).map(|entry| &entry.value)
1553    }
1554
1555    /// Returns the resolved string value for a key.
1556    ///
1557    /// # Examples
1558    ///
1559    /// ```
1560    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
1561    ///
1562    /// let mut defaults = ConfigLayer::default();
1563    /// defaults.set("profile.default", "default");
1564    /// defaults.set("theme.name", "dracula");
1565    ///
1566    /// let mut resolver = ConfigResolver::default();
1567    /// resolver.set_defaults(defaults);
1568    /// let resolved = resolver.resolve(ResolveOptions::default()).unwrap();
1569    ///
1570    /// assert_eq!(resolved.get_string("theme.name"), Some("dracula"));
1571    /// ```
1572    pub fn get_string(&self, key: &str) -> Option<&str> {
1573        match self.get(key).map(ConfigValue::reveal) {
1574            Some(ConfigValue::String(value)) => Some(value),
1575            _ => None,
1576        }
1577    }
1578
1579    /// Returns the resolved boolean value for a key.
1580    ///
1581    /// # Examples
1582    ///
1583    /// ```
1584    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
1585    ///
1586    /// let mut defaults = ConfigLayer::default();
1587    /// defaults.set("profile.default", "default");
1588    /// defaults.set("repl.history.enabled", true);
1589    ///
1590    /// let mut resolver = ConfigResolver::default();
1591    /// resolver.set_defaults(defaults);
1592    /// let resolved = resolver.resolve(ResolveOptions::default()).unwrap();
1593    ///
1594    /// assert_eq!(resolved.get_bool("repl.history.enabled"), Some(true));
1595    /// ```
1596    pub fn get_bool(&self, key: &str) -> Option<bool> {
1597        match self.get(key).map(ConfigValue::reveal) {
1598            Some(ConfigValue::Bool(value)) => Some(*value),
1599            _ => None,
1600        }
1601    }
1602
1603    /// Returns the resolved string list for a key.
1604    ///
1605    /// # Examples
1606    ///
1607    /// ```
1608    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
1609    ///
1610    /// let mut defaults = ConfigLayer::default();
1611    /// defaults.set("profile.default", "default");
1612    /// defaults.set("theme.path", vec!["/tmp/themes".to_string()]);
1613    ///
1614    /// let mut resolver = ConfigResolver::default();
1615    /// resolver.set_defaults(defaults);
1616    /// let resolved = resolver.resolve(ResolveOptions::default()).unwrap();
1617    ///
1618    /// assert_eq!(
1619    ///     resolved.get_string_list("theme.path"),
1620    ///     Some(vec!["/tmp/themes".to_string()])
1621    /// );
1622    /// ```
1623    pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
1624        match self.get(key).map(ConfigValue::reveal) {
1625            Some(ConfigValue::List(values)) => Some(
1626                values
1627                    .iter()
1628                    .filter_map(|value| match value {
1629                        ConfigValue::String(text) => Some(text.clone()),
1630                        ConfigValue::Secret(secret) => match secret.expose() {
1631                            ConfigValue::String(text) => Some(text.clone()),
1632                            _ => None,
1633                        },
1634                        _ => None,
1635                    })
1636                    .collect(),
1637            ),
1638            Some(ConfigValue::String(value)) => Some(vec![value.clone()]),
1639            Some(ConfigValue::Secret(secret)) => match secret.expose() {
1640                ConfigValue::String(value) => Some(vec![value.clone()]),
1641                _ => None,
1642            },
1643            _ => None,
1644        }
1645    }
1646
1647    /// Returns the full resolved entry for a runtime-visible key.
1648    pub fn get_value_entry(&self, key: &str) -> Option<&ResolvedValue> {
1649        self.values.get(key)
1650    }
1651
1652    /// Returns the resolved alias entry for a key.
1653    pub fn get_alias_entry(&self, key: &str) -> Option<&ResolvedValue> {
1654        let normalized = if key.trim().to_ascii_lowercase().starts_with("alias.") {
1655            key.trim().to_ascii_lowercase()
1656        } else {
1657            format!("alias.{}", key.trim().to_ascii_lowercase())
1658        };
1659        self.aliases.get(&normalized)
1660    }
1661}
1662
1663fn flatten_table(
1664    layer: &mut ConfigLayer,
1665    table: &toml::value::Table,
1666    prefix: &str,
1667    scope: &Scope,
1668) -> Result<(), ConfigError> {
1669    for (key, value) in table {
1670        let full_key = if prefix.is_empty() {
1671            key.to_string()
1672        } else {
1673            format!("{prefix}.{key}")
1674        };
1675
1676        flatten_key_value(layer, &full_key, value, scope)?;
1677    }
1678
1679    Ok(())
1680}
1681
1682fn flatten_key_value(
1683    layer: &mut ConfigLayer,
1684    key: &str,
1685    value: &toml::Value,
1686    scope: &Scope,
1687) -> Result<(), ConfigError> {
1688    match value {
1689        toml::Value::Table(table) => flatten_table(layer, table, key, scope),
1690        _ => {
1691            let converted = ConfigValue::from_toml(key, value)?;
1692            builtin_config_schema().validate_writable_key(key)?;
1693            validate_key_scope(key, scope)?;
1694            validate_bootstrap_value(key, &converted)?;
1695            layer.insert(key.to_string(), converted, scope.clone());
1696            Ok(())
1697        }
1698    }
1699}
1700
1701/// Looks up bootstrap-time metadata for a canonical config key.
1702pub fn bootstrap_key_spec(key: &str) -> Option<BootstrapKeySpec> {
1703    builtin_config_schema().bootstrap_key_spec(key)
1704}
1705
1706/// Reports whether `key` is consumed during bootstrap but not exposed as a
1707/// normal runtime-resolved config key.
1708pub fn is_bootstrap_only_key(key: &str) -> bool {
1709    bootstrap_key_spec(key).is_some_and(|spec| !spec.runtime_visible)
1710}
1711
1712/// Reports whether `key` belongs to the `alias.*` namespace.
1713///
1714/// # Examples
1715///
1716/// ```
1717/// use osp_cli::config::is_alias_key;
1718///
1719/// assert!(is_alias_key("alias.prod"));
1720/// assert!(is_alias_key(" Alias.User "));
1721/// assert!(!is_alias_key("ui.format"));
1722/// ```
1723pub fn is_alias_key(key: &str) -> bool {
1724    key.trim().to_ascii_lowercase().starts_with("alias.")
1725}
1726
1727/// Validates that a key can be written in the provided scope.
1728pub fn validate_key_scope(key: &str, scope: &Scope) -> Result<(), ConfigError> {
1729    builtin_config_schema().validate_key_scope(key, scope)
1730}
1731
1732/// Validates bootstrap-only value constraints for a key.
1733pub fn validate_bootstrap_value(key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
1734    builtin_config_schema().validate_bootstrap_value(key, value)
1735}
1736
1737fn adapt_value_for_schema(
1738    key: &str,
1739    value: &ConfigValue,
1740    schema: &SchemaEntry,
1741) -> Result<ConfigValue, ConfigError> {
1742    let (is_secret, value) = match value {
1743        ConfigValue::Secret(secret) => (true, secret.expose()),
1744        other => (false, other),
1745    };
1746
1747    let adapted = match schema.value_type {
1748        SchemaValueType::String => match value {
1749            ConfigValue::String(value) => ConfigValue::String(value.clone()),
1750            other => {
1751                return Err(ConfigError::InvalidValueType {
1752                    key: key.to_string(),
1753                    expected: SchemaValueType::String,
1754                    actual: value_type_name(other).to_string(),
1755                });
1756            }
1757        },
1758        SchemaValueType::Bool => match value {
1759            ConfigValue::Bool(value) => ConfigValue::Bool(*value),
1760            ConfigValue::String(value) => {
1761                ConfigValue::Bool(parse_bool(value).ok_or_else(|| {
1762                    ConfigError::InvalidValueType {
1763                        key: key.to_string(),
1764                        expected: SchemaValueType::Bool,
1765                        actual: "string".to_string(),
1766                    }
1767                })?)
1768            }
1769            other => {
1770                return Err(ConfigError::InvalidValueType {
1771                    key: key.to_string(),
1772                    expected: SchemaValueType::Bool,
1773                    actual: value_type_name(other).to_string(),
1774                });
1775            }
1776        },
1777        SchemaValueType::Integer => match value {
1778            ConfigValue::Integer(value) => ConfigValue::Integer(*value),
1779            ConfigValue::String(value) => {
1780                let parsed =
1781                    value
1782                        .trim()
1783                        .parse::<i64>()
1784                        .map_err(|_| ConfigError::InvalidValueType {
1785                            key: key.to_string(),
1786                            expected: SchemaValueType::Integer,
1787                            actual: "string".to_string(),
1788                        })?;
1789                ConfigValue::Integer(parsed)
1790            }
1791            other => {
1792                return Err(ConfigError::InvalidValueType {
1793                    key: key.to_string(),
1794                    expected: SchemaValueType::Integer,
1795                    actual: value_type_name(other).to_string(),
1796                });
1797            }
1798        },
1799        SchemaValueType::Float => match value {
1800            ConfigValue::Float(value) => ConfigValue::Float(*value),
1801            ConfigValue::Integer(value) => ConfigValue::Float(*value as f64),
1802            ConfigValue::String(value) => {
1803                let parsed =
1804                    value
1805                        .trim()
1806                        .parse::<f64>()
1807                        .map_err(|_| ConfigError::InvalidValueType {
1808                            key: key.to_string(),
1809                            expected: SchemaValueType::Float,
1810                            actual: "string".to_string(),
1811                        })?;
1812                ConfigValue::Float(parsed)
1813            }
1814            other => {
1815                return Err(ConfigError::InvalidValueType {
1816                    key: key.to_string(),
1817                    expected: SchemaValueType::Float,
1818                    actual: value_type_name(other).to_string(),
1819                });
1820            }
1821        },
1822        SchemaValueType::StringList => match value {
1823            ConfigValue::List(values) => {
1824                let mut out = Vec::with_capacity(values.len());
1825                for value in values {
1826                    match value {
1827                        ConfigValue::String(value) => out.push(ConfigValue::String(value.clone())),
1828                        ConfigValue::Secret(secret) => match secret.expose() {
1829                            ConfigValue::String(value) => {
1830                                out.push(ConfigValue::String(value.clone()))
1831                            }
1832                            other => {
1833                                return Err(ConfigError::InvalidValueType {
1834                                    key: key.to_string(),
1835                                    expected: SchemaValueType::StringList,
1836                                    actual: value_type_name(other).to_string(),
1837                                });
1838                            }
1839                        },
1840                        other => {
1841                            return Err(ConfigError::InvalidValueType {
1842                                key: key.to_string(),
1843                                expected: SchemaValueType::StringList,
1844                                actual: value_type_name(other).to_string(),
1845                            });
1846                        }
1847                    }
1848                }
1849                ConfigValue::List(out)
1850            }
1851            ConfigValue::String(value) => {
1852                let items = parse_string_list(value);
1853                ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
1854            }
1855            ConfigValue::Secret(secret) => match secret.expose() {
1856                ConfigValue::String(value) => {
1857                    let items = parse_string_list(value);
1858                    ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
1859                }
1860                other => {
1861                    return Err(ConfigError::InvalidValueType {
1862                        key: key.to_string(),
1863                        expected: SchemaValueType::StringList,
1864                        actual: value_type_name(other).to_string(),
1865                    });
1866                }
1867            },
1868            other => {
1869                return Err(ConfigError::InvalidValueType {
1870                    key: key.to_string(),
1871                    expected: SchemaValueType::StringList,
1872                    actual: value_type_name(other).to_string(),
1873                });
1874            }
1875        },
1876    };
1877
1878    let adapted = if is_secret {
1879        adapted.into_secret()
1880    } else {
1881        adapted
1882    };
1883
1884    if let Some(allowed_values) = &schema.allowed_values
1885        && let ConfigValue::String(value) = adapted.reveal()
1886    {
1887        let normalized = value.to_ascii_lowercase();
1888        if !allowed_values.contains(&normalized) {
1889            return Err(ConfigError::InvalidEnumValue {
1890                key: key.to_string(),
1891                value: value.clone(),
1892                allowed: allowed_values.clone(),
1893            });
1894        }
1895    }
1896
1897    Ok(adapted)
1898}
1899
1900fn adapt_dynamic_value_for_schema(
1901    key: &str,
1902    value: &ConfigValue,
1903    kind: DynamicSchemaKeyKind,
1904) -> Result<ConfigValue, ConfigError> {
1905    let adapted = match kind {
1906        DynamicSchemaKeyKind::PluginCommandState | DynamicSchemaKeyKind::PluginCommandProvider => {
1907            adapt_value_for_schema(key, value, &SchemaEntry::string())?
1908        }
1909    };
1910
1911    if matches!(kind, DynamicSchemaKeyKind::PluginCommandState) {
1912        validate_allowed_values(key, &adapted, Some(&["enabled", "disabled"]))?;
1913    }
1914
1915    Ok(adapted)
1916}
1917
1918fn validate_allowed_values(
1919    key: &str,
1920    value: &ConfigValue,
1921    allowed: Option<&[&str]>,
1922) -> Result<(), ConfigError> {
1923    let Some(allowed) = allowed else {
1924        return Ok(());
1925    };
1926    if let ConfigValue::String(current) = value {
1927        let normalized = current.to_ascii_lowercase();
1928        if !allowed.iter().any(|candidate| *candidate == normalized) {
1929            return Err(ConfigError::InvalidEnumValue {
1930                key: key.to_string(),
1931                value: current.clone(),
1932                allowed: allowed.iter().map(|value| (*value).to_string()).collect(),
1933            });
1934        }
1935    }
1936    Ok(())
1937}
1938
1939fn dynamic_schema_key_kind(key: &str) -> Option<DynamicSchemaKeyKind> {
1940    let normalized = key.trim().to_ascii_lowercase();
1941    let remainder = normalized.strip_prefix("plugins.")?;
1942    let (command, field) = remainder.rsplit_once('.')?;
1943    if command.trim().is_empty() {
1944        return None;
1945    }
1946    match field {
1947        "state" => Some(DynamicSchemaKeyKind::PluginCommandState),
1948        "provider" => Some(DynamicSchemaKeyKind::PluginCommandProvider),
1949        _ => None,
1950    }
1951}
1952
1953fn parse_bool(value: &str) -> Option<bool> {
1954    match value.trim().to_ascii_lowercase().as_str() {
1955        "true" => Some(true),
1956        "false" => Some(false),
1957        _ => None,
1958    }
1959}
1960
1961fn parse_string_list(value: &str) -> Vec<String> {
1962    let trimmed = value.trim();
1963    if trimmed.is_empty() {
1964        return Vec::new();
1965    }
1966
1967    let inner = trimmed
1968        .strip_prefix('[')
1969        .and_then(|value| value.strip_suffix(']'))
1970        .unwrap_or(trimmed);
1971
1972    inner
1973        .split(',')
1974        .map(|value| value.trim())
1975        .filter(|value| !value.is_empty())
1976        .map(|value| {
1977            value
1978                .strip_prefix('"')
1979                .and_then(|value| value.strip_suffix('"'))
1980                .or_else(|| {
1981                    value
1982                        .strip_prefix('\'')
1983                        .and_then(|value| value.strip_suffix('\''))
1984                })
1985                .unwrap_or(value)
1986                .to_string()
1987        })
1988        .collect()
1989}
1990
1991fn value_type_name(value: &ConfigValue) -> &'static str {
1992    match value.reveal() {
1993        ConfigValue::String(_) => "string",
1994        ConfigValue::Bool(_) => "bool",
1995        ConfigValue::Integer(_) => "integer",
1996        ConfigValue::Float(_) => "float",
1997        ConfigValue::List(_) => "list",
1998        ConfigValue::Secret(_) => "string",
1999    }
2000}
2001
2002pub(crate) fn parse_env_key(key: &str) -> Result<EnvKeySpec, ConfigError> {
2003    let Some(raw) = key.strip_prefix("OSP__") else {
2004        return Err(ConfigError::InvalidEnvOverride {
2005            key: key.to_string(),
2006            reason: "missing OSP__ prefix".to_string(),
2007        });
2008    };
2009
2010    let parts = raw
2011        .split("__")
2012        .filter(|part| !part.is_empty())
2013        .collect::<Vec<&str>>();
2014
2015    if parts.is_empty() {
2016        return Err(ConfigError::InvalidEnvOverride {
2017            key: key.to_string(),
2018            reason: "missing key path".to_string(),
2019        });
2020    }
2021
2022    let mut cursor = 0usize;
2023    let mut terminal: Option<String> = None;
2024    let mut profile: Option<String> = None;
2025
2026    while cursor < parts.len() {
2027        let part = parts[cursor];
2028        if part.eq_ignore_ascii_case("TERM") {
2029            if terminal.is_some() {
2030                return Err(ConfigError::InvalidEnvOverride {
2031                    key: key.to_string(),
2032                    reason: "TERM scope specified more than once".to_string(),
2033                });
2034            }
2035            let term = parts
2036                .get(cursor + 1)
2037                .ok_or_else(|| ConfigError::InvalidEnvOverride {
2038                    key: key.to_string(),
2039                    reason: "TERM requires a terminal name".to_string(),
2040                })?;
2041            terminal = Some(normalize_identifier(term));
2042            cursor += 2;
2043            continue;
2044        }
2045
2046        if part.eq_ignore_ascii_case("PROFILE") {
2047            // `profile.default` is a bootstrap key, not a profile scope. Keep
2048            // the exception isolated here so the scope parser stays readable.
2049            if remaining_parts_are_bootstrap_profile_default(&parts[cursor..]) {
2050                break;
2051            }
2052            if profile.is_some() {
2053                return Err(ConfigError::InvalidEnvOverride {
2054                    key: key.to_string(),
2055                    reason: "PROFILE scope specified more than once".to_string(),
2056                });
2057            }
2058            let profile_name =
2059                parts
2060                    .get(cursor + 1)
2061                    .ok_or_else(|| ConfigError::InvalidEnvOverride {
2062                        key: key.to_string(),
2063                        reason: "PROFILE requires a profile name".to_string(),
2064                    })?;
2065            profile = Some(normalize_identifier(profile_name));
2066            cursor += 2;
2067            continue;
2068        }
2069
2070        break;
2071    }
2072
2073    let key_parts = &parts[cursor..];
2074    if key_parts.is_empty() {
2075        return Err(ConfigError::InvalidEnvOverride {
2076            key: key.to_string(),
2077            reason: "missing final config key".to_string(),
2078        });
2079    }
2080
2081    let dotted_key = key_parts
2082        .iter()
2083        .map(|part| part.to_ascii_lowercase())
2084        .collect::<Vec<String>>()
2085        .join(".");
2086
2087    Ok(EnvKeySpec {
2088        key: dotted_key,
2089        scope: Scope { profile, terminal },
2090    })
2091}
2092
2093fn remaining_parts_are_bootstrap_profile_default(parts: &[&str]) -> bool {
2094    matches!(parts, [profile, default]
2095        if profile.eq_ignore_ascii_case("PROFILE")
2096            && default.eq_ignore_ascii_case("DEFAULT"))
2097}
2098
2099pub(crate) fn normalize_scope(scope: Scope) -> Scope {
2100    Scope {
2101        profile: scope
2102            .profile
2103            .as_deref()
2104            .map(normalize_identifier)
2105            .filter(|value| !value.is_empty()),
2106        terminal: scope
2107            .terminal
2108            .as_deref()
2109            .map(normalize_identifier)
2110            .filter(|value| !value.is_empty()),
2111    }
2112}
2113
2114pub(crate) fn normalize_identifier(value: &str) -> String {
2115    value.trim().to_ascii_lowercase()
2116}
2117
2118#[cfg(test)]
2119mod tests;