Skip to main content

magi_code/config/settings/
wire.rs

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// These private wire groups are shared by reading, writing, and schema generation.
97#[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
196// Reuse the tool wire schema, excluding execution (owned by agent.subagents).
197struct 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// Retention belongs to SessionSettings, not the mutable Settings projection.
243#[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
293// Keep the published schema's default annotations independent of sparse writes.
294fn 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    // The domain TUI retains raw autocomplete keys, including explicit defaults.
476    // Preserve its historical explicit empty projection rather than dropping the group.
477    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    // An explicitly present empty object is still a value. Do not refill it
721    // from a legacy alias or an older spelling during migration.
722    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    // Serde structs also accept positional arrays; settings groups must remain objects.
804    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    // Migration moves tool execution settings into agent.subagents.execution.
830    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}