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