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,
tools::{SubagentsToolSettings, ToolSettings},
};
use crate::config::{CustomProviderConfig, HookSettings};
pub(crate) const SETTINGS_SCHEMA_VERSION: u64 = 2;
#[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_approvals: BTreeMap<String, BTreeMap<String, bool>>,
#[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 settings = settings_from_value(value)?;
let root = value
.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_value(value: &Value) -> anyhow::Result<SessionSettings> {
let root = value
.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_approvals: settings.mcp_approvals.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 validate_settings_schema_version(value: &Value) -> anyhow::Result<()> {
let object = value
.as_object()
.ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
match settings_schema_version(object)? {
None | Some(SETTINGS_SCHEMA_VERSION) => Ok(()),
Some(version) => anyhow::bail!(
"unsupported settings schema_version {version}; expected {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 settings_from_value(value: &Value) -> anyhow::Result<Settings> {
validate_settings_schema_version(value)?;
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: BTreeMap::new(),
mcp_approvals: capabilities.mcp_approvals,
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)
}
}