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, default_anthropic_cache_ttl,
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(Serialize, Deserialize, JsonSchema)]
140struct AnthropicProviderSettingsSchema {
141 #[serde(default = "default_anthropic_cache_ttl")]
142 cache_ttl: Option<AnthropicCacheTtl>,
143}
144
145impl Default for AnthropicProviderSettingsSchema {
146 fn default() -> Self {
147 Self {
148 cache_ttl: default_anthropic_cache_ttl(),
149 }
150 }
151}
152
153impl AnthropicProviderSettingsSchema {
154 fn is_default(&self) -> bool {
155 self.cache_ttl == default_anthropic_cache_ttl()
156 }
157}
158
159#[derive(Default, Serialize, Deserialize, JsonSchema)]
160struct InternalToolingSettingsSchema {
161 #[serde(default, skip_serializing_if = "JevSettings::is_default")]
162 #[schemars(!skip_serializing_if)]
163 jev: JevSettings,
164}
165
166#[derive(Default, Serialize, Deserialize, JsonSchema)]
167struct CapabilitiesSettingsSchema {
168 #[serde(default, skip_serializing_if = "is_default")]
169 #[schemars(with = "ToolCapabilitiesSettingsSchema")]
170 tools: ToolSettings,
171 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172 #[schemars(!skip_serializing_if)]
173 mcp_approvals: BTreeMap<String, BTreeMap<String, bool>>,
174 #[serde(default, skip_serializing_if = "LspSettings::is_default")]
175 #[schemars(!skip_serializing_if)]
176 lsp: LspSettings,
177}
178
179struct ToolCapabilitiesSettingsSchema;
181
182impl JsonSchema for ToolCapabilitiesSettingsSchema {
183 fn schema_name() -> std::borrow::Cow<'static, str> {
184 "ToolCapabilitiesSettingsSchema".into()
185 }
186
187 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
188 let mut schema = ToolSettings::json_schema(generator);
189 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
190 properties.remove("subagents");
191 }
192 schema
193 }
194}
195
196#[derive(Default, Serialize, Deserialize, JsonSchema)]
197struct KnowledgeSettingsSchema {
198 #[serde(default, skip_serializing_if = "InstructionsSettings::is_default")]
199 #[schemars(!skip_serializing_if)]
200 instructions: InstructionsSettings,
201 #[serde(default, skip_serializing_if = "SkillsSettings::is_default")]
202 #[schemars(!skip_serializing_if)]
203 skills: SkillsSettings,
204}
205
206#[derive(Default, Serialize, Deserialize, JsonSchema)]
207struct AutomationSettingsSchema {
208 #[serde(default, skip_serializing_if = "HookSettings::is_default")]
209 #[schemars(!skip_serializing_if)]
210 hooks: HookSettings,
211 #[serde(default, skip_serializing_if = "IntegrationsSettings::is_default")]
212 #[schemars(!skip_serializing_if)]
213 integrations: IntegrationsSettings,
214}
215
216#[expect(dead_code)]
217#[derive(Default, JsonSchema)]
218struct SessionsSettingsSchema {
219 #[serde(default = "default_session_retention_days")]
220 pub retention_days: u64,
221 #[serde(flatten)]
222 titles: SessionTitlesWire,
223}
224
225#[derive(Default, Serialize, Deserialize, JsonSchema)]
227struct SessionTitlesWire {
228 #[serde(default, skip_serializing_if = "SessionTitleSettings::is_default")]
229 #[schemars(!skip_serializing_if)]
230 titles: SessionTitleSettings,
231}
232
233#[derive(Default, Serialize, Deserialize, JsonSchema)]
234struct InterfaceSettingsSchema {
235 #[serde(default, skip_serializing_if = "is_default")]
236 #[schemars(!skip_serializing_if)]
237 appearance: AppearanceSettings,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 #[schemars(!skip_serializing_if)]
240 no_color: Option<bool>,
241 #[serde(default, skip_serializing_if = "is_default")]
242 tui: InterfaceTuiSettingsSchema,
243}
244
245#[derive(Default, PartialEq, Serialize, Deserialize, JsonSchema)]
246#[schemars(transform = tui_schema_defaults)]
247struct InterfaceTuiSettingsSchema {
248 #[serde(flatten)]
249 settings: super::agent::TuiSettings,
250 #[serde(default, skip_serializing_if = "is_default")]
251 autocomplete: AutocompleteSettingsSchema,
252}
253
254#[derive(PartialEq, Serialize, Deserialize, JsonSchema)]
255#[schemars(transform = implicit_additional_properties)]
256struct AutocompleteSettingsSchema {
257 #[serde(
258 default = "default_file_autocomplete_respects_gitignore",
259 skip_serializing_if = "is_true"
260 )]
261 #[schemars(!skip_serializing_if)]
262 respects_gitignore: bool,
263 #[serde(default, flatten)]
264 extra: BTreeMap<String, Value>,
265}
266
267impl Default for AutocompleteSettingsSchema {
268 fn default() -> Self {
269 Self {
270 respects_gitignore: default_file_autocomplete_respects_gitignore(),
271 extra: BTreeMap::new(),
272 }
273 }
274}
275
276fn omit_schema_default<T>(_: &T) -> bool {
278 true
279}
280
281fn implicit_additional_properties(schema: &mut schemars::Schema) {
282 schema.remove("additionalProperties");
283}
284
285fn subagents_schema_defaults(schema: &mut schemars::Schema) {
286 implicit_additional_properties(schema);
287 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
288 properties["disabled"]["default"] = json!([]);
289 properties["schema_validation_max_retries"]["default"] = Value::Null;
290 }
291}
292
293fn tui_schema_defaults(schema: &mut schemars::Schema) {
294 implicit_additional_properties(schema);
295 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
296 properties["subagent_card_rows"]["default"] = Value::Null;
297 }
298}
299
300fn is_default<T: Default + PartialEq>(value: &T) -> bool {
301 *value == T::default()
302}
303
304fn is_true(value: &bool) -> bool {
305 *value
306}
307
308impl JsonSchema for Settings {
309 fn schema_name() -> std::borrow::Cow<'static, str> {
310 "Settings".into()
311 }
312
313 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
314 SettingsSchema::json_schema(generator)
315 }
316}
317
318impl Serialize for Settings {
319 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
320 where
321 S: Serializer,
322 {
323 let value = canonical_settings_value(self).map_err(serde::ser::Error::custom)?;
324 value.serialize(serializer)
325 }
326}
327
328#[derive(Debug, Clone, PartialEq)]
329pub(crate) struct SettingsDocument {
330 pub(crate) settings: Settings,
331 pub(crate) appearance: AppearanceSettings,
332}
333
334impl SettingsDocument {
335 pub(super) fn from_value(value: &Value) -> anyhow::Result<Self> {
336 let settings = settings_from_value(value)?;
337 let root = value
338 .as_object()
339 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
340 let interface = group_object(root, "interface", "interface")?;
341 let appearance = parse_group(interface, "interface.appearance")?;
342 Ok(Self {
343 settings,
344 appearance,
345 })
346 }
347}
348
349pub(super) fn session_settings_from_value(value: &Value) -> anyhow::Result<SessionSettings> {
350 let root = value
351 .as_object()
352 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
353 let sessions = group_object(root, "sessions", "sessions")?;
354 if sessions.is_empty() {
355 Ok(SessionSettings::default())
356 } else {
357 serde_json::from_value(Value::Object(sessions.clone())).map_err(Into::into)
358 }
359}
360
361pub(super) fn canonical_settings_value(settings: &Settings) -> anyhow::Result<Value> {
362 let mut root = Map::new();
363 root.insert(
364 "schema_version".to_string(),
365 Value::Number(SETTINGS_SCHEMA_VERSION.into()),
366 );
367
368 insert_wire_group(
369 &mut root,
370 "agent",
371 &AgentSettingsSchema {
372 primary_agent: settings.selected_primary_agent.clone(),
373 model: settings.selected_model.clone(),
374 fast: settings.fast.clone(),
375 context: settings.context.clone(),
376 compaction: settings.compaction.clone(),
377 summarizer: settings.summarizer.clone(),
378 side: settings.side.clone(),
379 subagents: AgentSubagentsSettingsSchema {
380 settings: settings.subagents.clone(),
381 execution: settings.tools.subagents.clone(),
382 },
383 },
384 )?;
385 insert_wire_group(
386 &mut root,
387 "providers",
388 &ProvidersSettingsSchema {
389 openai_codex: settings.openai_codex.clone(),
390 openai_responses: settings.openai_responses.clone(),
391 anthropic: AnthropicProviderSettingsSchema {
392 cache_ttl: settings.anthropic_cache_ttl,
393 },
394 custom: settings.custom_providers.clone(),
395 catalog: settings.models.clone(),
396 streaming: settings.provider_stream.clone(),
397 },
398 )?;
399
400 let mut tools = settings.tools.clone();
401 tools.subagents = SubagentsToolSettings::default();
402 insert_wire_group(
403 &mut root,
404 "capabilities",
405 &CapabilitiesSettingsSchema {
406 tools,
407 mcp_approvals: settings.mcp_approvals.clone(),
408 lsp: settings.lsp.clone(),
409 },
410 )?;
411
412 insert_wire_group(
413 &mut root,
414 "internal_tooling",
415 &InternalToolingSettingsSchema {
416 jev: settings.jev.clone(),
417 },
418 )?;
419
420 insert_wire_group(
421 &mut root,
422 "knowledge",
423 &KnowledgeSettingsSchema {
424 instructions: settings.instructions.clone(),
425 skills: settings.skills.clone(),
426 },
427 )?;
428 insert_wire_group(
429 &mut root,
430 "automation",
431 &AutomationSettingsSchema {
432 hooks: settings.hooks.clone(),
433 integrations: settings.integrations.clone(),
434 },
435 )?;
436
437 insert_wire_group(
438 &mut root,
439 "sessions",
440 &SessionTitlesWire {
441 titles: settings.session_titles.clone(),
442 },
443 )?;
444
445 insert_wire_group(
446 &mut root,
447 "interface",
448 &InterfaceSettingsSchema {
449 appearance: AppearanceSettings::default(),
450 no_color: settings.no_color,
451 tui: {
452 let mut tui: InterfaceTuiSettingsSchema =
453 serde_json::from_value(serde_json::to_value(&settings.tui)?)?;
454 tui.autocomplete.respects_gitignore = settings.file_autocomplete_respects_gitignore;
455 tui
456 },
457 },
458 )?;
459 if !settings.tui.is_default() {
462 let interface = root.entry("interface").or_insert_with(|| json!({}));
463 interface
464 .as_object_mut()
465 .expect("serialized interface is an object")
466 .entry("tui")
467 .or_insert_with(|| json!({}));
468 }
469
470 Ok(Value::Object(root))
471}
472
473fn insert_wire_group(
474 root: &mut Map<String, Value>,
475 key: &str,
476 group: &impl Serialize,
477) -> anyhow::Result<()> {
478 let value = serde_json::to_value(group)?;
479 if !value_is_empty_object(&value) {
480 root.insert(key.to_string(), value);
481 }
482 Ok(())
483}
484
485pub(crate) fn validate_settings_schema_version(value: &Value) -> anyhow::Result<()> {
486 let object = value
487 .as_object()
488 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
489 match settings_schema_version(object)? {
490 None | Some(SETTINGS_SCHEMA_VERSION) => Ok(()),
491 Some(version) => anyhow::bail!(
492 "unsupported settings schema_version {version}; expected {SETTINGS_SCHEMA_VERSION}"
493 ),
494 }
495}
496
497fn settings_schema_version(object: &Map<String, Value>) -> anyhow::Result<Option<u64>> {
498 let Some(value) = object.get("schema_version") else {
499 return Ok(None);
500 };
501 let Some(number) = value.as_u64() else {
502 anyhow::bail!("settings schema_version must be an integer")
503 };
504 Ok(Some(number))
505}
506
507fn settings_from_value(value: &Value) -> anyhow::Result<Settings> {
508 validate_settings_schema_version(value)?;
509 let root = value
510 .as_object()
511 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
512 for key in [
514 "agent",
515 "providers",
516 "capabilities",
517 "knowledge",
518 "automation",
519 "sessions",
520 "interface",
521 ] {
522 group_object(root, key, key)?;
523 }
524 let providers_group = group_object(root, "providers", "providers")?;
525 group_object(providers_group, "anthropic", "providers.anthropic")?;
526 let interface_group = group_object(root, "interface", "interface")?;
527 let tui_group = group_object(interface_group, "tui", "interface.tui")?;
528 group_object(tui_group, "autocomplete", "interface.tui.autocomplete")?;
529 group_object(root, "internal_tooling", "internal_tooling")?;
530 let agent: AgentSettingsSchema = parse_group(root, "agent")?;
531 let providers: ProvidersSettingsSchema = parse_group(root, "providers")?;
532 let capabilities: CapabilitiesSettingsSchema = parse_group(root, "capabilities")?;
533 let knowledge: KnowledgeSettingsSchema = parse_group(root, "knowledge")?;
534 let automation: AutomationSettingsSchema = parse_group(root, "automation")?;
535 let sessions: SessionTitlesWire = parse_group(root, "sessions")?;
536 let interface: InterfaceSettingsSchema = parse_group(root, "interface")?;
537
538 let internal_tooling: InternalToolingSettingsSchema = parse_group(root, "internal_tooling")?;
539 let mut tools = capabilities.tools;
540 tools.subagents = agent.subagents.execution;
541
542 let settings = Settings {
543 selected_model: agent.model,
544 fast: agent.fast,
545 openai_codex: providers.openai_codex,
546 openai_responses: providers.openai_responses,
547 no_color: interface.no_color,
548 anthropic_cache_ttl: providers.anthropic.cache_ttl,
549 file_autocomplete_respects_gitignore: interface.tui.autocomplete.respects_gitignore,
550 context: agent.context,
551 session_titles: sessions.titles,
552 compaction: agent.compaction,
553 summarizer: agent.summarizer,
554 side: agent.side,
555 tools,
556 subagents: agent.subagents.settings,
557 models: providers.catalog,
558 hooks: automation.hooks,
559 instructions: knowledge.instructions,
560 skills: knowledge.skills,
561 custom_providers: providers.custom,
562 mcp_servers: BTreeMap::new(),
563 mcp_approvals: capabilities.mcp_approvals,
564 lsp: capabilities.lsp,
565 integrations: automation.integrations,
566 tui: parse_group(
567 group_object(root, "interface", "interface")?,
568 "interface.tui",
569 )?,
570 provider_stream: providers.streaming,
571 selected_primary_agent: agent.primary_agent,
572 jev: internal_tooling.jev,
573 };
574 Ok(settings)
575}
576
577fn group_object<'a>(
578 parent: &'a Map<String, Value>,
579 key: &str,
580 path: &str,
581) -> anyhow::Result<&'a Map<String, Value>> {
582 match parent.get(key) {
583 None => Ok(empty_object()),
584 Some(value) => value
585 .as_object()
586 .ok_or_else(|| anyhow::anyhow!("{path} must be a JSON object")),
587 }
588}
589
590fn empty_object() -> &'static Map<String, Value> {
591 static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
592 EMPTY.get_or_init(Map::new)
593}
594
595fn parse_group<T>(parent: &Map<String, Value>, path: &str) -> anyhow::Result<T>
597where
598 T: DeserializeOwned + Default,
599{
600 let key = path.rsplit('.').next().unwrap_or(path);
601 let Some(value) = parent.get(key) else {
602 return Ok(T::default());
603 };
604 serde_path_to_error::deserialize(value.clone()).map_err(|error| {
605 let inner_path = error.path().to_string();
606 let location = if inner_path == "." {
607 path.to_string()
608 } else {
609 format!("{path}.{inner_path}")
610 };
611 anyhow::anyhow!("settings {location}: {}", error.into_inner())
612 })
613}
614
615fn value_is_empty_object(value: &Value) -> bool {
616 value.as_object().is_some_and(Map::is_empty)
617}
618
619impl<'de> Deserialize<'de> for Settings {
620 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
621 where
622 D: serde::Deserializer<'de>,
623 {
624 let value = Value::deserialize(deserializer)?;
625 SettingsDocument::from_value(&value)
626 .map(|document| document.settings)
627 .map_err(de::Error::custom)
628 }
629}