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