1use schemars::JsonSchema;
2use serde::{
3 Deserialize, Serialize,
4 de::{self, DeserializeOwned},
5 ser::Serializer,
6};
7use serde_json::{Map, Value, json};
8use std::collections::BTreeMap;
9
10use super::{
11 agent::{
12 CompactionSettings, InstructionsSettings, IntegrationsSettings, ModelsSettings,
13 SessionTitleSettings, SkillsSettings,
14 },
15 core::{
16 AnthropicCacheTtl, AppearanceSettings, FastSettings, JevSettings, OpenAiCodexSettings,
17 OpenAiResponsesSettings, SessionSettings, Settings,
18 },
19 helpers::default_file_autocomplete_respects_gitignore,
20 providers::{ProviderStreamSettings, SelectedModelSettings},
21 services::LspSettings,
22 tools::{SubagentsToolSettings, ToolSettings},
23};
24use crate::config::{CustomProviderConfig, HookSettings};
25
26pub(crate) const SETTINGS_SCHEMA_VERSION: u64 = 2;
27
28fn default_session_retention_days() -> u64 {
29 super::core::DEFAULT_SESSION_RETENTION_DAYS
30}
31
32#[expect(dead_code)]
33#[derive(Default, JsonSchema)]
34struct SettingsSchema {
35 #[schemars(range(min = 2, max = 2))]
36 pub schema_version: u64,
37 #[serde(default)]
38 #[schemars(skip_serializing_if = "omit_schema_default")]
39 pub agent: AgentSettingsSchema,
40 #[serde(default)]
41 #[schemars(skip_serializing_if = "omit_schema_default")]
42 pub providers: ProvidersSettingsSchema,
43 #[serde(default)]
44 #[schemars(skip_serializing_if = "omit_schema_default")]
45 pub capabilities: CapabilitiesSettingsSchema,
46 #[serde(default)]
47 #[schemars(skip_serializing_if = "omit_schema_default")]
48 pub knowledge: KnowledgeSettingsSchema,
49 #[serde(default)]
50 #[schemars(skip_serializing_if = "omit_schema_default")]
51 pub automation: AutomationSettingsSchema,
52 #[serde(default)]
53 #[schemars(skip_serializing_if = "omit_schema_default")]
54 pub internal_tooling: InternalToolingSettingsSchema,
55 #[serde(default)]
56 pub sessions: SessionsSettingsSchema,
57 #[serde(default)]
58 #[schemars(skip_serializing_if = "omit_schema_default")]
59 pub interface: InterfaceSettingsSchema,
60}
61
62#[derive(Default, Serialize, Deserialize, JsonSchema)]
64struct AgentSettingsSchema {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 #[schemars(!skip_serializing_if)]
67 primary_agent: Option<String>,
68 #[serde(default, skip_serializing_if = "SelectedModelSettings::is_default")]
69 #[schemars(!skip_serializing_if)]
70 model: SelectedModelSettings,
71 #[serde(default, skip_serializing_if = "FastSettings::is_default")]
72 #[schemars(!skip_serializing_if)]
73 fast: FastSettings,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 #[schemars(!skip_serializing_if)]
76 context: Option<crate::context::ContextBudget>,
77 #[serde(default, skip_serializing_if = "CompactionSettings::is_default")]
78 #[schemars(!skip_serializing_if)]
79 compaction: CompactionSettings,
80 #[serde(
81 default,
82 skip_serializing_if = "super::agent::SummarizerSettings::is_default"
83 )]
84 #[schemars(!skip_serializing_if)]
85 summarizer: super::agent::SummarizerSettings,
86 #[serde(
87 default,
88 skip_serializing_if = "super::agent::SideAgentSettings::is_default"
89 )]
90 #[schemars(!skip_serializing_if)]
91 side: super::agent::SideAgentSettings,
92 #[serde(
93 default,
94 skip_serializing_if = "AgentSubagentsSettingsSchema::is_default"
95 )]
96 subagents: AgentSubagentsSettingsSchema,
97}
98
99#[derive(Default, Serialize, Deserialize, JsonSchema)]
100#[schemars(transform = subagents_schema_defaults)]
101struct AgentSubagentsSettingsSchema {
102 #[serde(flatten)]
103 settings: super::agent::SubagentsSettings,
104 #[serde(default, skip_serializing_if = "SubagentsToolSettings::is_default")]
105 #[schemars(!skip_serializing_if)]
106 execution: SubagentsToolSettings,
107}
108
109impl AgentSubagentsSettingsSchema {
110 fn is_default(&self) -> bool {
111 self.settings == super::agent::SubagentsSettings::default() && self.execution.is_default()
112 }
113}
114
115#[derive(Default, Serialize, Deserialize, JsonSchema)]
116struct ProvidersSettingsSchema {
117 #[serde(default, skip_serializing_if = "OpenAiCodexSettings::is_default")]
118 #[schemars(!skip_serializing_if)]
119 openai_codex: OpenAiCodexSettings,
120 #[serde(default, skip_serializing_if = "OpenAiResponsesSettings::is_default")]
121 #[schemars(!skip_serializing_if)]
122 openai_responses: OpenAiResponsesSettings,
123 #[serde(
124 default,
125 skip_serializing_if = "AnthropicProviderSettingsSchema::is_default"
126 )]
127 anthropic: AnthropicProviderSettingsSchema,
128 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
129 #[schemars(!skip_serializing_if)]
130 custom: BTreeMap<String, CustomProviderConfig>,
131 #[serde(default, skip_serializing_if = "ModelsSettings::is_default")]
132 #[schemars(!skip_serializing_if)]
133 catalog: ModelsSettings,
134 #[serde(default, skip_serializing_if = "ProviderStreamSettings::is_default")]
135 #[schemars(!skip_serializing_if)]
136 streaming: ProviderStreamSettings,
137}
138
139#[derive(Default, Serialize, Deserialize, JsonSchema)]
140struct AnthropicProviderSettingsSchema {
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 #[schemars(!skip_serializing_if)]
143 cache_ttl: Option<AnthropicCacheTtl>,
144}
145
146impl AnthropicProviderSettingsSchema {
147 fn is_default(&self) -> bool {
148 self.cache_ttl.is_none()
149 }
150}
151
152#[derive(Default, Serialize, Deserialize, JsonSchema)]
153struct InternalToolingSettingsSchema {
154 #[serde(default, skip_serializing_if = "JevSettings::is_default")]
155 #[schemars(!skip_serializing_if)]
156 jev: JevSettings,
157}
158
159#[derive(Default, Serialize, Deserialize, JsonSchema)]
160struct CapabilitiesSettingsSchema {
161 #[serde(default, skip_serializing_if = "is_default")]
162 #[schemars(with = "ToolCapabilitiesSettingsSchema")]
163 tools: ToolSettings,
164 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
165 #[schemars(!skip_serializing_if)]
166 mcp_approvals: BTreeMap<String, BTreeMap<String, bool>>,
167 #[serde(default, skip_serializing_if = "LspSettings::is_default")]
168 #[schemars(!skip_serializing_if)]
169 lsp: LspSettings,
170}
171
172struct ToolCapabilitiesSettingsSchema;
174
175impl JsonSchema for ToolCapabilitiesSettingsSchema {
176 fn schema_name() -> std::borrow::Cow<'static, str> {
177 "ToolCapabilitiesSettingsSchema".into()
178 }
179
180 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
181 let mut schema = ToolSettings::json_schema(generator);
182 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
183 properties.remove("subagents");
184 }
185 schema
186 }
187}
188
189#[derive(Default, Serialize, Deserialize, JsonSchema)]
190struct KnowledgeSettingsSchema {
191 #[serde(default, skip_serializing_if = "InstructionsSettings::is_default")]
192 #[schemars(!skip_serializing_if)]
193 instructions: InstructionsSettings,
194 #[serde(default, skip_serializing_if = "SkillsSettings::is_default")]
195 #[schemars(!skip_serializing_if)]
196 skills: SkillsSettings,
197}
198
199#[derive(Default, Serialize, Deserialize, JsonSchema)]
200struct AutomationSettingsSchema {
201 #[serde(default, skip_serializing_if = "HookSettings::is_default")]
202 #[schemars(!skip_serializing_if)]
203 hooks: HookSettings,
204 #[serde(default, skip_serializing_if = "IntegrationsSettings::is_default")]
205 #[schemars(!skip_serializing_if)]
206 integrations: IntegrationsSettings,
207}
208
209#[expect(dead_code)]
210#[derive(Default, JsonSchema)]
211struct SessionsSettingsSchema {
212 #[serde(default = "default_session_retention_days")]
213 pub retention_days: u64,
214 #[serde(flatten)]
215 titles: SessionTitlesWire,
216}
217
218#[derive(Default, Serialize, Deserialize, JsonSchema)]
220struct SessionTitlesWire {
221 #[serde(default, skip_serializing_if = "SessionTitleSettings::is_default")]
222 #[schemars(!skip_serializing_if)]
223 titles: SessionTitleSettings,
224}
225
226#[derive(Default, Serialize, Deserialize, JsonSchema)]
227struct InterfaceSettingsSchema {
228 #[serde(default, skip_serializing_if = "is_default")]
229 #[schemars(!skip_serializing_if)]
230 appearance: AppearanceSettings,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 #[schemars(!skip_serializing_if)]
233 no_color: Option<bool>,
234 #[serde(default, skip_serializing_if = "is_default")]
235 tui: InterfaceTuiSettingsSchema,
236}
237
238#[derive(Default, PartialEq, Serialize, Deserialize, JsonSchema)]
239#[schemars(transform = tui_schema_defaults)]
240struct InterfaceTuiSettingsSchema {
241 #[serde(flatten)]
242 settings: super::agent::TuiSettings,
243 #[serde(default, skip_serializing_if = "is_default")]
244 autocomplete: AutocompleteSettingsSchema,
245}
246
247#[derive(PartialEq, Serialize, Deserialize, JsonSchema)]
248#[schemars(transform = implicit_additional_properties)]
249struct AutocompleteSettingsSchema {
250 #[serde(
251 default = "default_file_autocomplete_respects_gitignore",
252 skip_serializing_if = "is_true"
253 )]
254 #[schemars(!skip_serializing_if)]
255 respects_gitignore: bool,
256 #[serde(default, flatten)]
257 extra: BTreeMap<String, Value>,
258}
259
260impl Default for AutocompleteSettingsSchema {
261 fn default() -> Self {
262 Self {
263 respects_gitignore: default_file_autocomplete_respects_gitignore(),
264 extra: BTreeMap::new(),
265 }
266 }
267}
268
269fn omit_schema_default<T>(_: &T) -> bool {
271 true
272}
273
274fn implicit_additional_properties(schema: &mut schemars::Schema) {
275 schema.remove("additionalProperties");
276}
277
278fn subagents_schema_defaults(schema: &mut schemars::Schema) {
279 implicit_additional_properties(schema);
280 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
281 properties["disabled"]["default"] = json!([]);
282 properties["schema_validation_max_retries"]["default"] = Value::Null;
283 }
284}
285
286fn tui_schema_defaults(schema: &mut schemars::Schema) {
287 implicit_additional_properties(schema);
288 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
289 properties["subagent_card_rows"]["default"] = Value::Null;
290 }
291}
292
293fn is_default<T: Default + PartialEq>(value: &T) -> bool {
294 *value == T::default()
295}
296
297fn is_true(value: &bool) -> bool {
298 *value
299}
300
301impl JsonSchema for Settings {
302 fn schema_name() -> std::borrow::Cow<'static, str> {
303 "Settings".into()
304 }
305
306 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
307 SettingsSchema::json_schema(generator)
308 }
309}
310
311impl Serialize for Settings {
312 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
313 where
314 S: Serializer,
315 {
316 let value = canonical_settings_value(self).map_err(serde::ser::Error::custom)?;
317 value.serialize(serializer)
318 }
319}
320
321#[derive(Debug, Clone, PartialEq)]
322pub(crate) struct SettingsDocument {
323 pub(crate) settings: Settings,
324 pub(crate) appearance: AppearanceSettings,
325}
326
327impl SettingsDocument {
328 pub(super) fn from_value(value: &Value) -> anyhow::Result<Self> {
329 let settings = settings_from_value(value)?;
330 let root = value
331 .as_object()
332 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
333 let interface = group_object(root, "interface", "interface")?;
334 let appearance = parse_group(interface, "interface.appearance")?;
335 Ok(Self {
336 settings,
337 appearance,
338 })
339 }
340}
341
342pub(super) fn session_settings_from_value(value: &Value) -> anyhow::Result<SessionSettings> {
343 let root = value
344 .as_object()
345 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
346 let sessions = group_object(root, "sessions", "sessions")?;
347 if sessions.is_empty() {
348 Ok(SessionSettings::default())
349 } else {
350 serde_json::from_value(Value::Object(sessions.clone())).map_err(Into::into)
351 }
352}
353
354pub(super) fn canonical_settings_value(settings: &Settings) -> anyhow::Result<Value> {
355 let mut root = Map::new();
356 root.insert(
357 "schema_version".to_string(),
358 Value::Number(SETTINGS_SCHEMA_VERSION.into()),
359 );
360
361 insert_wire_group(
362 &mut root,
363 "agent",
364 &AgentSettingsSchema {
365 primary_agent: settings.selected_primary_agent.clone(),
366 model: settings.selected_model.clone(),
367 fast: settings.fast.clone(),
368 context: settings.context.clone(),
369 compaction: settings.compaction.clone(),
370 summarizer: settings.summarizer.clone(),
371 side: settings.side.clone(),
372 subagents: AgentSubagentsSettingsSchema {
373 settings: settings.subagents.clone(),
374 execution: settings.tools.subagents.clone(),
375 },
376 },
377 )?;
378 insert_wire_group(
379 &mut root,
380 "providers",
381 &ProvidersSettingsSchema {
382 openai_codex: settings.openai_codex.clone(),
383 openai_responses: settings.openai_responses.clone(),
384 anthropic: AnthropicProviderSettingsSchema {
385 cache_ttl: settings.anthropic_cache_ttl,
386 },
387 custom: settings.custom_providers.clone(),
388 catalog: settings.models.clone(),
389 streaming: settings.provider_stream.clone(),
390 },
391 )?;
392
393 let mut tools = settings.tools.clone();
394 tools.subagents = SubagentsToolSettings::default();
395 insert_wire_group(
396 &mut root,
397 "capabilities",
398 &CapabilitiesSettingsSchema {
399 tools,
400 mcp_approvals: settings.mcp_approvals.clone(),
401 lsp: settings.lsp.clone(),
402 },
403 )?;
404
405 insert_wire_group(
406 &mut root,
407 "internal_tooling",
408 &InternalToolingSettingsSchema {
409 jev: settings.jev.clone(),
410 },
411 )?;
412
413 insert_wire_group(
414 &mut root,
415 "knowledge",
416 &KnowledgeSettingsSchema {
417 instructions: settings.instructions.clone(),
418 skills: settings.skills.clone(),
419 },
420 )?;
421 insert_wire_group(
422 &mut root,
423 "automation",
424 &AutomationSettingsSchema {
425 hooks: settings.hooks.clone(),
426 integrations: settings.integrations.clone(),
427 },
428 )?;
429
430 insert_wire_group(
431 &mut root,
432 "sessions",
433 &SessionTitlesWire {
434 titles: settings.session_titles.clone(),
435 },
436 )?;
437
438 insert_wire_group(
439 &mut root,
440 "interface",
441 &InterfaceSettingsSchema {
442 appearance: AppearanceSettings::default(),
443 no_color: settings.no_color,
444 tui: {
445 let mut tui: InterfaceTuiSettingsSchema =
446 serde_json::from_value(serde_json::to_value(&settings.tui)?)?;
447 tui.autocomplete.respects_gitignore = settings.file_autocomplete_respects_gitignore;
448 tui
449 },
450 },
451 )?;
452 if !settings.tui.is_default() {
455 let interface = root.entry("interface").or_insert_with(|| json!({}));
456 interface
457 .as_object_mut()
458 .expect("serialized interface is an object")
459 .entry("tui")
460 .or_insert_with(|| json!({}));
461 }
462
463 Ok(Value::Object(root))
464}
465
466fn insert_wire_group(
467 root: &mut Map<String, Value>,
468 key: &str,
469 group: &impl Serialize,
470) -> anyhow::Result<()> {
471 let value = serde_json::to_value(group)?;
472 if !value_is_empty_object(&value) {
473 root.insert(key.to_string(), value);
474 }
475 Ok(())
476}
477
478pub(crate) fn validate_settings_schema_version(value: &Value) -> anyhow::Result<()> {
479 let object = value
480 .as_object()
481 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
482 match settings_schema_version(object)? {
483 None | Some(SETTINGS_SCHEMA_VERSION) => Ok(()),
484 Some(version) => anyhow::bail!(
485 "unsupported settings schema_version {version}; expected {SETTINGS_SCHEMA_VERSION}"
486 ),
487 }
488}
489
490fn settings_schema_version(object: &Map<String, Value>) -> anyhow::Result<Option<u64>> {
491 let Some(value) = object.get("schema_version") else {
492 return Ok(None);
493 };
494 let Some(number) = value.as_u64() else {
495 anyhow::bail!("settings schema_version must be an integer")
496 };
497 Ok(Some(number))
498}
499
500fn settings_from_value(value: &Value) -> anyhow::Result<Settings> {
501 validate_settings_schema_version(value)?;
502 let root = value
503 .as_object()
504 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
505 for key in [
507 "agent",
508 "providers",
509 "capabilities",
510 "knowledge",
511 "automation",
512 "sessions",
513 "interface",
514 ] {
515 group_object(root, key, key)?;
516 }
517 let providers_group = group_object(root, "providers", "providers")?;
518 group_object(providers_group, "anthropic", "providers.anthropic")?;
519 let interface_group = group_object(root, "interface", "interface")?;
520 let tui_group = group_object(interface_group, "tui", "interface.tui")?;
521 group_object(tui_group, "autocomplete", "interface.tui.autocomplete")?;
522 group_object(root, "internal_tooling", "internal_tooling")?;
523 let agent: AgentSettingsSchema = parse_group(root, "agent")?;
524 let providers: ProvidersSettingsSchema = parse_group(root, "providers")?;
525 let capabilities: CapabilitiesSettingsSchema = parse_group(root, "capabilities")?;
526 let knowledge: KnowledgeSettingsSchema = parse_group(root, "knowledge")?;
527 let automation: AutomationSettingsSchema = parse_group(root, "automation")?;
528 let sessions: SessionTitlesWire = parse_group(root, "sessions")?;
529 let interface: InterfaceSettingsSchema = parse_group(root, "interface")?;
530
531 let internal_tooling: InternalToolingSettingsSchema = parse_group(root, "internal_tooling")?;
532 let mut tools = capabilities.tools;
533 tools.subagents = agent.subagents.execution;
534
535 let settings = Settings {
536 selected_model: agent.model,
537 fast: agent.fast,
538 openai_codex: providers.openai_codex,
539 openai_responses: providers.openai_responses,
540 no_color: interface.no_color,
541 anthropic_cache_ttl: providers.anthropic.cache_ttl,
542 file_autocomplete_respects_gitignore: interface.tui.autocomplete.respects_gitignore,
543 context: agent.context,
544 session_titles: sessions.titles,
545 compaction: agent.compaction,
546 summarizer: agent.summarizer,
547 side: agent.side,
548 tools,
549 subagents: agent.subagents.settings,
550 models: providers.catalog,
551 hooks: automation.hooks,
552 instructions: knowledge.instructions,
553 skills: knowledge.skills,
554 custom_providers: providers.custom,
555 mcp_servers: BTreeMap::new(),
556 mcp_approvals: capabilities.mcp_approvals,
557 lsp: capabilities.lsp,
558 integrations: automation.integrations,
559 tui: parse_group(
560 group_object(root, "interface", "interface")?,
561 "interface.tui",
562 )?,
563 provider_stream: providers.streaming,
564 selected_primary_agent: agent.primary_agent,
565 jev: internal_tooling.jev,
566 };
567 Ok(settings)
568}
569
570fn group_object<'a>(
571 parent: &'a Map<String, Value>,
572 key: &str,
573 path: &str,
574) -> anyhow::Result<&'a Map<String, Value>> {
575 match parent.get(key) {
576 None => Ok(empty_object()),
577 Some(value) => value
578 .as_object()
579 .ok_or_else(|| anyhow::anyhow!("{path} must be a JSON object")),
580 }
581}
582
583fn empty_object() -> &'static Map<String, Value> {
584 static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
585 EMPTY.get_or_init(Map::new)
586}
587
588fn parse_group<T>(parent: &Map<String, Value>, path: &str) -> anyhow::Result<T>
590where
591 T: DeserializeOwned + Default,
592{
593 let key = path.rsplit('.').next().unwrap_or(path);
594 let Some(value) = parent.get(key) else {
595 return Ok(T::default());
596 };
597 serde_path_to_error::deserialize(value.clone()).map_err(|error| {
598 let inner_path = error.path().to_string();
599 let location = if inner_path == "." {
600 path.to_string()
601 } else {
602 format!("{path}.{inner_path}")
603 };
604 anyhow::anyhow!("settings {location}: {}", error.into_inner())
605 })
606}
607
608fn value_is_empty_object(value: &Value) -> bool {
609 value.as_object().is_some_and(Map::is_empty)
610}
611
612impl<'de> Deserialize<'de> for Settings {
613 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
614 where
615 D: serde::Deserializer<'de>,
616 {
617 let value = Value::deserialize(deserializer)?;
618 SettingsDocument::from_value(&value)
619 .map(|document| document.settings)
620 .map_err(de::Error::custom)
621 }
622}