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