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