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,
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// These private wire groups are shared by reading, writing, and schema generation.
61#[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
160// Reuse the tool wire schema, excluding execution (owned by agent.subagents).
161struct 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// Retention belongs to SessionSettings, not the mutable Settings projection.
207#[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
257// Keep the published schema's default annotations independent of sparse writes.
258fn 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    // The domain TUI retains raw autocomplete keys, including explicit defaults.
433    // Preserve its historical explicit empty projection rather than dropping the group.
434    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    // Serde structs also accept positional arrays; settings groups must remain objects.
486    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}