use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{Display, Formatter};
use std::sync::OnceLock;
use crate::config::ConfigError;
pub(crate) use crate::normalize::{normalize_identifier, normalize_optional_identifier};
#[derive(Debug, Clone, PartialEq)]
pub struct TomlEditResult {
pub previous: Option<ConfigValue>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ConfigSource {
BuiltinDefaults,
PresentationDefaults,
ConfigFile,
Secrets,
Environment,
Cli,
Session,
Derived,
}
impl Display for ConfigSource {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let value = match self {
ConfigSource::BuiltinDefaults => "defaults",
ConfigSource::PresentationDefaults => "presentation",
ConfigSource::ConfigFile => "file",
ConfigSource::Secrets => "secrets",
ConfigSource::Environment => "env",
ConfigSource::Cli => "cli",
ConfigSource::Session => "session",
ConfigSource::Derived => "derived",
};
write!(f, "{value}")
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigValue {
String(String),
Bool(bool),
Integer(i64),
Float(f64),
List(Vec<ConfigValue>),
Secret(SecretValue),
}
impl ConfigValue {
pub fn is_secret(&self) -> bool {
matches!(self, ConfigValue::Secret(_))
}
pub fn reveal(&self) -> &ConfigValue {
match self {
ConfigValue::Secret(secret) => secret.expose(),
other => other,
}
}
pub fn into_secret(self) -> ConfigValue {
match self {
ConfigValue::Secret(_) => self,
other => ConfigValue::Secret(SecretValue::new(other)),
}
}
pub(crate) fn from_toml(path: &str, value: &toml::Value) -> Result<Self, ConfigError> {
match value {
toml::Value::String(v) => Ok(Self::String(v.clone())),
toml::Value::Integer(v) => Ok(Self::Integer(*v)),
toml::Value::Float(v) => Ok(Self::Float(*v)),
toml::Value::Boolean(v) => Ok(Self::Bool(*v)),
toml::Value::Datetime(v) => Ok(Self::String(v.to_string())),
toml::Value::Array(values) => {
let mut out = Vec::with_capacity(values.len());
for item in values {
out.push(Self::from_toml(path, item)?);
}
Ok(Self::List(out))
}
toml::Value::Table(_) => Err(ConfigError::UnsupportedTomlValue {
path: path.to_string(),
kind: "table".to_string(),
}),
}
}
pub(crate) fn as_interpolation_string(
&self,
key: &str,
placeholder: &str,
) -> Result<String, ConfigError> {
match self.reveal() {
ConfigValue::String(value) => Ok(value.clone()),
ConfigValue::Bool(value) => Ok(value.to_string()),
ConfigValue::Integer(value) => Ok(value.to_string()),
ConfigValue::Float(value) => Ok(value.to_string()),
ConfigValue::List(_) => Err(ConfigError::NonScalarPlaceholder {
key: key.to_string(),
placeholder: placeholder.to_string(),
}),
ConfigValue::Secret(_) => Err(ConfigError::NonScalarPlaceholder {
key: key.to_string(),
placeholder: placeholder.to_string(),
}),
}
}
}
#[derive(Clone, PartialEq)]
pub struct SecretValue(Box<ConfigValue>);
impl SecretValue {
pub fn new(value: ConfigValue) -> Self {
Self(Box::new(value))
}
pub fn expose(&self) -> &ConfigValue {
&self.0
}
pub fn into_inner(self) -> ConfigValue {
*self.0
}
}
impl std::fmt::Debug for SecretValue {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "[REDACTED]")
}
}
impl Display for SecretValue {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "[REDACTED]")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaValueType {
String,
Bool,
Integer,
Float,
StringList,
}
impl Display for SchemaValueType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let value = match self {
SchemaValueType::String => "string",
SchemaValueType::Bool => "bool",
SchemaValueType::Integer => "integer",
SchemaValueType::Float => "float",
SchemaValueType::StringList => "list",
};
write!(f, "{value}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootstrapPhase {
Path,
Profile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootstrapScopeRule {
GlobalOnly,
GlobalOrTerminal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootstrapValueRule {
NonEmptyString,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootstrapKeySpec {
pub key: &'static str,
pub phase: BootstrapPhase,
pub runtime_visible: bool,
pub scope_rule: BootstrapScopeRule,
}
impl BootstrapKeySpec {
fn allows_scope(&self, scope: &Scope) -> bool {
match self.scope_rule {
BootstrapScopeRule::GlobalOnly => scope.profile.is_none() && scope.terminal.is_none(),
BootstrapScopeRule::GlobalOrTerminal => scope.profile.is_none(),
}
}
}
#[derive(Debug, Clone)]
#[must_use]
pub struct SchemaEntry {
canonical_key: Option<&'static str>,
doc: Option<&'static str>,
value_type: SchemaValueType,
required: bool,
writable: bool,
allowed_values: Option<Vec<String>>,
runtime_visible: bool,
bootstrap_phase: Option<BootstrapPhase>,
bootstrap_scope_rule: Option<BootstrapScopeRule>,
bootstrap_value_rule: Option<BootstrapValueRule>,
}
impl SchemaEntry {
pub fn string() -> Self {
Self {
canonical_key: None,
doc: None,
value_type: SchemaValueType::String,
required: false,
writable: true,
allowed_values: None,
runtime_visible: true,
bootstrap_phase: None,
bootstrap_scope_rule: None,
bootstrap_value_rule: None,
}
}
pub fn boolean() -> Self {
Self {
canonical_key: None,
doc: None,
value_type: SchemaValueType::Bool,
required: false,
writable: true,
allowed_values: None,
runtime_visible: true,
bootstrap_phase: None,
bootstrap_scope_rule: None,
bootstrap_value_rule: None,
}
}
pub fn integer() -> Self {
Self {
canonical_key: None,
doc: None,
value_type: SchemaValueType::Integer,
required: false,
writable: true,
allowed_values: None,
runtime_visible: true,
bootstrap_phase: None,
bootstrap_scope_rule: None,
bootstrap_value_rule: None,
}
}
pub fn float() -> Self {
Self {
canonical_key: None,
doc: None,
value_type: SchemaValueType::Float,
required: false,
writable: true,
allowed_values: None,
runtime_visible: true,
bootstrap_phase: None,
bootstrap_scope_rule: None,
bootstrap_value_rule: None,
}
}
pub fn string_list() -> Self {
Self {
canonical_key: None,
doc: None,
value_type: SchemaValueType::StringList,
required: false,
writable: true,
allowed_values: None,
runtime_visible: true,
bootstrap_phase: None,
bootstrap_scope_rule: None,
bootstrap_value_rule: None,
}
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn read_only(mut self) -> Self {
self.writable = false;
self
}
pub fn with_doc(mut self, doc: &'static str) -> Self {
self.doc = Some(doc);
self
}
pub fn bootstrap_only(mut self, phase: BootstrapPhase, scope_rule: BootstrapScopeRule) -> Self {
self.runtime_visible = false;
self.bootstrap_phase = Some(phase);
self.bootstrap_scope_rule = Some(scope_rule);
self
}
pub fn with_bootstrap_value_rule(mut self, rule: BootstrapValueRule) -> Self {
self.bootstrap_value_rule = Some(rule);
self
}
pub fn with_allowed_values<I, S>(mut self, values: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.allowed_values = Some(
values
.into_iter()
.map(|value| value.as_ref().to_ascii_lowercase())
.collect(),
);
self
}
pub fn value_type(&self) -> SchemaValueType {
self.value_type
}
pub fn allowed_values(&self) -> Option<&[String]> {
self.allowed_values.as_deref()
}
pub fn doc(&self) -> Option<&'static str> {
self.doc
}
pub fn runtime_visible(&self) -> bool {
self.runtime_visible
}
pub fn writable(&self) -> bool {
self.writable
}
fn with_canonical_key(mut self, key: &'static str) -> Self {
self.canonical_key = Some(key);
self
}
fn bootstrap_spec(&self) -> Option<BootstrapKeySpec> {
Some(BootstrapKeySpec {
key: self.canonical_key?,
phase: self.bootstrap_phase?,
runtime_visible: self.runtime_visible,
scope_rule: self.bootstrap_scope_rule?,
})
}
fn validate_bootstrap_value(&self, key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
match self.bootstrap_value_rule {
Some(BootstrapValueRule::NonEmptyString) => match value.reveal() {
ConfigValue::String(current) if !current.trim().is_empty() => Ok(()),
ConfigValue::String(current) => Err(ConfigError::InvalidBootstrapValue {
key: key.to_string(),
reason: format!("expected a non-empty string, got {current:?}"),
}),
other => Err(ConfigError::InvalidBootstrapValue {
key: key.to_string(),
reason: format!("expected string, got {other:?}"),
}),
},
None => Ok(()),
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigSchema {
entries: BTreeMap<String, SchemaEntry>,
allow_extensions_namespace: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DynamicSchemaKeyKind {
PluginCommandState,
PluginCommandProvider,
}
impl Default for ConfigSchema {
fn default() -> Self {
builtin_config_schema().clone()
}
}
impl ConfigSchema {
fn builtin() -> Self {
let mut schema = Self {
entries: BTreeMap::new(),
allow_extensions_namespace: true,
};
insert_identity_schema_keys(&mut schema);
insert_ui_schema_keys(&mut schema);
insert_repl_schema_keys(&mut schema);
insert_color_schema_keys(&mut schema);
insert_misc_schema_keys(&mut schema);
schema
}
}
fn insert_builtin_schema_key(
schema: &mut ConfigSchema,
key: &'static str,
entry: SchemaEntry,
doc: &'static str,
) {
schema.insert(key, entry.with_doc(doc));
}
fn insert_identity_schema_keys(schema: &mut ConfigSchema) {
insert_builtin_schema_key(
schema,
"profile.default",
SchemaEntry::string()
.bootstrap_only(
BootstrapPhase::Profile,
BootstrapScopeRule::GlobalOrTerminal,
)
.with_bootstrap_value_rule(BootstrapValueRule::NonEmptyString),
"Default profile selected when no override is provided",
);
insert_builtin_schema_key(
schema,
"profile.active",
SchemaEntry::string().required().read_only(),
"Active profile derived during resolution",
);
insert_builtin_schema_key(
schema,
"theme.name",
SchemaEntry::string(),
"Name of the active color theme",
);
insert_builtin_schema_key(
schema,
"theme.path",
SchemaEntry::string_list(),
"Extra theme search paths",
);
insert_builtin_schema_key(
schema,
"user.name",
SchemaEntry::string(),
"Short user name used in prompts and interpolation",
);
insert_builtin_schema_key(
schema,
"user.display_name",
SchemaEntry::string(),
"Preferred display name for the current user",
);
insert_builtin_schema_key(
schema,
"user.full_name",
SchemaEntry::string(),
"Full name for the current user",
);
insert_builtin_schema_key(
schema,
"domain",
SchemaEntry::string(),
"Default domain name used in prompts and interpolation",
);
}
fn insert_ui_schema_keys(schema: &mut ConfigSchema) {
insert_builtin_schema_key(
schema,
"ui.format",
SchemaEntry::string()
.with_allowed_values(["auto", "guide", "json", "table", "md", "mreg", "value"]),
"Default output format",
);
insert_builtin_schema_key(
schema,
"ui.mode",
SchemaEntry::string().with_allowed_values(["auto", "plain", "rich"]),
"Preferred render mode",
);
insert_builtin_schema_key(
schema,
"ui.presentation",
SchemaEntry::string().with_allowed_values([
"expressive",
"compact",
"austere",
"gammel-og-bitter",
]),
"UI presentation preset",
);
insert_builtin_schema_key(
schema,
"ui.color.mode",
SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
"Color rendering policy",
);
insert_builtin_schema_key(
schema,
"ui.unicode.mode",
SchemaEntry::string().with_allowed_values(["auto", "always", "never"]),
"Unicode rendering policy",
);
insert_builtin_schema_key(
schema,
"ui.width",
SchemaEntry::integer(),
"Default render width hint",
);
insert_builtin_schema_key(
schema,
"ui.margin",
SchemaEntry::integer(),
"Left margin used when rendering output",
);
insert_builtin_schema_key(
schema,
"ui.indent",
SchemaEntry::integer(),
"Indent width for nested output",
);
insert_builtin_schema_key(
schema,
"ui.help.level",
SchemaEntry::string().with_allowed_values(["inherit", "none", "tiny", "normal", "verbose"]),
"Help detail level or inherit",
);
insert_builtin_schema_key(
schema,
"ui.guide.default_format",
SchemaEntry::string().with_allowed_values(["guide", "inherit", "none"]),
"Guide rendering format used by help-like output",
);
insert_builtin_schema_key(
schema,
"ui.messages.layout",
SchemaEntry::string().with_allowed_values([
"full", "compact", "austere", "plain", "none", "grouped", "minimal",
]),
"Message layout style",
);
insert_builtin_schema_key(
schema,
"ui.chrome.frame",
SchemaEntry::string().with_allowed_values([
"none",
"top",
"bottom",
"top-bottom",
"square",
"round",
]),
"Section chrome frame style",
);
insert_builtin_schema_key(
schema,
"ui.chrome.rule_policy",
SchemaEntry::string().with_allowed_values([
"per-section",
"independent",
"separate",
"shared",
"stacked",
"list",
]),
"How sibling section rules are shared",
);
insert_builtin_schema_key(
schema,
"ui.table.overflow",
SchemaEntry::string().with_allowed_values([
"clip", "hidden", "crop", "ellipsis", "truncate", "wrap", "none", "visible",
]),
"Table overflow behavior",
);
insert_builtin_schema_key(
schema,
"ui.table.border",
SchemaEntry::string().with_allowed_values(["none", "square", "round"]),
"Table border style",
);
insert_builtin_schema_key(
schema,
"ui.help.table_chrome",
SchemaEntry::string().with_allowed_values(["inherit", "none", "square", "round"]),
"Help table chrome style or inherit",
);
insert_builtin_schema_key(
schema,
"ui.help.entry_indent",
SchemaEntry::string(),
"Help entry indent override or inherit",
);
insert_builtin_schema_key(
schema,
"ui.help.entry_gap",
SchemaEntry::string(),
"Help entry gap override or inherit",
);
insert_builtin_schema_key(
schema,
"ui.help.section_spacing",
SchemaEntry::string(),
"Help section spacing override or inherit",
);
insert_builtin_schema_key(
schema,
"ui.short_list_max",
SchemaEntry::integer(),
"Maximum items rendered as a short list",
);
insert_builtin_schema_key(
schema,
"ui.medium_list_max",
SchemaEntry::integer(),
"Maximum items rendered as a medium list",
);
insert_builtin_schema_key(
schema,
"ui.grid_padding",
SchemaEntry::integer(),
"Padding between rendered grid columns",
);
insert_builtin_schema_key(
schema,
"ui.grid_columns",
SchemaEntry::integer(),
"Fixed grid column count when set",
);
insert_builtin_schema_key(
schema,
"ui.column_weight",
SchemaEntry::integer(),
"Relative weight used for adaptive columns",
);
insert_builtin_schema_key(
schema,
"ui.mreg.stack_min_col_width",
SchemaEntry::integer(),
"Minimum column width before MREG stacks columns",
);
insert_builtin_schema_key(
schema,
"ui.mreg.stack_overflow_ratio",
SchemaEntry::integer(),
"Overflow ratio threshold for stacked MREG output",
);
insert_builtin_schema_key(
schema,
"ui.message.verbosity",
SchemaEntry::string().with_allowed_values(["error", "warning", "success", "info", "trace"]),
"Default message verbosity level",
);
insert_builtin_schema_key(
schema,
"ui.prompt",
SchemaEntry::string(),
"Prompt template used by the UI",
);
insert_builtin_schema_key(
schema,
"ui.prompt.secrets",
SchemaEntry::boolean(),
"Whether prompts may reveal secret values",
);
insert_builtin_schema_key(
schema,
"extensions.plugins.timeout_ms",
SchemaEntry::integer(),
"Plugin process timeout in milliseconds",
);
insert_builtin_schema_key(
schema,
"extensions.plugins.discovery.path",
SchemaEntry::boolean(),
"Whether plugin discovery should scan PATH",
);
}
fn insert_repl_schema_keys(schema: &mut ConfigSchema) {
insert_builtin_schema_key(
schema,
"repl.prompt",
SchemaEntry::string(),
"Prompt template used by the REPL",
);
insert_builtin_schema_key(
schema,
"repl.prompt_right",
SchemaEntry::string(),
"Right-hand prompt template used by the REPL",
);
insert_builtin_schema_key(
schema,
"repl.input_mode",
SchemaEntry::string().with_allowed_values(["auto", "interactive", "basic"]),
"REPL input mode",
);
insert_builtin_schema_key(
schema,
"repl.simple_prompt",
SchemaEntry::boolean(),
"Whether the REPL should use the simple prompt",
);
insert_builtin_schema_key(
schema,
"repl.shell_indicator",
SchemaEntry::string(),
"Template for the current shell indicator",
);
insert_builtin_schema_key(
schema,
"repl.intro",
SchemaEntry::string().with_allowed_values(["none", "minimal", "compact", "full"]),
"REPL intro detail level",
);
insert_builtin_schema_key(
schema,
"repl.intro_template.minimal",
SchemaEntry::string(),
"Template for the minimal REPL intro",
);
insert_builtin_schema_key(
schema,
"repl.intro_template.compact",
SchemaEntry::string(),
"Template for the compact REPL intro",
);
insert_builtin_schema_key(
schema,
"repl.intro_template.full",
SchemaEntry::string(),
"Template for the full REPL intro",
);
insert_builtin_schema_key(
schema,
"repl.history.path",
SchemaEntry::string(),
"Path to the persistent REPL history file",
);
insert_builtin_schema_key(
schema,
"repl.history.max_entries",
SchemaEntry::integer(),
"Maximum number of persisted REPL history entries",
);
insert_builtin_schema_key(
schema,
"repl.history.enabled",
SchemaEntry::boolean(),
"Whether persistent REPL history is enabled",
);
insert_builtin_schema_key(
schema,
"repl.history.dedupe",
SchemaEntry::boolean(),
"Whether duplicate history entries are collapsed",
);
insert_builtin_schema_key(
schema,
"repl.history.profile_scoped",
SchemaEntry::boolean(),
"Whether history files are scoped by profile",
);
insert_builtin_schema_key(
schema,
"repl.history.menu_rows",
SchemaEntry::integer(),
"Maximum rows shown in the history menu",
);
insert_builtin_schema_key(
schema,
"repl.history.exclude",
SchemaEntry::string_list(),
"Commands excluded from persisted history",
);
insert_builtin_schema_key(
schema,
"session.cache.max_results",
SchemaEntry::integer(),
"Maximum cached session results",
);
}
fn insert_color_schema_keys(schema: &mut ConfigSchema) {
insert_builtin_schema_key(
schema,
"color.prompt.text",
SchemaEntry::string(),
"Prompt text color override",
);
insert_builtin_schema_key(
schema,
"color.prompt.command",
SchemaEntry::string(),
"Prompt command color override",
);
insert_builtin_schema_key(
schema,
"color.prompt.completion.text",
SchemaEntry::string(),
"Completion text color override",
);
insert_builtin_schema_key(
schema,
"color.prompt.completion.background",
SchemaEntry::string(),
"Completion background color override",
);
insert_builtin_schema_key(
schema,
"color.prompt.completion.highlight",
SchemaEntry::string(),
"Completion highlight color override",
);
insert_builtin_schema_key(
schema,
"color.text",
SchemaEntry::string(),
"Primary text color override",
);
insert_builtin_schema_key(
schema,
"color.text.muted",
SchemaEntry::string(),
"Muted text color override",
);
insert_builtin_schema_key(
schema,
"color.key",
SchemaEntry::string(),
"Key label color override",
);
insert_builtin_schema_key(
schema,
"color.border",
SchemaEntry::string(),
"Border color override",
);
insert_builtin_schema_key(
schema,
"color.table.header",
SchemaEntry::string(),
"Table header color override",
);
insert_builtin_schema_key(
schema,
"color.mreg.key",
SchemaEntry::string(),
"MREG key color override",
);
insert_builtin_schema_key(
schema,
"color.value",
SchemaEntry::string(),
"Value color override",
);
insert_builtin_schema_key(
schema,
"color.value.number",
SchemaEntry::string(),
"Numeric value color override",
);
insert_builtin_schema_key(
schema,
"color.value.bool_true",
SchemaEntry::string(),
"True boolean color override",
);
insert_builtin_schema_key(
schema,
"color.value.bool_false",
SchemaEntry::string(),
"False boolean color override",
);
insert_builtin_schema_key(
schema,
"color.value.null",
SchemaEntry::string(),
"Null value color override",
);
insert_builtin_schema_key(
schema,
"color.value.ipv4",
SchemaEntry::string(),
"IPv4 value color override",
);
insert_builtin_schema_key(
schema,
"color.value.ipv6",
SchemaEntry::string(),
"IPv6 value color override",
);
insert_builtin_schema_key(
schema,
"color.panel.border",
SchemaEntry::string(),
"Panel border color override",
);
insert_builtin_schema_key(
schema,
"color.panel.title",
SchemaEntry::string(),
"Panel title color override",
);
insert_builtin_schema_key(
schema,
"color.code",
SchemaEntry::string(),
"Code block color override",
);
insert_builtin_schema_key(
schema,
"color.json.key",
SchemaEntry::string(),
"JSON key color override",
);
insert_builtin_schema_key(
schema,
"color.message.error",
SchemaEntry::string(),
"Error message color override",
);
insert_builtin_schema_key(
schema,
"color.message.warning",
SchemaEntry::string(),
"Warning message color override",
);
insert_builtin_schema_key(
schema,
"color.message.success",
SchemaEntry::string(),
"Success message color override",
);
insert_builtin_schema_key(
schema,
"color.message.info",
SchemaEntry::string(),
"Info message color override",
);
insert_builtin_schema_key(
schema,
"color.message.trace",
SchemaEntry::string(),
"Trace message color override",
);
}
fn insert_misc_schema_keys(schema: &mut ConfigSchema) {
insert_builtin_schema_key(
schema,
"auth.visible.builtins",
SchemaEntry::string(),
"Visible builtin auth command allow-list",
);
insert_builtin_schema_key(
schema,
"auth.visible.plugins",
SchemaEntry::string(),
"Visible plugin auth command allow-list",
);
insert_builtin_schema_key(
schema,
"debug.level",
SchemaEntry::integer(),
"Developer debug verbosity",
);
insert_builtin_schema_key(
schema,
"log.file.enabled",
SchemaEntry::boolean(),
"Whether file logging is enabled",
);
insert_builtin_schema_key(
schema,
"log.file.path",
SchemaEntry::string(),
"Path to the runtime log file",
);
insert_builtin_schema_key(
schema,
"log.file.level",
SchemaEntry::string().with_allowed_values(["error", "warn", "info", "debug", "trace"]),
"Minimum log level written to the log file",
);
insert_builtin_schema_key(
schema,
"base.dir",
SchemaEntry::string(),
"Base directory available for interpolation and tooling",
);
}
impl ConfigSchema {
pub fn insert(&mut self, key: &'static str, entry: SchemaEntry) {
self.entries
.insert(key.to_string(), entry.with_canonical_key(key));
}
pub fn set_allow_extensions_namespace(&mut self, value: bool) {
self.allow_extensions_namespace = value;
}
pub fn is_known_key(&self, key: &str) -> bool {
self.entries.contains_key(key)
|| self.is_extension_key(key)
|| self.is_alias_key(key)
|| dynamic_schema_key_kind(key).is_some()
}
pub fn is_runtime_visible_key(&self, key: &str) -> bool {
self.entries
.get(key)
.is_some_and(SchemaEntry::runtime_visible)
|| self.is_extension_key(key)
|| dynamic_schema_key_kind(key).is_some()
}
pub fn validate_writable_key(&self, key: &str) -> Result<(), ConfigError> {
let normalized = key.trim().to_ascii_lowercase();
if let Some(entry) = self.entries.get(&normalized)
&& !entry.writable()
{
return Err(ConfigError::ReadOnlyConfigKey {
key: normalized,
reason: "derived at runtime".to_string(),
});
}
Ok(())
}
pub fn bootstrap_key_spec(&self, key: &str) -> Option<BootstrapKeySpec> {
let normalized = key.trim().to_ascii_lowercase();
self.entries
.get(&normalized)
.and_then(SchemaEntry::bootstrap_spec)
}
pub fn entries(&self) -> impl Iterator<Item = (&str, &SchemaEntry)> {
self.entries
.iter()
.map(|(key, entry)| (key.as_str(), entry))
}
pub fn doc_for_key(&self, key: &str) -> Option<&'static str> {
let normalized = key.trim().to_ascii_lowercase();
self.entries.get(&normalized).and_then(SchemaEntry::doc)
}
pub fn expected_type(&self, key: &str) -> Option<SchemaValueType> {
self.entries
.get(key)
.map(|entry| entry.value_type)
.or_else(|| dynamic_schema_key_kind(key).map(|_| SchemaValueType::String))
}
pub fn parse_input_value(&self, key: &str, raw: &str) -> Result<ConfigValue, ConfigError> {
if !self.is_known_key(key) {
return Err(ConfigError::UnknownConfigKeys {
keys: vec![key.to_string()],
});
}
self.validate_writable_key(key)?;
let value = match self.expected_type(key) {
Some(SchemaValueType::String) | None => ConfigValue::String(raw.to_string()),
Some(SchemaValueType::Bool) => {
ConfigValue::Bool(
parse_bool(raw).ok_or_else(|| ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Bool,
actual: "string".to_string(),
})?,
)
}
Some(SchemaValueType::Integer) => {
let parsed =
raw.trim()
.parse::<i64>()
.map_err(|_| ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Integer,
actual: "string".to_string(),
})?;
ConfigValue::Integer(parsed)
}
Some(SchemaValueType::Float) => {
let parsed =
raw.trim()
.parse::<f64>()
.map_err(|_| ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Float,
actual: "string".to_string(),
})?;
ConfigValue::Float(parsed)
}
Some(SchemaValueType::StringList) => {
let items = parse_string_list(raw);
ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
}
};
if let Some(entry) = self.entries.get(key) {
validate_allowed_values(
key,
&value,
entry
.allowed_values()
.map(|values| values.iter().map(String::as_str).collect::<Vec<_>>())
.as_deref(),
)?;
} else if let Some(DynamicSchemaKeyKind::PluginCommandState) = dynamic_schema_key_kind(key)
{
validate_allowed_values(key, &value, Some(&["enabled", "disabled"]))?;
}
Ok(value)
}
pub(crate) fn validate_and_adapt(
&self,
values: &mut BTreeMap<String, ResolvedValue>,
) -> Result<(), ConfigError> {
let mut unknown = Vec::new();
for key in values.keys() {
if self.is_runtime_visible_key(key) {
continue;
}
unknown.push(key.clone());
}
if !unknown.is_empty() {
unknown.sort();
return Err(ConfigError::UnknownConfigKeys { keys: unknown });
}
for (key, entry) in &self.entries {
if entry.runtime_visible && entry.required && !values.contains_key(key) {
return Err(ConfigError::MissingRequiredKey { key: key.clone() });
}
}
for (key, resolved) in values.iter_mut() {
if let Some(kind) = dynamic_schema_key_kind(key) {
resolved.value = adapt_dynamic_value_for_schema(key, &resolved.value, kind)?;
continue;
}
let Some(schema_entry) = self.entries.get(key) else {
continue;
};
if !schema_entry.runtime_visible {
continue;
}
resolved.value = adapt_value_for_schema(key, &resolved.value, schema_entry)?;
}
Ok(())
}
fn is_extension_key(&self, key: &str) -> bool {
self.allow_extensions_namespace && key.starts_with("extensions.")
}
fn is_alias_key(&self, key: &str) -> bool {
key.starts_with("alias.")
}
pub fn validate_key_scope(&self, key: &str, scope: &Scope) -> Result<(), ConfigError> {
let normalized_scope = normalize_scope(scope.clone());
if let Some(spec) = self.bootstrap_key_spec(key)
&& !spec.allows_scope(&normalized_scope)
{
return Err(ConfigError::InvalidBootstrapScope {
key: spec.key.to_string(),
profile: normalized_scope.profile,
terminal: normalized_scope.terminal,
});
}
Ok(())
}
pub fn validate_bootstrap_value(
&self,
key: &str,
value: &ConfigValue,
) -> Result<(), ConfigError> {
let normalized = key.trim().to_ascii_lowercase();
let Some(entry) = self.entries.get(&normalized) else {
return Ok(());
};
entry.validate_bootstrap_value(&normalized, value)
}
}
fn builtin_config_schema() -> &'static ConfigSchema {
static BUILTIN_SCHEMA: OnceLock<ConfigSchema> = OnceLock::new();
BUILTIN_SCHEMA.get_or_init(ConfigSchema::builtin)
}
impl Display for ConfigValue {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ConfigValue::String(v) => write!(f, "{v}"),
ConfigValue::Bool(v) => write!(f, "{v}"),
ConfigValue::Integer(v) => write!(f, "{v}"),
ConfigValue::Float(v) => write!(f, "{v}"),
ConfigValue::List(v) => {
let joined = v
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
.join(",");
write!(f, "[{joined}]")
}
ConfigValue::Secret(secret) => write!(f, "{secret}"),
}
}
}
impl From<&str> for ConfigValue {
fn from(value: &str) -> Self {
ConfigValue::String(value.to_string())
}
}
impl From<String> for ConfigValue {
fn from(value: String) -> Self {
ConfigValue::String(value)
}
}
impl From<bool> for ConfigValue {
fn from(value: bool) -> Self {
ConfigValue::Bool(value)
}
}
impl From<i64> for ConfigValue {
fn from(value: i64) -> Self {
ConfigValue::Integer(value)
}
}
impl From<f64> for ConfigValue {
fn from(value: f64) -> Self {
ConfigValue::Float(value)
}
}
impl From<Vec<String>> for ConfigValue {
fn from(values: Vec<String>) -> Self {
ConfigValue::List(values.into_iter().map(ConfigValue::String).collect())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Scope {
pub profile: Option<String>,
pub terminal: Option<String>,
}
impl Scope {
pub fn global() -> Self {
Self::default()
}
pub fn profile(profile: &str) -> Self {
Self {
profile: Some(normalize_identifier(profile)),
terminal: None,
}
}
pub fn terminal(terminal: &str) -> Self {
Self {
profile: None,
terminal: Some(normalize_identifier(terminal)),
}
}
pub fn profile_terminal(profile: &str, terminal: &str) -> Self {
Self {
profile: Some(normalize_identifier(profile)),
terminal: Some(normalize_identifier(terminal)),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LayerEntry {
pub key: String,
pub value: ConfigValue,
pub scope: Scope,
pub origin: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ConfigLayer {
pub(crate) entries: Vec<LayerEntry>,
}
impl ConfigLayer {
pub fn entries(&self) -> &[LayerEntry] {
&self.entries
}
pub fn extend_from_layer(&mut self, other: &ConfigLayer) {
self.entries.extend(other.entries().iter().cloned());
}
pub fn set<K, V>(&mut self, key: K, value: V)
where
K: Into<String>,
V: Into<ConfigValue>,
{
self.insert(key, value, Scope::global());
}
pub fn set_for_profile<K, V>(&mut self, profile: &str, key: K, value: V)
where
K: Into<String>,
V: Into<ConfigValue>,
{
self.insert(key, value, Scope::profile(profile));
}
pub fn set_for_terminal<K, V>(&mut self, terminal: &str, key: K, value: V)
where
K: Into<String>,
V: Into<ConfigValue>,
{
self.insert(key, value, Scope::terminal(terminal));
}
pub fn set_for_profile_terminal<K, V>(
&mut self,
profile: &str,
terminal: &str,
key: K,
value: V,
) where
K: Into<String>,
V: Into<ConfigValue>,
{
self.insert(key, value, Scope::profile_terminal(profile, terminal));
}
pub fn insert<K, V>(&mut self, key: K, value: V, scope: Scope)
where
K: Into<String>,
V: Into<ConfigValue>,
{
self.entries.push(LayerEntry {
key: key.into(),
value: value.into(),
scope: normalize_scope(scope),
origin: None,
});
}
pub fn insert_with_origin<K, V, O>(&mut self, key: K, value: V, scope: Scope, origin: Option<O>)
where
K: Into<String>,
V: Into<ConfigValue>,
O: Into<String>,
{
self.entries.push(LayerEntry {
key: key.into(),
value: value.into(),
scope: normalize_scope(scope),
origin: origin.map(Into::into),
});
}
pub fn mark_all_secret(&mut self) {
for entry in &mut self.entries {
if !entry.value.is_secret() {
entry.value = entry.value.clone().into_secret();
}
}
}
pub fn remove_scoped(&mut self, key: &str, scope: &Scope) -> Option<ConfigValue> {
let normalized_scope = normalize_scope(scope.clone());
let index = self
.entries
.iter()
.rposition(|entry| entry.key == key && entry.scope == normalized_scope)?;
Some(self.entries.remove(index).value)
}
pub fn from_toml_str(raw: &str) -> Result<Self, ConfigError> {
let parsed = raw
.parse::<toml::Value>()
.map_err(|err| ConfigError::TomlParse(err.to_string()))?;
let root = parsed.as_table().ok_or(ConfigError::TomlRootMustBeTable)?;
let mut layer = ConfigLayer::default();
for (section, value) in root {
match section.as_str() {
"default" => {
let table = value
.as_table()
.ok_or_else(|| ConfigError::InvalidSection {
section: "default".to_string(),
expected: "table".to_string(),
})?;
flatten_table(&mut layer, table, "", &Scope::global())?;
}
"profile" => {
let profiles = value
.as_table()
.ok_or_else(|| ConfigError::InvalidSection {
section: "profile".to_string(),
expected: "table".to_string(),
})?;
for (profile, profile_table_value) in profiles {
let profile_table = profile_table_value.as_table().ok_or_else(|| {
ConfigError::InvalidSection {
section: format!("profile.{profile}"),
expected: "table".to_string(),
}
})?;
flatten_table(&mut layer, profile_table, "", &Scope::profile(profile))?;
}
}
"terminal" => {
let terminals =
value
.as_table()
.ok_or_else(|| ConfigError::InvalidSection {
section: "terminal".to_string(),
expected: "table".to_string(),
})?;
for (terminal, terminal_table_value) in terminals {
let terminal_table = terminal_table_value.as_table().ok_or_else(|| {
ConfigError::InvalidSection {
section: format!("terminal.{terminal}"),
expected: "table".to_string(),
}
})?;
for (key, terminal_value) in terminal_table {
if key == "profile" {
continue;
}
flatten_key_value(
&mut layer,
key,
terminal_value,
&Scope::terminal(terminal),
)?;
}
if let Some(profile_section) = terminal_table.get("profile") {
let profile_tables = profile_section.as_table().ok_or_else(|| {
ConfigError::InvalidSection {
section: format!("terminal.{terminal}.profile"),
expected: "table".to_string(),
}
})?;
for (profile_key, profile_value) in profile_tables {
if let Some(profile_table) = profile_value.as_table() {
flatten_table(
&mut layer,
profile_table,
"",
&Scope::profile_terminal(profile_key, terminal),
)?;
} else {
flatten_key_value(
&mut layer,
&format!("profile.{profile_key}"),
profile_value,
&Scope::terminal(terminal),
)?;
}
}
}
}
}
unknown => {
return Err(ConfigError::UnknownTopLevelSection(unknown.to_string()));
}
}
}
Ok(layer)
}
pub fn from_env_iter<I, K, V>(vars: I) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
let mut layer = ConfigLayer::default();
for (name, value) in vars {
let key = name.as_ref();
if !key.starts_with("OSP__") {
continue;
}
let spec = parse_env_key(key)?;
builtin_config_schema().validate_writable_key(&spec.key)?;
validate_key_scope(&spec.key, &spec.scope)?;
let converted = ConfigValue::String(value.as_ref().to_string());
validate_bootstrap_value(&spec.key, &converted)?;
layer.insert_with_origin(spec.key, converted, spec.scope, Some(key.to_string()));
}
Ok(layer)
}
pub(crate) fn validate_entries(&self) -> Result<(), ConfigError> {
for entry in &self.entries {
builtin_config_schema().validate_writable_key(&entry.key)?;
validate_key_scope(&entry.key, &entry.scope)?;
validate_bootstrap_value(&entry.key, &entry.value)?;
}
Ok(())
}
}
pub(crate) struct EnvKeySpec {
pub(crate) key: String,
pub(crate) scope: Scope,
}
#[derive(Debug, Clone, Default)]
#[must_use]
pub struct ResolveOptions {
pub profile_override: Option<String>,
pub terminal: Option<String>,
}
impl ResolveOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_profile_override(mut self, profile_override: Option<String>) -> Self {
self.profile_override = normalize_optional_identifier(profile_override);
self
}
pub fn with_profile(mut self, profile: &str) -> Self {
self.profile_override = Some(normalize_identifier(profile));
self
}
pub fn with_terminal(mut self, terminal: &str) -> Self {
self.terminal = Some(normalize_identifier(terminal));
self
}
pub fn with_terminal_override(mut self, terminal: Option<String>) -> Self {
self.terminal = normalize_optional_identifier(terminal);
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedValue {
pub raw_value: ConfigValue,
pub value: ConfigValue,
pub source: ConfigSource,
pub scope: Scope,
pub origin: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExplainCandidate {
pub entry_index: usize,
pub value: ConfigValue,
pub scope: Scope,
pub origin: Option<String>,
pub rank: Option<u8>,
pub selected_in_layer: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExplainLayer {
pub source: ConfigSource,
pub selected_entry_index: Option<usize>,
pub candidates: Vec<ExplainCandidate>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExplainInterpolationStep {
pub placeholder: String,
pub raw_value: ConfigValue,
pub value: ConfigValue,
pub source: ConfigSource,
pub scope: Scope,
pub origin: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExplainInterpolation {
pub template: String,
pub steps: Vec<ExplainInterpolationStep>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActiveProfileSource {
Override,
DefaultProfile,
}
impl ActiveProfileSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Override => "override",
Self::DefaultProfile => "profile.default",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigExplain {
pub key: String,
pub active_profile: String,
pub active_profile_source: ActiveProfileSource,
pub terminal: Option<String>,
pub known_profiles: BTreeSet<String>,
pub layers: Vec<ExplainLayer>,
pub final_entry: Option<ResolvedValue>,
pub interpolation: Option<ExplainInterpolation>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BootstrapConfigExplain {
pub key: String,
pub active_profile: String,
pub active_profile_source: ActiveProfileSource,
pub terminal: Option<String>,
pub known_profiles: BTreeSet<String>,
pub layers: Vec<ExplainLayer>,
pub final_entry: Option<ResolvedValue>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedConfig {
pub(crate) active_profile: String,
pub(crate) terminal: Option<String>,
pub(crate) known_profiles: BTreeSet<String>,
pub(crate) values: BTreeMap<String, ResolvedValue>,
pub(crate) aliases: BTreeMap<String, ResolvedValue>,
}
impl ResolvedConfig {
pub fn active_profile(&self) -> &str {
&self.active_profile
}
pub fn terminal(&self) -> Option<&str> {
self.terminal.as_deref()
}
pub fn known_profiles(&self) -> &BTreeSet<String> {
&self.known_profiles
}
pub fn values(&self) -> &BTreeMap<String, ResolvedValue> {
&self.values
}
pub fn aliases(&self) -> &BTreeMap<String, ResolvedValue> {
&self.aliases
}
pub fn get(&self, key: &str) -> Option<&ConfigValue> {
self.values.get(key).map(|entry| &entry.value)
}
pub fn get_string(&self, key: &str) -> Option<&str> {
match self.get(key).map(ConfigValue::reveal) {
Some(ConfigValue::String(value)) => Some(value),
_ => None,
}
}
pub fn get_bool(&self, key: &str) -> Option<bool> {
match self.get(key).map(ConfigValue::reveal) {
Some(ConfigValue::Bool(value)) => Some(*value),
_ => None,
}
}
pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
match self.get(key).map(ConfigValue::reveal) {
Some(ConfigValue::List(values)) => Some(
values
.iter()
.filter_map(|value| match value {
ConfigValue::String(text) => Some(text.clone()),
ConfigValue::Secret(secret) => match secret.expose() {
ConfigValue::String(text) => Some(text.clone()),
_ => None,
},
_ => None,
})
.collect(),
),
Some(ConfigValue::String(value)) => Some(vec![value.clone()]),
Some(ConfigValue::Secret(secret)) => match secret.expose() {
ConfigValue::String(value) => Some(vec![value.clone()]),
_ => None,
},
_ => None,
}
}
pub fn get_value_entry(&self, key: &str) -> Option<&ResolvedValue> {
self.values.get(key)
}
pub fn get_alias_entry(&self, key: &str) -> Option<&ResolvedValue> {
let normalized = if key.trim().to_ascii_lowercase().starts_with("alias.") {
key.trim().to_ascii_lowercase()
} else {
format!("alias.{}", key.trim().to_ascii_lowercase())
};
self.aliases.get(&normalized)
}
}
fn flatten_table(
layer: &mut ConfigLayer,
table: &toml::value::Table,
prefix: &str,
scope: &Scope,
) -> Result<(), ConfigError> {
for (key, value) in table {
let full_key = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
flatten_key_value(layer, &full_key, value, scope)?;
}
Ok(())
}
fn flatten_key_value(
layer: &mut ConfigLayer,
key: &str,
value: &toml::Value,
scope: &Scope,
) -> Result<(), ConfigError> {
match value {
toml::Value::Table(table) => flatten_table(layer, table, key, scope),
_ => {
let converted = ConfigValue::from_toml(key, value)?;
builtin_config_schema().validate_writable_key(key)?;
validate_key_scope(key, scope)?;
validate_bootstrap_value(key, &converted)?;
layer.insert(key.to_string(), converted, scope.clone());
Ok(())
}
}
}
pub fn bootstrap_key_spec(key: &str) -> Option<BootstrapKeySpec> {
builtin_config_schema().bootstrap_key_spec(key)
}
pub fn is_bootstrap_only_key(key: &str) -> bool {
bootstrap_key_spec(key).is_some_and(|spec| !spec.runtime_visible)
}
pub fn is_alias_key(key: &str) -> bool {
key.trim().to_ascii_lowercase().starts_with("alias.")
}
pub fn validate_key_scope(key: &str, scope: &Scope) -> Result<(), ConfigError> {
builtin_config_schema().validate_key_scope(key, scope)
}
pub fn validate_bootstrap_value(key: &str, value: &ConfigValue) -> Result<(), ConfigError> {
builtin_config_schema().validate_bootstrap_value(key, value)
}
fn adapt_value_for_schema(
key: &str,
value: &ConfigValue,
schema: &SchemaEntry,
) -> Result<ConfigValue, ConfigError> {
let (is_secret, value) = match value {
ConfigValue::Secret(secret) => (true, secret.expose()),
other => (false, other),
};
let adapted = match schema.value_type {
SchemaValueType::String => match value {
ConfigValue::String(value) => ConfigValue::String(value.clone()),
other => {
return Err(ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::String,
actual: value_type_name(other).to_string(),
});
}
},
SchemaValueType::Bool => match value {
ConfigValue::Bool(value) => ConfigValue::Bool(*value),
ConfigValue::String(value) => {
ConfigValue::Bool(parse_bool(value).ok_or_else(|| {
ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Bool,
actual: "string".to_string(),
}
})?)
}
other => {
return Err(ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Bool,
actual: value_type_name(other).to_string(),
});
}
},
SchemaValueType::Integer => match value {
ConfigValue::Integer(value) => ConfigValue::Integer(*value),
ConfigValue::String(value) => {
let parsed =
value
.trim()
.parse::<i64>()
.map_err(|_| ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Integer,
actual: "string".to_string(),
})?;
ConfigValue::Integer(parsed)
}
other => {
return Err(ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Integer,
actual: value_type_name(other).to_string(),
});
}
},
SchemaValueType::Float => match value {
ConfigValue::Float(value) => ConfigValue::Float(*value),
ConfigValue::Integer(value) => ConfigValue::Float(*value as f64),
ConfigValue::String(value) => {
let parsed =
value
.trim()
.parse::<f64>()
.map_err(|_| ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Float,
actual: "string".to_string(),
})?;
ConfigValue::Float(parsed)
}
other => {
return Err(ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::Float,
actual: value_type_name(other).to_string(),
});
}
},
SchemaValueType::StringList => match value {
ConfigValue::List(values) => {
let mut out = Vec::with_capacity(values.len());
for value in values {
match value {
ConfigValue::String(value) => out.push(ConfigValue::String(value.clone())),
ConfigValue::Secret(secret) => match secret.expose() {
ConfigValue::String(value) => {
out.push(ConfigValue::String(value.clone()))
}
other => {
return Err(ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::StringList,
actual: value_type_name(other).to_string(),
});
}
},
other => {
return Err(ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::StringList,
actual: value_type_name(other).to_string(),
});
}
}
}
ConfigValue::List(out)
}
ConfigValue::String(value) => {
let items = parse_string_list(value);
ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
}
ConfigValue::Secret(secret) => match secret.expose() {
ConfigValue::String(value) => {
let items = parse_string_list(value);
ConfigValue::List(items.into_iter().map(ConfigValue::String).collect())
}
other => {
return Err(ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::StringList,
actual: value_type_name(other).to_string(),
});
}
},
other => {
return Err(ConfigError::InvalidValueType {
key: key.to_string(),
expected: SchemaValueType::StringList,
actual: value_type_name(other).to_string(),
});
}
},
};
let adapted = if is_secret {
adapted.into_secret()
} else {
adapted
};
if let Some(allowed_values) = &schema.allowed_values
&& let ConfigValue::String(value) = adapted.reveal()
{
let normalized = value.to_ascii_lowercase();
if !allowed_values.contains(&normalized) {
return Err(ConfigError::InvalidEnumValue {
key: key.to_string(),
value: value.clone(),
allowed: allowed_values.clone(),
});
}
}
Ok(adapted)
}
fn adapt_dynamic_value_for_schema(
key: &str,
value: &ConfigValue,
kind: DynamicSchemaKeyKind,
) -> Result<ConfigValue, ConfigError> {
let adapted = match kind {
DynamicSchemaKeyKind::PluginCommandState | DynamicSchemaKeyKind::PluginCommandProvider => {
adapt_value_for_schema(key, value, &SchemaEntry::string())?
}
};
if matches!(kind, DynamicSchemaKeyKind::PluginCommandState) {
validate_allowed_values(key, &adapted, Some(&["enabled", "disabled"]))?;
}
Ok(adapted)
}
fn validate_allowed_values(
key: &str,
value: &ConfigValue,
allowed: Option<&[&str]>,
) -> Result<(), ConfigError> {
let Some(allowed) = allowed else {
return Ok(());
};
if let ConfigValue::String(current) = value {
let normalized = current.to_ascii_lowercase();
if !allowed.iter().any(|candidate| *candidate == normalized) {
return Err(ConfigError::InvalidEnumValue {
key: key.to_string(),
value: current.clone(),
allowed: allowed.iter().map(|value| (*value).to_string()).collect(),
});
}
}
Ok(())
}
fn dynamic_schema_key_kind(key: &str) -> Option<DynamicSchemaKeyKind> {
let normalized = key.trim().to_ascii_lowercase();
let remainder = normalized.strip_prefix("plugins.")?;
let (command, field) = remainder.rsplit_once('.')?;
if command.trim().is_empty() {
return None;
}
match field {
"state" => Some(DynamicSchemaKeyKind::PluginCommandState),
"provider" => Some(DynamicSchemaKeyKind::PluginCommandProvider),
_ => None,
}
}
fn parse_bool(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn parse_string_list(value: &str) -> Vec<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Vec::new();
}
let inner = trimmed
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(trimmed);
inner
.split(',')
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|value| {
value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| {
value
.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
})
.unwrap_or(value)
.to_string()
})
.collect()
}
fn value_type_name(value: &ConfigValue) -> &'static str {
match value.reveal() {
ConfigValue::String(_) => "string",
ConfigValue::Bool(_) => "bool",
ConfigValue::Integer(_) => "integer",
ConfigValue::Float(_) => "float",
ConfigValue::List(_) => "list",
ConfigValue::Secret(_) => "string",
}
}
pub(crate) fn parse_env_key(key: &str) -> Result<EnvKeySpec, ConfigError> {
let Some(raw) = key.strip_prefix("OSP__") else {
return Err(ConfigError::InvalidEnvOverride {
key: key.to_string(),
reason: "missing OSP__ prefix".to_string(),
});
};
let parts = raw
.split("__")
.filter(|part| !part.is_empty())
.collect::<Vec<&str>>();
if parts.is_empty() {
return Err(ConfigError::InvalidEnvOverride {
key: key.to_string(),
reason: "missing key path".to_string(),
});
}
let mut cursor = 0usize;
let mut terminal: Option<String> = None;
let mut profile: Option<String> = None;
while cursor < parts.len() {
let part = parts[cursor];
if part.eq_ignore_ascii_case("TERM") {
if terminal.is_some() {
return Err(ConfigError::InvalidEnvOverride {
key: key.to_string(),
reason: "TERM scope specified more than once".to_string(),
});
}
let term = parts
.get(cursor + 1)
.ok_or_else(|| ConfigError::InvalidEnvOverride {
key: key.to_string(),
reason: "TERM requires a terminal name".to_string(),
})?;
terminal = Some(normalize_identifier(term));
cursor += 2;
continue;
}
if part.eq_ignore_ascii_case("PROFILE") {
if remaining_parts_are_bootstrap_profile_default(&parts[cursor..]) {
break;
}
if profile.is_some() {
return Err(ConfigError::InvalidEnvOverride {
key: key.to_string(),
reason: "PROFILE scope specified more than once".to_string(),
});
}
let profile_name =
parts
.get(cursor + 1)
.ok_or_else(|| ConfigError::InvalidEnvOverride {
key: key.to_string(),
reason: "PROFILE requires a profile name".to_string(),
})?;
profile = Some(normalize_identifier(profile_name));
cursor += 2;
continue;
}
break;
}
let key_parts = &parts[cursor..];
if key_parts.is_empty() {
return Err(ConfigError::InvalidEnvOverride {
key: key.to_string(),
reason: "missing final config key".to_string(),
});
}
let dotted_key = key_parts
.iter()
.map(|part| part.to_ascii_lowercase())
.collect::<Vec<String>>()
.join(".");
Ok(EnvKeySpec {
key: dotted_key,
scope: Scope { profile, terminal },
})
}
fn remaining_parts_are_bootstrap_profile_default(parts: &[&str]) -> bool {
matches!(parts, [profile, default]
if profile.eq_ignore_ascii_case("PROFILE")
&& default.eq_ignore_ascii_case("DEFAULT"))
}
pub(crate) fn normalize_scope(scope: Scope) -> Scope {
Scope {
profile: normalize_optional_identifier(scope.profile),
terminal: normalize_optional_identifier(scope.terminal),
}
}
#[cfg(test)]
mod tests;