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, McpServersSettings},
22 tools::{SubagentsToolSettings, ToolSettings},
23};
24use crate::config::{CustomProviderConfig, HookSettings};
25
26pub(crate) const SETTINGS_SCHEMA_VERSION: u64 = 2;
27
28const LEGACY_ROOT_KEYS: &[&str] = &[
29 "selected_model",
30 "provider",
31 "model",
32 "thinking_level",
33 "no_color",
34 "appearance",
35 "fast",
36 "openai_codex",
37 "openai_responses",
38 "anthropic_cache_ttl",
39 "file_autocomplete_respects_gitignore",
40 "context",
41 "session_titles",
42 "compaction",
43 "summarizer",
44 "tools",
45 "subagents",
46 "models",
47 "hooks",
48 "instructions",
49 "skills",
50 "custom_providers",
51 "mcp_servers",
52 "lsp",
53 "integrations",
54 "herdr",
55 "tui",
56 "provider_stream",
57 "ttsr",
58 "selected_primary_agent",
59 "disabled_skills",
60 "sessions",
61 "schema_version",
62];
63
64#[allow(dead_code)]
65fn default_session_retention_days() -> u64 {
66 super::core::DEFAULT_SESSION_RETENTION_DAYS
67}
68
69#[allow(dead_code)]
70#[derive(Default, JsonSchema)]
71struct SettingsSchema {
72 #[schemars(range(min = 2, max = 2))]
73 pub schema_version: u64,
74 #[serde(default)]
75 #[schemars(skip_serializing_if = "omit_schema_default")]
76 pub agent: AgentSettingsSchema,
77 #[serde(default)]
78 #[schemars(skip_serializing_if = "omit_schema_default")]
79 pub providers: ProvidersSettingsSchema,
80 #[serde(default)]
81 #[schemars(skip_serializing_if = "omit_schema_default")]
82 pub capabilities: CapabilitiesSettingsSchema,
83 #[serde(default)]
84 #[schemars(skip_serializing_if = "omit_schema_default")]
85 pub knowledge: KnowledgeSettingsSchema,
86 #[serde(default)]
87 #[schemars(skip_serializing_if = "omit_schema_default")]
88 pub automation: AutomationSettingsSchema,
89 #[serde(default)]
90 pub sessions: SessionsSettingsSchema,
91 #[serde(default)]
92 #[schemars(skip_serializing_if = "omit_schema_default")]
93 pub interface: InterfaceSettingsSchema,
94}
95
96#[derive(Default, Serialize, Deserialize, JsonSchema)]
98struct AgentSettingsSchema {
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 #[schemars(!skip_serializing_if)]
101 primary_agent: Option<String>,
102 #[serde(default, skip_serializing_if = "SelectedModelSettings::is_default")]
103 #[schemars(!skip_serializing_if)]
104 model: SelectedModelSettings,
105 #[serde(default, skip_serializing_if = "FastSettings::is_default")]
106 #[schemars(!skip_serializing_if)]
107 fast: FastSettings,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 #[schemars(!skip_serializing_if)]
110 context: Option<crate::context::ContextBudget>,
111 #[serde(default, skip_serializing_if = "CompactionSettings::is_default")]
112 #[schemars(!skip_serializing_if)]
113 compaction: CompactionSettings,
114 #[serde(
115 default,
116 skip_serializing_if = "super::agent::SummarizerSettings::is_default"
117 )]
118 #[schemars(!skip_serializing_if)]
119 summarizer: super::agent::SummarizerSettings,
120 #[serde(
121 default,
122 skip_serializing_if = "AgentSubagentsSettingsSchema::is_default"
123 )]
124 subagents: AgentSubagentsSettingsSchema,
125 #[serde(default, skip_serializing_if = "TtsrSettings::is_default")]
126 #[schemars(!skip_serializing_if)]
127 reminders: TtsrSettings,
128}
129
130#[derive(Default, Serialize, Deserialize, JsonSchema)]
131#[schemars(transform = subagents_schema_defaults)]
132struct AgentSubagentsSettingsSchema {
133 #[serde(flatten)]
134 settings: super::agent::SubagentsSettings,
135 #[serde(default, skip_serializing_if = "SubagentsToolSettings::is_default")]
136 #[schemars(!skip_serializing_if)]
137 execution: SubagentsToolSettings,
138}
139
140impl AgentSubagentsSettingsSchema {
141 fn is_default(&self) -> bool {
142 self.settings == super::agent::SubagentsSettings::default() && self.execution.is_default()
143 }
144}
145
146#[derive(Default, Serialize, Deserialize, JsonSchema)]
147struct ProvidersSettingsSchema {
148 #[serde(default, skip_serializing_if = "OpenAiCodexSettings::is_default")]
149 #[schemars(!skip_serializing_if)]
150 openai_codex: OpenAiCodexSettings,
151 #[serde(default, skip_serializing_if = "OpenAiResponsesSettings::is_default")]
152 #[schemars(!skip_serializing_if)]
153 openai_responses: OpenAiResponsesSettings,
154 #[serde(
155 default,
156 skip_serializing_if = "AnthropicProviderSettingsSchema::is_default"
157 )]
158 anthropic: AnthropicProviderSettingsSchema,
159 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
160 #[schemars(!skip_serializing_if)]
161 custom: BTreeMap<String, CustomProviderConfig>,
162 #[serde(default, skip_serializing_if = "ModelsSettings::is_default")]
163 #[schemars(!skip_serializing_if)]
164 catalog: ModelsSettings,
165 #[serde(default, skip_serializing_if = "ProviderStreamSettings::is_default")]
166 #[schemars(!skip_serializing_if)]
167 streaming: ProviderStreamSettings,
168}
169
170#[derive(Default, Serialize, Deserialize, JsonSchema)]
171struct AnthropicProviderSettingsSchema {
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 #[schemars(!skip_serializing_if)]
174 cache_ttl: Option<AnthropicCacheTtl>,
175}
176
177impl AnthropicProviderSettingsSchema {
178 fn is_default(&self) -> bool {
179 self.cache_ttl.is_none()
180 }
181}
182
183#[derive(Default, Serialize, Deserialize, JsonSchema)]
184struct CapabilitiesSettingsSchema {
185 #[serde(default, skip_serializing_if = "is_default")]
186 #[schemars(with = "ToolCapabilitiesSettingsSchema")]
187 tools: ToolSettings,
188 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
189 #[schemars(!skip_serializing_if)]
190 mcp: McpServersSettings,
191 #[serde(default, skip_serializing_if = "LspSettings::is_default")]
192 #[schemars(!skip_serializing_if)]
193 lsp: LspSettings,
194}
195
196struct ToolCapabilitiesSettingsSchema;
198
199impl JsonSchema for ToolCapabilitiesSettingsSchema {
200 fn schema_name() -> std::borrow::Cow<'static, str> {
201 "ToolCapabilitiesSettingsSchema".into()
202 }
203
204 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
205 let mut schema = ToolSettings::json_schema(generator);
206 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
207 properties.remove("subagents");
208 }
209 schema
210 }
211}
212
213#[derive(Default, Serialize, Deserialize, JsonSchema)]
214struct KnowledgeSettingsSchema {
215 #[serde(default, skip_serializing_if = "InstructionsSettings::is_default")]
216 #[schemars(!skip_serializing_if)]
217 instructions: InstructionsSettings,
218 #[serde(default, skip_serializing_if = "SkillsSettings::is_default")]
219 #[schemars(!skip_serializing_if)]
220 skills: SkillsSettings,
221}
222
223#[derive(Default, Serialize, Deserialize, JsonSchema)]
224struct AutomationSettingsSchema {
225 #[serde(default, skip_serializing_if = "HookSettings::is_default")]
226 #[schemars(!skip_serializing_if)]
227 hooks: HookSettings,
228 #[serde(default, skip_serializing_if = "IntegrationsSettings::is_default")]
229 #[schemars(!skip_serializing_if)]
230 integrations: IntegrationsSettings,
231}
232
233#[allow(dead_code)]
234#[derive(Default, JsonSchema)]
235struct SessionsSettingsSchema {
236 #[serde(default = "default_session_retention_days")]
237 pub retention_days: u64,
238 #[serde(flatten)]
239 titles: SessionTitlesWire,
240}
241
242#[derive(Default, Serialize, Deserialize, JsonSchema)]
244struct SessionTitlesWire {
245 #[serde(default, skip_serializing_if = "SessionTitleSettings::is_default")]
246 #[schemars(!skip_serializing_if)]
247 titles: SessionTitleSettings,
248}
249
250#[derive(Default, Serialize, Deserialize, JsonSchema)]
251struct InterfaceSettingsSchema {
252 #[serde(default, skip_serializing_if = "is_default")]
253 #[schemars(!skip_serializing_if)]
254 appearance: AppearanceSettings,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 #[schemars(!skip_serializing_if)]
257 no_color: Option<bool>,
258 #[serde(default, skip_serializing_if = "is_default")]
259 tui: InterfaceTuiSettingsSchema,
260}
261
262#[derive(Default, PartialEq, Serialize, Deserialize, JsonSchema)]
263#[schemars(transform = tui_schema_defaults)]
264struct InterfaceTuiSettingsSchema {
265 #[serde(flatten)]
266 settings: super::agent::TuiSettings,
267 #[serde(default, skip_serializing_if = "is_default")]
268 autocomplete: AutocompleteSettingsSchema,
269}
270
271#[derive(PartialEq, Serialize, Deserialize, JsonSchema)]
272#[schemars(transform = implicit_additional_properties)]
273struct AutocompleteSettingsSchema {
274 #[serde(
275 default = "default_file_autocomplete_respects_gitignore",
276 skip_serializing_if = "is_true"
277 )]
278 #[schemars(!skip_serializing_if)]
279 respects_gitignore: bool,
280 #[serde(default, flatten)]
281 extra: BTreeMap<String, Value>,
282}
283
284impl Default for AutocompleteSettingsSchema {
285 fn default() -> Self {
286 Self {
287 respects_gitignore: default_file_autocomplete_respects_gitignore(),
288 extra: BTreeMap::new(),
289 }
290 }
291}
292
293fn omit_schema_default<T>(_: &T) -> bool {
295 true
296}
297
298fn implicit_additional_properties(schema: &mut schemars::Schema) {
299 schema.remove("additionalProperties");
300}
301
302fn subagents_schema_defaults(schema: &mut schemars::Schema) {
303 implicit_additional_properties(schema);
304 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
305 properties["disabled"]["default"] = json!([]);
306 properties["schema_validation_max_retries"]["default"] = Value::Null;
307 }
308}
309
310fn tui_schema_defaults(schema: &mut schemars::Schema) {
311 implicit_additional_properties(schema);
312 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
313 properties["subagent_card_rows"]["default"] = Value::Null;
314 }
315}
316
317fn is_default<T: Default + PartialEq>(value: &T) -> bool {
318 *value == T::default()
319}
320
321fn is_true(value: &bool) -> bool {
322 *value
323}
324
325impl JsonSchema for Settings {
326 fn schema_name() -> std::borrow::Cow<'static, str> {
327 "Settings".into()
328 }
329
330 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
331 SettingsSchema::json_schema(generator)
332 }
333}
334
335impl Serialize for Settings {
336 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
337 where
338 S: Serializer,
339 {
340 let value = canonical_settings_value(self).map_err(serde::ser::Error::custom)?;
341 value.serialize(serializer)
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub(crate) struct SettingsDocument {
347 pub(crate) settings: Settings,
348 pub(crate) appearance: AppearanceSettings,
349}
350
351impl SettingsDocument {
352 pub(super) fn from_value(value: Value) -> anyhow::Result<Self> {
353 let normalized = migrate_settings_value(value)?;
354 Self::from_normalized_value(&normalized)
355 }
356
357 pub(super) fn from_normalized_value(normalized: &Value) -> anyhow::Result<Self> {
358 let settings = settings_from_normalized_value(normalized)?;
359 let root = normalized
360 .as_object()
361 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
362 let interface = group_object(root, "interface", "interface")?;
363 let appearance = parse_group(interface, "appearance")?;
364 Ok(Self {
365 settings,
366 appearance,
367 })
368 }
369}
370
371pub(super) fn session_settings_from_normalized_value(
372 normalized: &Value,
373) -> anyhow::Result<SessionSettings> {
374 let root = normalized
375 .as_object()
376 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
377 let sessions = group_object(root, "sessions", "sessions")?;
378 if sessions.is_empty() {
379 Ok(SessionSettings::default())
380 } else {
381 serde_json::from_value(Value::Object(sessions.clone())).map_err(Into::into)
382 }
383}
384
385pub(super) fn canonical_settings_value(settings: &Settings) -> anyhow::Result<Value> {
386 let mut root = Map::new();
387 root.insert(
388 "schema_version".to_string(),
389 Value::Number(SETTINGS_SCHEMA_VERSION.into()),
390 );
391
392 insert_wire_group(
393 &mut root,
394 "agent",
395 &AgentSettingsSchema {
396 primary_agent: settings.selected_primary_agent.clone(),
397 model: settings.selected_model.clone(),
398 fast: settings.fast.clone(),
399 context: settings.context.clone(),
400 compaction: settings.compaction.clone(),
401 summarizer: settings.summarizer.clone(),
402 subagents: AgentSubagentsSettingsSchema {
403 settings: settings.subagents.clone(),
404 execution: settings.tools.subagents.clone(),
405 },
406 reminders: settings.ttsr.clone(),
407 },
408 )?;
409 insert_wire_group(
410 &mut root,
411 "providers",
412 &ProvidersSettingsSchema {
413 openai_codex: settings.openai_codex.clone(),
414 openai_responses: settings.openai_responses.clone(),
415 anthropic: AnthropicProviderSettingsSchema {
416 cache_ttl: settings.anthropic_cache_ttl,
417 },
418 custom: settings.custom_providers.clone(),
419 catalog: settings.models.clone(),
420 streaming: settings.provider_stream.clone(),
421 },
422 )?;
423
424 let mut tools = settings.tools.clone();
425 tools.subagents = SubagentsToolSettings::default();
426 insert_wire_group(
427 &mut root,
428 "capabilities",
429 &CapabilitiesSettingsSchema {
430 tools,
431 mcp: settings.mcp_servers.clone(),
432 lsp: settings.lsp.clone(),
433 },
434 )?;
435
436 insert_wire_group(
437 &mut root,
438 "knowledge",
439 &KnowledgeSettingsSchema {
440 instructions: settings.instructions.clone(),
441 skills: settings.skills.clone(),
442 },
443 )?;
444 insert_wire_group(
445 &mut root,
446 "automation",
447 &AutomationSettingsSchema {
448 hooks: settings.hooks.clone(),
449 integrations: settings.integrations.clone(),
450 },
451 )?;
452
453 insert_wire_group(
454 &mut root,
455 "sessions",
456 &SessionTitlesWire {
457 titles: settings.session_titles.clone(),
458 },
459 )?;
460
461 insert_wire_group(
462 &mut root,
463 "interface",
464 &InterfaceSettingsSchema {
465 appearance: AppearanceSettings::default(),
466 no_color: settings.no_color,
467 tui: {
468 let mut tui: InterfaceTuiSettingsSchema =
469 serde_json::from_value(serde_json::to_value(&settings.tui)?)?;
470 tui.autocomplete.respects_gitignore = settings.file_autocomplete_respects_gitignore;
471 tui
472 },
473 },
474 )?;
475 if !settings.tui.is_default() {
478 let interface = root.entry("interface").or_insert_with(|| json!({}));
479 interface
480 .as_object_mut()
481 .expect("serialized interface is an object")
482 .entry("tui")
483 .or_insert_with(|| json!({}));
484 }
485
486 Ok(Value::Object(root))
487}
488
489fn insert_wire_group(
490 root: &mut Map<String, Value>,
491 key: &str,
492 group: &impl Serialize,
493) -> anyhow::Result<()> {
494 let value = serde_json::to_value(group)?;
495 if !value_is_empty_object(&value) {
496 root.insert(key.to_string(), value);
497 }
498 Ok(())
499}
500
501pub(crate) fn migrate_settings_value(value: Value) -> anyhow::Result<Value> {
502 let Some(object) = value.as_object() else {
503 return Ok(value);
504 };
505 match settings_schema_version(object)? {
506 None | Some(1) | Some(SETTINGS_SCHEMA_VERSION) => migrate_to_v2(value),
507 Some(version) => anyhow::bail!(
508 "unsupported settings schema_version {version}; expected 1 or {SETTINGS_SCHEMA_VERSION}"
509 ),
510 }
511}
512
513fn settings_schema_version(object: &Map<String, Value>) -> anyhow::Result<Option<u64>> {
514 let Some(value) = object.get("schema_version") else {
515 return Ok(None);
516 };
517 let Some(number) = value.as_u64() else {
518 anyhow::bail!("settings schema_version must be an integer")
519 };
520 Ok(Some(number))
521}
522
523fn migrate_to_v2(value: Value) -> anyhow::Result<Value> {
524 let source = value
525 .as_object()
526 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
527 let mut target = Map::new();
528 for (key, value) in source {
529 if !LEGACY_ROOT_KEYS.contains(&key.as_str()) {
530 target.insert(key.clone(), value.clone());
531 }
532 }
533
534 let mut agent = Map::new();
535 let mut model = source
536 .get("selected_model")
537 .cloned()
538 .unwrap_or_else(|| json!({}));
539 if let Some(model_object) = model.as_object_mut() {
540 copy_if_absent(model_object, "provider", source.get("provider"));
541 copy_if_absent(model_object, "model", source.get("model"));
542 copy_if_absent(model_object, "thinking_level", source.get("thinking_level"));
543 }
544 if source.contains_key("selected_model")
545 || source.contains_key("provider")
546 || source.contains_key("model")
547 || source.contains_key("thinking_level")
548 {
549 agent.insert("model".to_string(), model);
550 }
551 copy_group_if_present(
552 &mut agent,
553 "primary_agent",
554 source,
555 "selected_primary_agent",
556 );
557 copy_group_if_present(&mut agent, "fast", source, "fast");
558 copy_group_if_present(&mut agent, "context", source, "context");
559 copy_group_if_present(&mut agent, "compaction", source, "compaction");
560 copy_group_if_present(&mut agent, "summarizer", source, "summarizer");
561 copy_group_if_present(&mut agent, "reminders", source, "ttsr");
562 copy_group_if_present(&mut agent, "subagents", source, "subagents");
563 if !agent.is_empty() {
564 merge_group(&mut target, "agent", Value::Object(agent));
565 }
566
567 let mut providers = Map::new();
568 copy_group_if_present(&mut providers, "openai_codex", source, "openai_codex");
569 copy_group_if_present(
570 &mut providers,
571 "openai_responses",
572 source,
573 "openai_responses",
574 );
575 if source.contains_key("anthropic_cache_ttl") {
576 providers.insert(
577 "anthropic".to_string(),
578 json!({"cache_ttl": source["anthropic_cache_ttl"].clone()}),
579 );
580 }
581 copy_group_if_present(&mut providers, "custom", source, "custom_providers");
582 copy_group_if_present(&mut providers, "catalog", source, "models");
583 copy_group_if_present(&mut providers, "streaming", source, "provider_stream");
584 if !providers.is_empty() {
585 merge_group(&mut target, "providers", Value::Object(providers));
586 }
587
588 let mut capabilities = Map::new();
589 copy_group_if_present(&mut capabilities, "tools", source, "tools");
590 copy_group_if_present(&mut capabilities, "mcp", source, "mcp_servers");
591 copy_group_if_present(&mut capabilities, "lsp", source, "lsp");
592 if !capabilities.is_empty() {
593 merge_group(&mut target, "capabilities", Value::Object(capabilities));
594 }
595
596 let mut knowledge = Map::new();
597 copy_group_if_present(&mut knowledge, "instructions", source, "instructions");
598 let mut skills = source.get("skills").cloned().unwrap_or_else(|| json!({}));
599 merge_object_field_if_missing(&mut skills, "disabled", source.get("disabled_skills"));
600 if source.contains_key("skills") || source.contains_key("disabled_skills") {
601 knowledge.insert("skills".to_string(), skills);
602 }
603 if !knowledge.is_empty() {
604 merge_group(&mut target, "knowledge", Value::Object(knowledge));
605 }
606
607 let mut automation = Map::new();
608 copy_group_if_present(&mut automation, "hooks", source, "hooks");
609 let mut integrations = source
610 .get("integrations")
611 .cloned()
612 .unwrap_or_else(|| json!({}));
613 merge_object_field_if_missing(&mut integrations, "herdr", source.get("herdr"));
614 if source.contains_key("integrations") || source.contains_key("herdr") {
615 automation.insert("integrations".to_string(), integrations);
616 }
617 if !automation.is_empty() {
618 merge_group(&mut target, "automation", Value::Object(automation));
619 }
620
621 let mut sessions = source.get("sessions").cloned().unwrap_or_else(|| json!({}));
622 merge_object_field_if_missing(&mut sessions, "titles", source.get("session_titles"));
623 if source.contains_key("sessions") || source.contains_key("session_titles") {
624 merge_group(&mut target, "sessions", sessions);
625 }
626
627 let mut interface = Map::new();
628 copy_group_if_present(&mut interface, "appearance", source, "appearance");
629 copy_group_if_present(&mut interface, "no_color", source, "no_color");
630 let mut tui = source.get("tui").cloned().unwrap_or_else(|| json!({}));
631 if source.contains_key("file_autocomplete_respects_gitignore") {
632 let autocomplete = tui.as_object_mut().map(|object| {
633 object
634 .entry("autocomplete".to_string())
635 .or_insert_with(|| json!({}))
636 });
637 if let Some(autocomplete) = autocomplete {
638 copy_if_absent(
639 autocomplete
640 .as_object_mut()
641 .ok_or_else(|| anyhow::anyhow!("tui.autocomplete must be a JSON object"))?,
642 "respects_gitignore",
643 source.get("file_autocomplete_respects_gitignore"),
644 );
645 }
646 }
647 if source.contains_key("tui") || source.contains_key("file_autocomplete_respects_gitignore") {
648 interface.insert("tui".to_string(), tui);
649 }
650 if !interface.is_empty() {
651 merge_group(&mut target, "interface", Value::Object(interface));
652 }
653
654 let canonical_subagents = source
655 .get("agent")
656 .and_then(Value::as_object)
657 .and_then(|agent| agent.get("subagents"));
658 normalize_canonical_paths(&mut target, canonical_subagents);
659 target.insert(
660 "schema_version".to_string(),
661 Value::Number(SETTINGS_SCHEMA_VERSION.into()),
662 );
663 Ok(Value::Object(target))
664}
665
666fn normalize_canonical_paths(root: &mut Map<String, Value>, canonical_subagents: Option<&Value>) {
667 let mut execution = None;
668 if let Some(capabilities) = root.get_mut("capabilities").and_then(Value::as_object_mut)
669 && let Some(tools) = capabilities.get_mut("tools").and_then(Value::as_object_mut)
670 {
671 execution = normalize_tool_aliases(tools);
672 }
673
674 let Some(execution) = execution else {
675 return;
676 };
677 if !root.contains_key("agent") {
678 root.insert(
679 "agent".to_string(),
680 json!({"subagents": {"execution": execution}}),
681 );
682 return;
683 }
684
685 let Some(agent) = root.get_mut("agent").and_then(Value::as_object_mut) else {
686 return;
687 };
688 if agent.is_empty()
689 || canonical_subagents.is_some_and(|value| value.as_object().is_some_and(Map::is_empty))
690 || canonical_subagents.is_some_and(|value| !value.is_object())
691 {
692 return;
693 }
694 let subagents = agent
695 .entry("subagents".to_string())
696 .or_insert_with(|| json!({}));
697 let Some(subagents) = subagents.as_object_mut() else {
698 return;
699 };
700 match subagents.get_mut("execution") {
701 Some(existing) => merge_missing(existing, &execution),
702 None => {
703 subagents.insert("execution".to_string(), execution);
704 }
705 }
706}
707
708fn merge_group(target: &mut Map<String, Value>, key: &str, value: Value) {
709 if let Some(existing) = target.get_mut(key) {
710 merge_missing(existing, &value);
711 } else {
712 target.insert(key.to_string(), value);
713 }
714}
715
716fn merge_missing(target: &mut Value, source: &Value) {
717 let (Value::Object(target), Value::Object(source)) = (target, source) else {
718 return;
719 };
720 if target.is_empty() {
723 return;
724 }
725 for (key, value) in source {
726 match target.get_mut(key) {
727 Some(existing) => merge_missing(existing, value),
728 None => {
729 target.insert(key.clone(), value.clone());
730 }
731 }
732 }
733}
734
735fn copy_group_if_present(
736 target: &mut Map<String, Value>,
737 target_key: &str,
738 source: &Map<String, Value>,
739 source_key: &str,
740) {
741 if let Some(value) = source.get(source_key) {
742 target.insert(target_key.to_string(), value.clone());
743 }
744}
745
746fn normalize_tool_aliases(tools: &mut Map<String, Value>) -> Option<Value> {
747 merge_tool_alias(tools, "grep", "ffgrep");
748 merge_tool_alias(tools, "find", "fffind");
749 take_tool_alias(tools, "subagents", "parallel_subagents")
750}
751
752fn merge_tool_alias(tools: &mut Map<String, Value>, canonical: &str, alias: &str) {
753 let Some(alias_value) = tools.remove(alias) else {
754 return;
755 };
756 if let Some(canonical_value) = tools.get_mut(canonical) {
757 merge_missing(canonical_value, &alias_value);
758 } else {
759 tools.insert(canonical.to_string(), alias_value);
760 }
761}
762
763fn take_tool_alias(tools: &mut Map<String, Value>, canonical: &str, alias: &str) -> Option<Value> {
764 let canonical_value = tools.remove(canonical);
765 let alias_value = tools.remove(alias);
766 match (canonical_value, alias_value) {
767 (Some(mut canonical_value), Some(alias_value)) => {
768 merge_missing(&mut canonical_value, &alias_value);
769 Some(canonical_value)
770 }
771 (Some(canonical_value), None) | (None, Some(canonical_value)) => Some(canonical_value),
772 (None, None) => None,
773 }
774}
775
776fn merge_object_field_if_missing(target: &mut Value, key: &str, source: Option<&Value>) {
777 let Some(source) = source else {
778 return;
779 };
780 let Some(target) = target.as_object_mut() else {
781 return;
782 };
783 match target.get_mut(key) {
784 Some(existing) => merge_missing(existing, source),
785 None => {
786 target.insert(key.to_string(), source.clone());
787 }
788 }
789}
790
791fn copy_if_absent(target: &mut Map<String, Value>, key: &str, value: Option<&Value>) {
792 if !target.contains_key(key)
793 && let Some(value) = value
794 {
795 target.insert(key.to_string(), value.clone());
796 }
797}
798
799fn settings_from_normalized_value(value: &Value) -> anyhow::Result<Settings> {
800 let root = value
801 .as_object()
802 .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
803 for key in [
805 "agent",
806 "providers",
807 "capabilities",
808 "knowledge",
809 "automation",
810 "sessions",
811 "interface",
812 ] {
813 group_object(root, key, key)?;
814 }
815 let providers_group = group_object(root, "providers", "providers")?;
816 group_object(providers_group, "anthropic", "providers.anthropic")?;
817 let interface_group = group_object(root, "interface", "interface")?;
818 let tui_group = group_object(interface_group, "tui", "interface.tui")?;
819 group_object(tui_group, "autocomplete", "interface.tui.autocomplete")?;
820 let agent: AgentSettingsSchema = parse_group(root, "agent")?;
821 let providers: ProvidersSettingsSchema = parse_group(root, "providers")?;
822 let capabilities: CapabilitiesSettingsSchema = parse_group(root, "capabilities")?;
823 let knowledge: KnowledgeSettingsSchema = parse_group(root, "knowledge")?;
824 let automation: AutomationSettingsSchema = parse_group(root, "automation")?;
825 let sessions: SessionTitlesWire = parse_group(root, "sessions")?;
826 let interface: InterfaceSettingsSchema = parse_group(root, "interface")?;
827
828 let mut tools = capabilities.tools;
829 tools.subagents = agent.subagents.execution;
831
832 let settings = Settings {
833 selected_model: agent.model,
834 fast: agent.fast,
835 openai_codex: providers.openai_codex,
836 openai_responses: providers.openai_responses,
837 no_color: interface.no_color,
838 anthropic_cache_ttl: providers.anthropic.cache_ttl,
839 file_autocomplete_respects_gitignore: interface.tui.autocomplete.respects_gitignore,
840 context: agent.context,
841 session_titles: sessions.titles,
842 compaction: agent.compaction,
843 summarizer: agent.summarizer,
844 tools,
845 subagents: agent.subagents.settings,
846 models: providers.catalog,
847 hooks: automation.hooks,
848 instructions: knowledge.instructions,
849 skills: knowledge.skills,
850 custom_providers: providers.custom,
851 mcp_servers: capabilities.mcp,
852 lsp: capabilities.lsp,
853 integrations: automation.integrations,
854 tui: parse_group(group_object(root, "interface", "interface")?, "tui")?,
855 provider_stream: providers.streaming,
856 ttsr: agent.reminders,
857 selected_primary_agent: agent.primary_agent,
858 };
859 Ok(settings)
860}
861
862fn group_object<'a>(
863 parent: &'a Map<String, Value>,
864 key: &str,
865 path: &str,
866) -> anyhow::Result<&'a Map<String, Value>> {
867 match parent.get(key) {
868 None => Ok(empty_object()),
869 Some(value) => value
870 .as_object()
871 .ok_or_else(|| anyhow::anyhow!("{path} must be a JSON object")),
872 }
873}
874
875fn empty_object() -> &'static Map<String, Value> {
876 static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
877 EMPTY.get_or_init(Map::new)
878}
879
880fn parse_group<T>(parent: &Map<String, Value>, key: &str) -> anyhow::Result<T>
881where
882 T: DeserializeOwned + Default,
883{
884 parent
885 .get(key)
886 .map(|value| serde_json::from_value(value.clone()))
887 .transpose()?
888 .map_or_else(|| Ok(T::default()), Ok)
889}
890
891fn value_is_empty_object(value: &Value) -> bool {
892 value.as_object().is_some_and(Map::is_empty)
893}
894
895impl<'de> Deserialize<'de> for Settings {
896 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
897 where
898 D: serde::Deserializer<'de>,
899 {
900 let value = Value::deserialize(deserializer)?;
901 SettingsDocument::from_value(value)
902 .map(|document| document.settings)
903 .map_err(de::Error::custom)
904 }
905}