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