use schemars::JsonSchema;
use serde::{
Deserialize, Serialize,
de::{self, DeserializeOwned},
ser::Serializer,
};
use serde_json::{Map, Value, json};
use std::collections::BTreeMap;
use super::{
agent::{
CompactionSettings, InstructionsSettings, IntegrationsSettings, ModelsSettings,
SessionTitleSettings, SkillsSettings,
},
core::{
AnthropicCacheTtl, AppearanceSettings, FastSettings, OpenAiCodexSettings,
OpenAiResponsesSettings, SessionSettings, Settings,
},
helpers::default_file_autocomplete_respects_gitignore,
providers::{ProviderStreamSettings, SelectedModelSettings, TtsrSettings},
services::{LspSettings, McpServersSettings},
tools::{SubagentsToolSettings, ToolSettings},
};
use crate::config::{CustomProviderConfig, HookSettings};
pub(crate) const SETTINGS_SCHEMA_VERSION: u64 = 2;
const LEGACY_ROOT_KEYS: &[&str] = &[
"selected_model",
"provider",
"model",
"thinking_level",
"no_color",
"appearance",
"fast",
"openai_codex",
"openai_responses",
"anthropic_cache_ttl",
"file_autocomplete_respects_gitignore",
"context",
"session_titles",
"compaction",
"summarizer",
"tools",
"subagents",
"models",
"hooks",
"instructions",
"skills",
"custom_providers",
"mcp_servers",
"lsp",
"integrations",
"herdr",
"tui",
"provider_stream",
"ttsr",
"selected_primary_agent",
"disabled_skills",
"sessions",
"schema_version",
];
#[allow(dead_code)]
fn default_session_retention_days() -> u64 {
super::core::DEFAULT_SESSION_RETENTION_DAYS
}
#[allow(dead_code)]
#[derive(Default, JsonSchema)]
struct SettingsSchema {
#[schemars(range(min = 2, max = 2))]
pub schema_version: u64,
#[serde(default)]
#[schemars(skip_serializing_if = "omit_schema_default")]
pub agent: AgentSettingsSchema,
#[serde(default)]
#[schemars(skip_serializing_if = "omit_schema_default")]
pub providers: ProvidersSettingsSchema,
#[serde(default)]
#[schemars(skip_serializing_if = "omit_schema_default")]
pub capabilities: CapabilitiesSettingsSchema,
#[serde(default)]
#[schemars(skip_serializing_if = "omit_schema_default")]
pub knowledge: KnowledgeSettingsSchema,
#[serde(default)]
#[schemars(skip_serializing_if = "omit_schema_default")]
pub automation: AutomationSettingsSchema,
#[serde(default)]
pub sessions: SessionsSettingsSchema,
#[serde(default)]
#[schemars(skip_serializing_if = "omit_schema_default")]
pub interface: InterfaceSettingsSchema,
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
struct AgentSettingsSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(!skip_serializing_if)]
primary_agent: Option<String>,
#[serde(default, skip_serializing_if = "SelectedModelSettings::is_default")]
#[schemars(!skip_serializing_if)]
model: SelectedModelSettings,
#[serde(default, skip_serializing_if = "FastSettings::is_default")]
#[schemars(!skip_serializing_if)]
fast: FastSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(!skip_serializing_if)]
context: Option<crate::context::ContextBudget>,
#[serde(default, skip_serializing_if = "CompactionSettings::is_default")]
#[schemars(!skip_serializing_if)]
compaction: CompactionSettings,
#[serde(
default,
skip_serializing_if = "super::agent::SummarizerSettings::is_default"
)]
#[schemars(!skip_serializing_if)]
summarizer: super::agent::SummarizerSettings,
#[serde(
default,
skip_serializing_if = "AgentSubagentsSettingsSchema::is_default"
)]
subagents: AgentSubagentsSettingsSchema,
#[serde(default, skip_serializing_if = "TtsrSettings::is_default")]
#[schemars(!skip_serializing_if)]
reminders: TtsrSettings,
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
#[schemars(transform = subagents_schema_defaults)]
struct AgentSubagentsSettingsSchema {
#[serde(flatten)]
settings: super::agent::SubagentsSettings,
#[serde(default, skip_serializing_if = "SubagentsToolSettings::is_default")]
#[schemars(!skip_serializing_if)]
execution: SubagentsToolSettings,
}
impl AgentSubagentsSettingsSchema {
fn is_default(&self) -> bool {
self.settings == super::agent::SubagentsSettings::default() && self.execution.is_default()
}
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
struct ProvidersSettingsSchema {
#[serde(default, skip_serializing_if = "OpenAiCodexSettings::is_default")]
#[schemars(!skip_serializing_if)]
openai_codex: OpenAiCodexSettings,
#[serde(default, skip_serializing_if = "OpenAiResponsesSettings::is_default")]
#[schemars(!skip_serializing_if)]
openai_responses: OpenAiResponsesSettings,
#[serde(
default,
skip_serializing_if = "AnthropicProviderSettingsSchema::is_default"
)]
anthropic: AnthropicProviderSettingsSchema,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[schemars(!skip_serializing_if)]
custom: BTreeMap<String, CustomProviderConfig>,
#[serde(default, skip_serializing_if = "ModelsSettings::is_default")]
#[schemars(!skip_serializing_if)]
catalog: ModelsSettings,
#[serde(default, skip_serializing_if = "ProviderStreamSettings::is_default")]
#[schemars(!skip_serializing_if)]
streaming: ProviderStreamSettings,
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
struct AnthropicProviderSettingsSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(!skip_serializing_if)]
cache_ttl: Option<AnthropicCacheTtl>,
}
impl AnthropicProviderSettingsSchema {
fn is_default(&self) -> bool {
self.cache_ttl.is_none()
}
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
struct CapabilitiesSettingsSchema {
#[serde(default, skip_serializing_if = "is_default")]
#[schemars(with = "ToolCapabilitiesSettingsSchema")]
tools: ToolSettings,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[schemars(!skip_serializing_if)]
mcp: McpServersSettings,
#[serde(default, skip_serializing_if = "LspSettings::is_default")]
#[schemars(!skip_serializing_if)]
lsp: LspSettings,
}
struct ToolCapabilitiesSettingsSchema;
impl JsonSchema for ToolCapabilitiesSettingsSchema {
fn schema_name() -> std::borrow::Cow<'static, str> {
"ToolCapabilitiesSettingsSchema".into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mut schema = ToolSettings::json_schema(generator);
if let Some(Value::Object(properties)) = schema.get_mut("properties") {
properties.remove("subagents");
}
schema
}
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
struct KnowledgeSettingsSchema {
#[serde(default, skip_serializing_if = "InstructionsSettings::is_default")]
#[schemars(!skip_serializing_if)]
instructions: InstructionsSettings,
#[serde(default, skip_serializing_if = "SkillsSettings::is_default")]
#[schemars(!skip_serializing_if)]
skills: SkillsSettings,
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
struct AutomationSettingsSchema {
#[serde(default, skip_serializing_if = "HookSettings::is_default")]
#[schemars(!skip_serializing_if)]
hooks: HookSettings,
#[serde(default, skip_serializing_if = "IntegrationsSettings::is_default")]
#[schemars(!skip_serializing_if)]
integrations: IntegrationsSettings,
}
#[allow(dead_code)]
#[derive(Default, JsonSchema)]
struct SessionsSettingsSchema {
#[serde(default = "default_session_retention_days")]
pub retention_days: u64,
#[serde(flatten)]
titles: SessionTitlesWire,
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
struct SessionTitlesWire {
#[serde(default, skip_serializing_if = "SessionTitleSettings::is_default")]
#[schemars(!skip_serializing_if)]
titles: SessionTitleSettings,
}
#[derive(Default, Serialize, Deserialize, JsonSchema)]
struct InterfaceSettingsSchema {
#[serde(default, skip_serializing_if = "is_default")]
#[schemars(!skip_serializing_if)]
appearance: AppearanceSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(!skip_serializing_if)]
no_color: Option<bool>,
#[serde(default, skip_serializing_if = "is_default")]
tui: InterfaceTuiSettingsSchema,
}
#[derive(Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(transform = tui_schema_defaults)]
struct InterfaceTuiSettingsSchema {
#[serde(flatten)]
settings: super::agent::TuiSettings,
#[serde(default, skip_serializing_if = "is_default")]
autocomplete: AutocompleteSettingsSchema,
}
#[derive(PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(transform = implicit_additional_properties)]
struct AutocompleteSettingsSchema {
#[serde(
default = "default_file_autocomplete_respects_gitignore",
skip_serializing_if = "is_true"
)]
#[schemars(!skip_serializing_if)]
respects_gitignore: bool,
#[serde(default, flatten)]
extra: BTreeMap<String, Value>,
}
impl Default for AutocompleteSettingsSchema {
fn default() -> Self {
Self {
respects_gitignore: default_file_autocomplete_respects_gitignore(),
extra: BTreeMap::new(),
}
}
}
fn omit_schema_default<T>(_: &T) -> bool {
true
}
fn implicit_additional_properties(schema: &mut schemars::Schema) {
schema.remove("additionalProperties");
}
fn subagents_schema_defaults(schema: &mut schemars::Schema) {
implicit_additional_properties(schema);
if let Some(Value::Object(properties)) = schema.get_mut("properties") {
properties["disabled"]["default"] = json!([]);
properties["schema_validation_max_retries"]["default"] = Value::Null;
}
}
fn tui_schema_defaults(schema: &mut schemars::Schema) {
implicit_additional_properties(schema);
if let Some(Value::Object(properties)) = schema.get_mut("properties") {
properties["subagent_card_rows"]["default"] = Value::Null;
}
}
fn is_default<T: Default + PartialEq>(value: &T) -> bool {
*value == T::default()
}
fn is_true(value: &bool) -> bool {
*value
}
impl JsonSchema for Settings {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Settings".into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
SettingsSchema::json_schema(generator)
}
}
impl Serialize for Settings {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let value = canonical_settings_value(self).map_err(serde::ser::Error::custom)?;
value.serialize(serializer)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SettingsDocument {
pub(crate) settings: Settings,
pub(crate) appearance: AppearanceSettings,
}
impl SettingsDocument {
pub(super) fn from_value(value: Value) -> anyhow::Result<Self> {
let normalized = migrate_settings_value(value)?;
Self::from_normalized_value(&normalized)
}
pub(super) fn from_normalized_value(normalized: &Value) -> anyhow::Result<Self> {
let settings = settings_from_normalized_value(normalized)?;
let root = normalized
.as_object()
.ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
let interface = group_object(root, "interface", "interface")?;
let appearance = parse_group(interface, "appearance")?;
Ok(Self {
settings,
appearance,
})
}
}
pub(super) fn session_settings_from_normalized_value(
normalized: &Value,
) -> anyhow::Result<SessionSettings> {
let root = normalized
.as_object()
.ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
let sessions = group_object(root, "sessions", "sessions")?;
if sessions.is_empty() {
Ok(SessionSettings::default())
} else {
serde_json::from_value(Value::Object(sessions.clone())).map_err(Into::into)
}
}
pub(super) fn canonical_settings_value(settings: &Settings) -> anyhow::Result<Value> {
let mut root = Map::new();
root.insert(
"schema_version".to_string(),
Value::Number(SETTINGS_SCHEMA_VERSION.into()),
);
insert_wire_group(
&mut root,
"agent",
&AgentSettingsSchema {
primary_agent: settings.selected_primary_agent.clone(),
model: settings.selected_model.clone(),
fast: settings.fast.clone(),
context: settings.context.clone(),
compaction: settings.compaction.clone(),
summarizer: settings.summarizer.clone(),
subagents: AgentSubagentsSettingsSchema {
settings: settings.subagents.clone(),
execution: settings.tools.subagents.clone(),
},
reminders: settings.ttsr.clone(),
},
)?;
insert_wire_group(
&mut root,
"providers",
&ProvidersSettingsSchema {
openai_codex: settings.openai_codex.clone(),
openai_responses: settings.openai_responses.clone(),
anthropic: AnthropicProviderSettingsSchema {
cache_ttl: settings.anthropic_cache_ttl,
},
custom: settings.custom_providers.clone(),
catalog: settings.models.clone(),
streaming: settings.provider_stream.clone(),
},
)?;
let mut tools = settings.tools.clone();
tools.subagents = SubagentsToolSettings::default();
insert_wire_group(
&mut root,
"capabilities",
&CapabilitiesSettingsSchema {
tools,
mcp: settings.mcp_servers.clone(),
lsp: settings.lsp.clone(),
},
)?;
insert_wire_group(
&mut root,
"knowledge",
&KnowledgeSettingsSchema {
instructions: settings.instructions.clone(),
skills: settings.skills.clone(),
},
)?;
insert_wire_group(
&mut root,
"automation",
&AutomationSettingsSchema {
hooks: settings.hooks.clone(),
integrations: settings.integrations.clone(),
},
)?;
insert_wire_group(
&mut root,
"sessions",
&SessionTitlesWire {
titles: settings.session_titles.clone(),
},
)?;
insert_wire_group(
&mut root,
"interface",
&InterfaceSettingsSchema {
appearance: AppearanceSettings::default(),
no_color: settings.no_color,
tui: {
let mut tui: InterfaceTuiSettingsSchema =
serde_json::from_value(serde_json::to_value(&settings.tui)?)?;
tui.autocomplete.respects_gitignore = settings.file_autocomplete_respects_gitignore;
tui
},
},
)?;
if !settings.tui.is_default() {
let interface = root.entry("interface").or_insert_with(|| json!({}));
interface
.as_object_mut()
.expect("serialized interface is an object")
.entry("tui")
.or_insert_with(|| json!({}));
}
Ok(Value::Object(root))
}
fn insert_wire_group(
root: &mut Map<String, Value>,
key: &str,
group: &impl Serialize,
) -> anyhow::Result<()> {
let value = serde_json::to_value(group)?;
if !value_is_empty_object(&value) {
root.insert(key.to_string(), value);
}
Ok(())
}
pub(crate) fn migrate_settings_value(value: Value) -> anyhow::Result<Value> {
let Some(object) = value.as_object() else {
return Ok(value);
};
match settings_schema_version(object)? {
None | Some(1) | Some(SETTINGS_SCHEMA_VERSION) => migrate_to_v2(value),
Some(version) => anyhow::bail!(
"unsupported settings schema_version {version}; expected 1 or {SETTINGS_SCHEMA_VERSION}"
),
}
}
fn settings_schema_version(object: &Map<String, Value>) -> anyhow::Result<Option<u64>> {
let Some(value) = object.get("schema_version") else {
return Ok(None);
};
let Some(number) = value.as_u64() else {
anyhow::bail!("settings schema_version must be an integer")
};
Ok(Some(number))
}
fn migrate_to_v2(value: Value) -> anyhow::Result<Value> {
let source = value
.as_object()
.ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
let mut target = Map::new();
for (key, value) in source {
if !LEGACY_ROOT_KEYS.contains(&key.as_str()) {
target.insert(key.clone(), value.clone());
}
}
let mut agent = Map::new();
let mut model = source
.get("selected_model")
.cloned()
.unwrap_or_else(|| json!({}));
if let Some(model_object) = model.as_object_mut() {
copy_if_absent(model_object, "provider", source.get("provider"));
copy_if_absent(model_object, "model", source.get("model"));
copy_if_absent(model_object, "thinking_level", source.get("thinking_level"));
}
if source.contains_key("selected_model")
|| source.contains_key("provider")
|| source.contains_key("model")
|| source.contains_key("thinking_level")
{
agent.insert("model".to_string(), model);
}
copy_group_if_present(
&mut agent,
"primary_agent",
source,
"selected_primary_agent",
);
copy_group_if_present(&mut agent, "fast", source, "fast");
copy_group_if_present(&mut agent, "context", source, "context");
copy_group_if_present(&mut agent, "compaction", source, "compaction");
copy_group_if_present(&mut agent, "summarizer", source, "summarizer");
copy_group_if_present(&mut agent, "reminders", source, "ttsr");
copy_group_if_present(&mut agent, "subagents", source, "subagents");
if !agent.is_empty() {
merge_group(&mut target, "agent", Value::Object(agent));
}
let mut providers = Map::new();
copy_group_if_present(&mut providers, "openai_codex", source, "openai_codex");
copy_group_if_present(
&mut providers,
"openai_responses",
source,
"openai_responses",
);
if source.contains_key("anthropic_cache_ttl") {
providers.insert(
"anthropic".to_string(),
json!({"cache_ttl": source["anthropic_cache_ttl"].clone()}),
);
}
copy_group_if_present(&mut providers, "custom", source, "custom_providers");
copy_group_if_present(&mut providers, "catalog", source, "models");
copy_group_if_present(&mut providers, "streaming", source, "provider_stream");
if !providers.is_empty() {
merge_group(&mut target, "providers", Value::Object(providers));
}
let mut capabilities = Map::new();
copy_group_if_present(&mut capabilities, "tools", source, "tools");
copy_group_if_present(&mut capabilities, "mcp", source, "mcp_servers");
copy_group_if_present(&mut capabilities, "lsp", source, "lsp");
if !capabilities.is_empty() {
merge_group(&mut target, "capabilities", Value::Object(capabilities));
}
let mut knowledge = Map::new();
copy_group_if_present(&mut knowledge, "instructions", source, "instructions");
let mut skills = source.get("skills").cloned().unwrap_or_else(|| json!({}));
merge_object_field_if_missing(&mut skills, "disabled", source.get("disabled_skills"));
if source.contains_key("skills") || source.contains_key("disabled_skills") {
knowledge.insert("skills".to_string(), skills);
}
if !knowledge.is_empty() {
merge_group(&mut target, "knowledge", Value::Object(knowledge));
}
let mut automation = Map::new();
copy_group_if_present(&mut automation, "hooks", source, "hooks");
let mut integrations = source
.get("integrations")
.cloned()
.unwrap_or_else(|| json!({}));
merge_object_field_if_missing(&mut integrations, "herdr", source.get("herdr"));
if source.contains_key("integrations") || source.contains_key("herdr") {
automation.insert("integrations".to_string(), integrations);
}
if !automation.is_empty() {
merge_group(&mut target, "automation", Value::Object(automation));
}
let mut sessions = source.get("sessions").cloned().unwrap_or_else(|| json!({}));
merge_object_field_if_missing(&mut sessions, "titles", source.get("session_titles"));
if source.contains_key("sessions") || source.contains_key("session_titles") {
merge_group(&mut target, "sessions", sessions);
}
let mut interface = Map::new();
copy_group_if_present(&mut interface, "appearance", source, "appearance");
copy_group_if_present(&mut interface, "no_color", source, "no_color");
let mut tui = source.get("tui").cloned().unwrap_or_else(|| json!({}));
if source.contains_key("file_autocomplete_respects_gitignore") {
let autocomplete = tui.as_object_mut().map(|object| {
object
.entry("autocomplete".to_string())
.or_insert_with(|| json!({}))
});
if let Some(autocomplete) = autocomplete {
copy_if_absent(
autocomplete
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("tui.autocomplete must be a JSON object"))?,
"respects_gitignore",
source.get("file_autocomplete_respects_gitignore"),
);
}
}
if source.contains_key("tui") || source.contains_key("file_autocomplete_respects_gitignore") {
interface.insert("tui".to_string(), tui);
}
if !interface.is_empty() {
merge_group(&mut target, "interface", Value::Object(interface));
}
let canonical_subagents = source
.get("agent")
.and_then(Value::as_object)
.and_then(|agent| agent.get("subagents"));
normalize_canonical_paths(&mut target, canonical_subagents);
target.insert(
"schema_version".to_string(),
Value::Number(SETTINGS_SCHEMA_VERSION.into()),
);
Ok(Value::Object(target))
}
fn normalize_canonical_paths(root: &mut Map<String, Value>, canonical_subagents: Option<&Value>) {
let mut execution = None;
if let Some(capabilities) = root.get_mut("capabilities").and_then(Value::as_object_mut)
&& let Some(tools) = capabilities.get_mut("tools").and_then(Value::as_object_mut)
{
execution = normalize_tool_aliases(tools);
}
let Some(execution) = execution else {
return;
};
if !root.contains_key("agent") {
root.insert(
"agent".to_string(),
json!({"subagents": {"execution": execution}}),
);
return;
}
let Some(agent) = root.get_mut("agent").and_then(Value::as_object_mut) else {
return;
};
if agent.is_empty()
|| canonical_subagents.is_some_and(|value| value.as_object().is_some_and(Map::is_empty))
|| canonical_subagents.is_some_and(|value| !value.is_object())
{
return;
}
let subagents = agent
.entry("subagents".to_string())
.or_insert_with(|| json!({}));
let Some(subagents) = subagents.as_object_mut() else {
return;
};
match subagents.get_mut("execution") {
Some(existing) => merge_missing(existing, &execution),
None => {
subagents.insert("execution".to_string(), execution);
}
}
}
fn merge_group(target: &mut Map<String, Value>, key: &str, value: Value) {
if let Some(existing) = target.get_mut(key) {
merge_missing(existing, &value);
} else {
target.insert(key.to_string(), value);
}
}
fn merge_missing(target: &mut Value, source: &Value) {
let (Value::Object(target), Value::Object(source)) = (target, source) else {
return;
};
if target.is_empty() {
return;
}
for (key, value) in source {
match target.get_mut(key) {
Some(existing) => merge_missing(existing, value),
None => {
target.insert(key.clone(), value.clone());
}
}
}
}
fn copy_group_if_present(
target: &mut Map<String, Value>,
target_key: &str,
source: &Map<String, Value>,
source_key: &str,
) {
if let Some(value) = source.get(source_key) {
target.insert(target_key.to_string(), value.clone());
}
}
fn normalize_tool_aliases(tools: &mut Map<String, Value>) -> Option<Value> {
merge_tool_alias(tools, "grep", "ffgrep");
merge_tool_alias(tools, "find", "fffind");
take_tool_alias(tools, "subagents", "parallel_subagents")
}
fn merge_tool_alias(tools: &mut Map<String, Value>, canonical: &str, alias: &str) {
let Some(alias_value) = tools.remove(alias) else {
return;
};
if let Some(canonical_value) = tools.get_mut(canonical) {
merge_missing(canonical_value, &alias_value);
} else {
tools.insert(canonical.to_string(), alias_value);
}
}
fn take_tool_alias(tools: &mut Map<String, Value>, canonical: &str, alias: &str) -> Option<Value> {
let canonical_value = tools.remove(canonical);
let alias_value = tools.remove(alias);
match (canonical_value, alias_value) {
(Some(mut canonical_value), Some(alias_value)) => {
merge_missing(&mut canonical_value, &alias_value);
Some(canonical_value)
}
(Some(canonical_value), None) | (None, Some(canonical_value)) => Some(canonical_value),
(None, None) => None,
}
}
fn merge_object_field_if_missing(target: &mut Value, key: &str, source: Option<&Value>) {
let Some(source) = source else {
return;
};
let Some(target) = target.as_object_mut() else {
return;
};
match target.get_mut(key) {
Some(existing) => merge_missing(existing, source),
None => {
target.insert(key.to_string(), source.clone());
}
}
}
fn copy_if_absent(target: &mut Map<String, Value>, key: &str, value: Option<&Value>) {
if !target.contains_key(key)
&& let Some(value) = value
{
target.insert(key.to_string(), value.clone());
}
}
fn settings_from_normalized_value(value: &Value) -> anyhow::Result<Settings> {
let root = value
.as_object()
.ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
for key in [
"agent",
"providers",
"capabilities",
"knowledge",
"automation",
"sessions",
"interface",
] {
group_object(root, key, key)?;
}
let providers_group = group_object(root, "providers", "providers")?;
group_object(providers_group, "anthropic", "providers.anthropic")?;
let interface_group = group_object(root, "interface", "interface")?;
let tui_group = group_object(interface_group, "tui", "interface.tui")?;
group_object(tui_group, "autocomplete", "interface.tui.autocomplete")?;
let agent: AgentSettingsSchema = parse_group(root, "agent")?;
let providers: ProvidersSettingsSchema = parse_group(root, "providers")?;
let capabilities: CapabilitiesSettingsSchema = parse_group(root, "capabilities")?;
let knowledge: KnowledgeSettingsSchema = parse_group(root, "knowledge")?;
let automation: AutomationSettingsSchema = parse_group(root, "automation")?;
let sessions: SessionTitlesWire = parse_group(root, "sessions")?;
let interface: InterfaceSettingsSchema = parse_group(root, "interface")?;
let mut tools = capabilities.tools;
tools.subagents = agent.subagents.execution;
let settings = Settings {
selected_model: agent.model,
fast: agent.fast,
openai_codex: providers.openai_codex,
openai_responses: providers.openai_responses,
no_color: interface.no_color,
anthropic_cache_ttl: providers.anthropic.cache_ttl,
file_autocomplete_respects_gitignore: interface.tui.autocomplete.respects_gitignore,
context: agent.context,
session_titles: sessions.titles,
compaction: agent.compaction,
summarizer: agent.summarizer,
tools,
subagents: agent.subagents.settings,
models: providers.catalog,
hooks: automation.hooks,
instructions: knowledge.instructions,
skills: knowledge.skills,
custom_providers: providers.custom,
mcp_servers: capabilities.mcp,
lsp: capabilities.lsp,
integrations: automation.integrations,
tui: parse_group(group_object(root, "interface", "interface")?, "tui")?,
provider_stream: providers.streaming,
ttsr: agent.reminders,
selected_primary_agent: agent.primary_agent,
};
Ok(settings)
}
fn group_object<'a>(
parent: &'a Map<String, Value>,
key: &str,
path: &str,
) -> anyhow::Result<&'a Map<String, Value>> {
match parent.get(key) {
None => Ok(empty_object()),
Some(value) => value
.as_object()
.ok_or_else(|| anyhow::anyhow!("{path} must be a JSON object")),
}
}
fn empty_object() -> &'static Map<String, Value> {
static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
EMPTY.get_or_init(Map::new)
}
fn parse_group<T>(parent: &Map<String, Value>, key: &str) -> anyhow::Result<T>
where
T: DeserializeOwned + Default,
{
parent
.get(key)
.map(|value| serde_json::from_value(value.clone()))
.transpose()?
.map_or_else(|| Ok(T::default()), Ok)
}
fn value_is_empty_object(value: &Value) -> bool {
value.as_object().is_some_and(Map::is_empty)
}
impl<'de> Deserialize<'de> for Settings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
SettingsDocument::from_value(value)
.map(|document| document.settings)
.map_err(de::Error::custom)
}
}