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