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