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