Skip to main content

magi_code/config/
settings.rs

1use crate::{
2    config::custom_provider_config::validate_custom_provider_settings,
3    persistence::{CrossProcessFileLock, atomic_write, in_process_file_lock},
4    subagents::{DEFAULT_SUBAGENT_MAX_DEPTH, MAX_SUBAGENT_MAX_DEPTH},
5    thinking::ThinkingLevel,
6};
7use anyhow::Context;
8use regex::Regex;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize, de};
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    fs, io,
14    net::IpAddr,
15    path::{Path, PathBuf},
16    time::Duration,
17};
18
19use super::{
20    CustomProviderConfig, HookSettings, McPaths,
21    custom_provider_config::validate_custom_provider_id,
22};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SettingsScope {
26    Global,
27    Project,
28}
29
30impl SettingsScope {
31    pub fn label(self) -> &'static str {
32        match self {
33            Self::Global => "Global",
34            Self::Project => "Project",
35        }
36    }
37
38    pub fn toggle(self) -> Self {
39        match self {
40            Self::Global => Self::Project,
41            Self::Project => Self::Global,
42        }
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum SettingsListKind {
48    Skills,
49    Tools,
50    Subagents,
51    Models,
52}
53
54#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
55#[serde(rename_all = "lowercase")]
56pub enum TextVerbosity {
57    #[default]
58    Low,
59    Medium,
60    High,
61}
62
63impl TextVerbosity {
64    pub fn as_api_str(self) -> &'static str {
65        match self {
66            Self::Low => "low",
67            Self::Medium => "medium",
68            Self::High => "high",
69        }
70    }
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
74pub struct OpenAiResponsesSettings {
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub text_verbosity: Option<TextVerbosity>,
77}
78
79impl OpenAiResponsesSettings {
80    pub fn is_default(&self) -> bool {
81        self == &Self::default()
82    }
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
86pub struct OpenAiCodexSettings {
87    #[serde(default)]
88    pub text_verbosity: TextVerbosity,
89}
90
91impl OpenAiCodexSettings {
92    pub fn is_default(&self) -> bool {
93        self == &Self::default()
94    }
95}
96
97#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
98pub enum AnthropicCacheTtl {
99    #[default]
100    #[serde(rename = "5m")]
101    FiveMinutes,
102    #[serde(rename = "1h")]
103    OneHour,
104}
105
106#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
107pub struct Settings {
108    #[serde(default, skip_serializing_if = "SelectedModelSettings::is_default")]
109    pub selected_model: SelectedModelSettings,
110    #[serde(default, skip_serializing_if = "OpenAiCodexSettings::is_default")]
111    pub openai_codex: OpenAiCodexSettings,
112    #[serde(default, skip_serializing_if = "OpenAiResponsesSettings::is_default")]
113    pub openai_responses: OpenAiResponsesSettings,
114    pub no_color: Option<bool>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub anthropic_cache_ttl: Option<AnthropicCacheTtl>,
117    #[serde(
118        default = "default_file_autocomplete_respects_gitignore",
119        skip_serializing_if = "is_true"
120    )]
121    pub file_autocomplete_respects_gitignore: bool,
122    pub context: Option<crate::context::ContextBudget>,
123    #[serde(default, skip_serializing_if = "SessionTitleSettings::is_default")]
124    pub session_titles: SessionTitleSettings,
125    #[serde(default, skip_serializing_if = "CompactionSettings::is_default")]
126    pub compaction: CompactionSettings,
127    #[serde(default, skip_serializing_if = "ToolSettings::is_default")]
128    pub tools: ToolSettings,
129    #[serde(default, skip_serializing_if = "SubagentsSettings::is_default")]
130    pub subagents: SubagentsSettings,
131    #[serde(default, skip_serializing_if = "ModelsSettings::is_default")]
132    pub models: ModelsSettings,
133    #[serde(default, skip_serializing_if = "HookSettings::is_default")]
134    pub hooks: HookSettings,
135    #[serde(default, skip_serializing_if = "InstructionsSettings::is_default")]
136    pub instructions: InstructionsSettings,
137    #[serde(default, skip_serializing_if = "SkillsSettings::is_default")]
138    pub skills: SkillsSettings,
139    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
140    pub custom_providers: BTreeMap<String, CustomProviderConfig>,
141    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
142    pub mcp_servers: McpServersSettings,
143    #[serde(default, skip_serializing_if = "LspSettings::is_default")]
144    pub lsp: LspSettings,
145    #[serde(default, skip_serializing_if = "IntegrationsSettings::is_default")]
146    pub integrations: IntegrationsSettings,
147    #[serde(default, skip_serializing_if = "TuiSettings::is_default")]
148    pub tui: TuiSettings,
149    #[serde(default, skip_serializing_if = "ProviderStreamSettings::is_default")]
150    pub provider_stream: ProviderStreamSettings,
151    #[serde(default, skip_serializing_if = "TtsrSettings::is_default")]
152    pub ttsr: TtsrSettings,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub selected_primary_agent: Option<String>,
155}
156
157impl Default for Settings {
158    fn default() -> Self {
159        Self {
160            openai_responses: OpenAiResponsesSettings::default(),
161            selected_model: SelectedModelSettings::default(),
162            openai_codex: OpenAiCodexSettings::default(),
163            no_color: None,
164            anthropic_cache_ttl: None,
165            file_autocomplete_respects_gitignore: true,
166            context: None,
167            session_titles: SessionTitleSettings::default(),
168            compaction: CompactionSettings::default(),
169            tools: ToolSettings::default(),
170            subagents: SubagentsSettings::default(),
171            models: ModelsSettings::default(),
172            hooks: HookSettings::default(),
173            instructions: InstructionsSettings::default(),
174            skills: SkillsSettings::default(),
175            custom_providers: BTreeMap::new(),
176            mcp_servers: BTreeMap::new(),
177            lsp: LspSettings::default(),
178            integrations: IntegrationsSettings::default(),
179            tui: TuiSettings::default(),
180            provider_stream: ProviderStreamSettings::default(),
181            ttsr: TtsrSettings::default(),
182            selected_primary_agent: None,
183        }
184    }
185}
186
187impl Settings {
188    pub(crate) fn text_verbosity_for(&self, provider: &str) -> Option<TextVerbosity> {
189        if provider == crate::providers::OPENAI_CODEX_PROVIDER {
190            return Some(
191                self.openai_responses
192                    .text_verbosity
193                    .unwrap_or(self.openai_codex.text_verbosity),
194            );
195        }
196        self.custom_providers.get(provider).and_then(|custom| {
197            (custom.use_responses_endpoint && custom.supports_text_verbosity)
198                .then_some(self.openai_responses.text_verbosity)
199                .flatten()
200        })
201    }
202}
203
204fn default_file_autocomplete_respects_gitignore() -> bool {
205    true
206}
207
208fn is_true(value: &bool) -> bool {
209    *value
210}
211
212fn is_false(value: &bool) -> bool {
213    !*value
214}
215
216pub type McpServersSettings = BTreeMap<String, McpServerConfig>;
217pub type LspServersSettings = BTreeMap<String, LspServerConfig>;
218
219pub const DEFAULT_LSP_DIAGNOSTICS_WAIT_MS: u64 = 2_000;
220pub const DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES: u64 = 10;
221pub const MAX_LSP_DIAGNOSTICS_WAIT_MS: u64 = 30_000;
222pub const MAX_LSP_IDLE_SHUTDOWN_MINUTES: u64 = 240;
223
224#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
225pub struct LspSettings {
226    #[serde(default)]
227    pub enabled: bool,
228    #[serde(default = "default_true")]
229    pub inject_diagnostics_on_edit: bool,
230    #[serde(default = "default_lsp_diagnostics_wait_ms")]
231    pub diagnostics_wait_ms: u64,
232    #[serde(default = "default_lsp_idle_shutdown_minutes")]
233    pub idle_shutdown_minutes: u64,
234    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
235    pub servers: LspServersSettings,
236}
237
238impl Default for LspSettings {
239    fn default() -> Self {
240        Self {
241            enabled: false,
242            inject_diagnostics_on_edit: true,
243            diagnostics_wait_ms: default_lsp_diagnostics_wait_ms(),
244            idle_shutdown_minutes: default_lsp_idle_shutdown_minutes(),
245            servers: BTreeMap::new(),
246        }
247    }
248}
249
250impl LspSettings {
251    pub fn is_default(&self) -> bool {
252        self == &Self::default()
253    }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
257pub struct LspServerConfig {
258    pub command: String,
259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
260    pub args: Vec<String>,
261    #[serde(default = "default_true")]
262    pub enabled: bool,
263}
264
265pub fn default_lsp_diagnostics_wait_ms() -> u64 {
266    DEFAULT_LSP_DIAGNOSTICS_WAIT_MS
267}
268
269pub fn default_lsp_idle_shutdown_minutes() -> u64 {
270    DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES
271}
272
273pub const DEFAULT_MCP_TIMEOUT_SECONDS: u64 = 30;
274pub const MAX_MCP_TIMEOUT_SECONDS: u64 = 300;
275
276#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
277#[serde(tag = "type", rename_all = "snake_case")]
278pub enum McpServerConfig {
279    Stdio(McpStdioServerConfig),
280    Http(McpHttpServerConfig),
281}
282
283impl std::fmt::Debug for McpServerConfig {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        match self {
286            Self::Stdio(config) => f.debug_tuple("Stdio").field(config).finish(),
287            Self::Http(config) => f.debug_tuple("Http").field(config).finish(),
288        }
289    }
290}
291
292impl McpServerConfig {
293    pub(crate) fn enabled(&self) -> bool {
294        match self {
295            Self::Stdio(config) => config.enabled,
296            Self::Http(config) => config.enabled,
297        }
298    }
299
300    pub(crate) fn set_enabled(&mut self, enabled: bool) {
301        match self {
302            Self::Stdio(config) => config.enabled = enabled,
303            Self::Http(config) => config.enabled = enabled,
304        }
305    }
306}
307
308#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
309pub struct McpStdioServerConfig {
310    pub command: String,
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub args: Vec<String>,
313    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
314    pub env: BTreeMap<String, String>,
315    #[serde(default = "default_true")]
316    pub enabled: bool,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub timeout: Option<u64>,
319}
320
321impl std::fmt::Debug for McpStdioServerConfig {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        f.debug_struct("McpStdioServerConfig")
324            .field("command", &self.command)
325            .field("args", &self.args)
326            .field("env", &format_args!("<{} vars redacted>", self.env.len()))
327            .field("enabled", &self.enabled)
328            .field("timeout", &self.timeout)
329            .finish()
330    }
331}
332
333#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
334pub struct McpHttpServerConfig {
335    pub url: String,
336    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
337    pub headers: BTreeMap<String, String>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub oauth: Option<McpOAuthConfig>,
340    #[serde(default = "default_true")]
341    pub enabled: bool,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub timeout: Option<u64>,
344}
345
346#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
347pub struct McpOAuthConfig {
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub client_id: Option<String>,
350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
351    pub scopes: Vec<String>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub authorization_server: Option<String>,
354}
355
356impl std::fmt::Debug for McpOAuthConfig {
357    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358        f.debug_struct("McpOAuthConfig")
359            .field("client_id", &self.client_id.as_ref().map(|_| "[REDACTED]"))
360            .field("scopes", &self.scopes)
361            .field(
362                "authorization_server",
363                &self
364                    .authorization_server
365                    .as_ref()
366                    .map(|url| sanitize_mcp_http_url_for_display(url)),
367            )
368            .finish()
369    }
370}
371
372impl std::fmt::Debug for McpHttpServerConfig {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        f.debug_struct("McpHttpServerConfig")
375            .field("url", &sanitize_mcp_http_url_for_display(&self.url))
376            .field(
377                "headers",
378                &crate::mcp::headers::redact_headers(&self.headers),
379            )
380            .field("oauth", &self.oauth)
381            .field("enabled", &self.enabled)
382            .field("timeout", &self.timeout)
383            .finish()
384    }
385}
386
387pub(crate) fn validate_settings(settings: &Settings) -> anyhow::Result<()> {
388    validate_custom_provider_settings(settings)?;
389    validate_context_model_overrides(settings.context.as_ref())?;
390    validate_mcp_servers_settings(&settings.mcp_servers)?;
391    validate_lsp_settings(&settings.lsp)?;
392    settings.provider_stream.validate()?;
393    settings.subagents.validate()?;
394    settings.ttsr.validate()?;
395    Ok(())
396}
397
398fn validate_context_model_overrides(
399    context: Option<&crate::context::ContextBudget>,
400) -> anyhow::Result<()> {
401    let Some(context) = context else {
402        return Ok(());
403    };
404    for (key, model_override) in &context.model_overrides {
405        let Some((provider, model)) = key.split_once('/') else {
406            anyhow::bail!("context.model_overrides key '{key}' must use provider/model");
407        };
408        if provider.is_empty() || model.is_empty() {
409            anyhow::bail!("context.model_overrides key '{key}' must use non-empty provider/model");
410        }
411        if key
412            .chars()
413            .any(|ch| ch.is_ascii_whitespace() || ch.is_ascii_control())
414        {
415            anyhow::bail!(
416                "context.model_overrides key '{key}' must not contain ASCII whitespace or control characters"
417            );
418        }
419        if model_override.is_empty() {
420            anyhow::bail!(
421                "context.model_overrides.{key} must set at least one of max_tokens, reserve_tokens, or keep_recent_tokens"
422            );
423        }
424    }
425    Ok(())
426}
427
428pub(crate) fn validate_lsp_settings(settings: &LspSettings) -> anyhow::Result<()> {
429    if !(1..=MAX_LSP_DIAGNOSTICS_WAIT_MS).contains(&settings.diagnostics_wait_ms) {
430        anyhow::bail!(
431            "lsp.diagnostics_wait_ms must be between 1 and {MAX_LSP_DIAGNOSTICS_WAIT_MS} milliseconds"
432        );
433    }
434    if !(1..=MAX_LSP_IDLE_SHUTDOWN_MINUTES).contains(&settings.idle_shutdown_minutes) {
435        anyhow::bail!(
436            "lsp.idle_shutdown_minutes must be between 1 and {MAX_LSP_IDLE_SHUTDOWN_MINUTES} minutes"
437        );
438    }
439    for (name, config) in &settings.servers {
440        validate_lsp_server_name(name)?;
441        if config.command.trim().is_empty() {
442            anyhow::bail!("lsp.servers.{name}.command must not be empty");
443        }
444    }
445    Ok(())
446}
447
448fn validate_lsp_server_name(name: &str) -> anyhow::Result<()> {
449    if name.trim().is_empty() {
450        anyhow::bail!("lsp server name must not be empty");
451    }
452    if name.contains("__") {
453        anyhow::bail!("lsp server name '{name}' must not contain '__'");
454    }
455    Ok(())
456}
457
458pub(crate) fn validate_mcp_servers_settings(servers: &McpServersSettings) -> anyhow::Result<()> {
459    for (name, config) in servers {
460        validate_mcp_server_name(name)?;
461        match config {
462            McpServerConfig::Stdio(stdio) => {
463                if stdio.command.trim().is_empty() {
464                    anyhow::bail!("mcp_servers.{name}.command must not be empty");
465                }
466                validate_mcp_timeout(name, stdio.timeout)?;
467            }
468            McpServerConfig::Http(http) => validate_mcp_http_server(name, http)?,
469        }
470    }
471    Ok(())
472}
473
474fn validate_mcp_timeout(name: &str, timeout: Option<u64>) -> anyhow::Result<()> {
475    if let Some(timeout) = timeout
476        && !(1..=MAX_MCP_TIMEOUT_SECONDS).contains(&timeout)
477    {
478        anyhow::bail!(
479            "mcp_servers.{name}.timeout must be between 1 and {MAX_MCP_TIMEOUT_SECONDS} seconds"
480        );
481    }
482    Ok(())
483}
484
485fn sanitize_mcp_http_url_for_display(url: &str) -> String {
486    match reqwest::Url::parse(url) {
487        Ok(parsed) => {
488            let host = parsed.host_str().unwrap_or("<unknown>");
489            let port = parsed
490                .port()
491                .map(|port| format!(":{port}"))
492                .unwrap_or_default();
493            format!("{}://{}{}{}", parsed.scheme(), host, port, parsed.path())
494        }
495        Err(_) => "<invalid-url>".to_string(),
496    }
497}
498
499fn validate_mcp_http_server(name: &str, config: &McpHttpServerConfig) -> anyhow::Result<()> {
500    validate_mcp_http_url(name, &config.url)?;
501    validate_mcp_timeout(name, config.timeout)?;
502    if let Some(oauth) = &config.oauth {
503        validate_mcp_oauth_config(name, oauth, &config.headers)?;
504    }
505    for (header_name, header_value) in &config.headers {
506        validate_mcp_http_header_name(name, header_name)?;
507        let env_ref = crate::mcp::headers::parse_env_header_ref(header_value);
508        if crate::mcp::headers::is_env_header_ref_syntax(header_value) && env_ref.is_none() {
509            anyhow::bail!(
510                "mcp_servers.{name}.headers.{header_name} must use {{env:VAR_NAME}} with a valid environment variable name"
511            );
512        }
513        if crate::mcp::headers::is_sensitive_header(header_name) && env_ref.is_none() {
514            anyhow::bail!(
515                "mcp_servers.{name}.headers.{header_name} is sensitive and must use {{env:VAR_NAME}}"
516            );
517        }
518    }
519    Ok(())
520}
521
522fn validate_mcp_oauth_config(
523    name: &str,
524    oauth: &McpOAuthConfig,
525    headers: &BTreeMap<String, String>,
526) -> anyhow::Result<()> {
527    for header_name in headers.keys() {
528        if header_name.eq_ignore_ascii_case("authorization")
529            || header_name.eq_ignore_ascii_case("proxy-authorization")
530        {
531            anyhow::bail!(
532                "mcp_servers.{name}.headers.{header_name} must not be configured when mcp_servers.{name}.oauth is configured"
533            );
534        }
535    }
536    if let Some(client_id) = &oauth.client_id
537        && client_id.trim().is_empty()
538    {
539        anyhow::bail!("mcp_servers.{name}.oauth.client_id must not be empty");
540    }
541    for scope in &oauth.scopes {
542        if scope.trim().is_empty()
543            || scope
544                .bytes()
545                .any(|byte| !byte.is_ascii() || byte.is_ascii_control())
546        {
547            anyhow::bail!(
548                "mcp_servers.{name}.oauth.scopes entries must be non-empty printable ASCII"
549            );
550        }
551    }
552    if let Some(url) = &oauth.authorization_server {
553        validate_mcp_http_url_field(name, "oauth.authorization_server", url)?;
554    }
555    Ok(())
556}
557
558fn validate_mcp_http_url(name: &str, url: &str) -> anyhow::Result<()> {
559    validate_mcp_http_url_field(name, "url", url)
560}
561
562pub(crate) fn validate_mcp_http_url_field(
563    name: &str,
564    field: &str,
565    url: &str,
566) -> anyhow::Result<()> {
567    let parsed = reqwest::Url::parse(url)
568        .map_err(|_| anyhow::anyhow!("mcp_servers.{name}.{field} must be an absolute HTTP URL"))?;
569    if !parsed.username().is_empty() || parsed.password().is_some() {
570        anyhow::bail!("mcp_servers.{name}.{field} must not contain credentials");
571    }
572    match parsed.scheme() {
573        "https" => Ok(()),
574        "http" if is_loopback_http_host(parsed.host_str()) => Ok(()),
575        "http" => anyhow::bail!(
576            "mcp_servers.{name}.{field} must use https; http is allowed only for loopback hosts"
577        ),
578        _ => anyhow::bail!("mcp_servers.{name}.{field} must use http or https"),
579    }
580}
581
582fn is_loopback_http_host(host: Option<&str>) -> bool {
583    match host {
584        Some("localhost") => true,
585        Some(host) => host
586            .trim_matches(['[', ']'])
587            .parse::<IpAddr>()
588            .is_ok_and(|ip| ip.is_loopback()),
589        None => false,
590    }
591}
592
593fn validate_mcp_http_header_name(server_name: &str, header_name: &str) -> anyhow::Result<()> {
594    if header_name.is_empty() {
595        anyhow::bail!("mcp_servers.{server_name}.headers contains an empty header name");
596    }
597    if header_name
598        .bytes()
599        .any(|byte| !byte.is_ascii() || byte.is_ascii_control() || byte == b':' || byte == b' ')
600    {
601        anyhow::bail!(
602            "mcp_servers.{server_name}.headers.{header_name} must be visible ASCII without colon, spaces, or control characters"
603        );
604    }
605    Ok(())
606}
607
608pub(crate) fn validate_mcp_server_name(name: &str) -> anyhow::Result<()> {
609    if name.is_empty() {
610        anyhow::bail!("mcp server name must not be empty");
611    }
612    if name.contains("__") {
613        anyhow::bail!("mcp server name '{name}' must not contain '__'");
614    }
615    if !name
616        .bytes()
617        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
618    {
619        anyhow::bail!(
620            "mcp server name '{name}' must contain only ASCII letters, digits, '_' or '-'"
621        );
622    }
623    Ok(())
624}
625
626#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
627pub struct ProviderStreamSettings {
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub semantic_progress_timeout_seconds: Option<u64>,
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub subagent_semantic_progress_timeout_seconds: Option<u64>,
632}
633
634pub const DEFAULT_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS: u64 = 60;
635pub const DEFAULT_SUBAGENT_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS: u64 = 120;
636pub const MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS: u64 = 600;
637
638impl ProviderStreamSettings {
639    pub fn is_default(&self) -> bool {
640        self == &Self::default()
641    }
642
643    pub(crate) fn semantic_progress_timeout(&self) -> Duration {
644        Duration::from_secs(
645            self.semantic_progress_timeout_seconds
646                .unwrap_or(DEFAULT_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS),
647        )
648    }
649
650    pub(crate) fn subagent_semantic_progress_timeout(&self) -> Duration {
651        Duration::from_secs(
652            self.subagent_semantic_progress_timeout_seconds
653                .unwrap_or(DEFAULT_SUBAGENT_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS),
654        )
655    }
656
657    fn validate(&self) -> anyhow::Result<()> {
658        validate_provider_stream_timeout(
659            "provider_stream.semantic_progress_timeout_seconds",
660            self.semantic_progress_timeout_seconds,
661        )?;
662        validate_provider_stream_timeout(
663            "provider_stream.subagent_semantic_progress_timeout_seconds",
664            self.subagent_semantic_progress_timeout_seconds,
665        )
666    }
667}
668
669fn validate_provider_stream_timeout(field: &str, value: Option<u64>) -> anyhow::Result<()> {
670    if let Some(value) = value
671        && !(1..=MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS).contains(&value)
672    {
673        anyhow::bail!(
674            "{field} must be between 1 and {MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS} seconds"
675        );
676    }
677    Ok(())
678}
679
680#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
681pub struct TtsrSettings {
682    #[serde(default, skip_serializing_if = "is_false")]
683    pub enabled: bool,
684    #[serde(default, skip_serializing_if = "Vec::is_empty")]
685    pub rules: Vec<TtsrRuleSetting>,
686}
687
688impl TtsrSettings {
689    pub fn is_default(&self) -> bool {
690        self == &Self::default()
691    }
692
693    fn validate(&self) -> anyhow::Result<()> {
694        if self.rules.len() > 128 {
695            anyhow::bail!("ttsr.rules must contain at most 128 rules");
696        }
697        for (index, rule) in self.rules.iter().enumerate() {
698            if rule.pattern.trim().is_empty() {
699                anyhow::bail!("ttsr.rules[{index}].pattern must not be empty");
700            }
701            Regex::new(&rule.pattern).map_err(|_| {
702                anyhow::anyhow!("ttsr.rules[{index}].pattern must be a valid regex")
703            })?;
704            if rule.reminder.trim().is_empty() {
705                anyhow::bail!("ttsr.rules[{index}].reminder must not be empty");
706            }
707        }
708        Ok(())
709    }
710}
711
712#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
713pub struct TtsrRuleSetting {
714    pub pattern: String,
715    pub reminder: String,
716}
717
718#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
719pub struct SelectedModelSettings {
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub provider: Option<String>,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub model: Option<String>,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub thinking_level: Option<ThinkingLevel>,
726    #[serde(default, flatten)]
727    pub(crate) extra: BTreeMap<String, serde_json::Value>,
728}
729
730impl SelectedModelSettings {
731    pub fn is_default(&self) -> bool {
732        self == &Self::default()
733    }
734}
735
736#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
737pub struct ToolOutputCompressionSettings {
738    #[serde(default)]
739    pub enabled: bool,
740}
741
742impl ToolOutputCompressionSettings {
743    pub fn is_default(&self) -> bool {
744        self == &Self::default()
745    }
746}
747
748#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
749pub struct ToolSettings {
750    #[serde(default, skip_serializing_if = "ReadToolSettings::is_default")]
751    pub read: ReadToolSettings,
752    #[serde(default, skip_serializing_if = "ViewImageToolSettings::is_default")]
753    pub view_image: ViewImageToolSettings,
754    #[serde(default, skip_serializing_if = "HashEditToolSettings::is_default")]
755    pub hash_edit: HashEditToolSettings,
756    #[serde(default, skip_serializing_if = "WriteToolSettings::is_default")]
757    pub write: WriteToolSettings,
758    #[serde(
759        default,
760        alias = "ffgrep",
761        skip_serializing_if = "GrepToolSettings::is_default"
762    )]
763    pub grep: GrepToolSettings,
764    #[serde(
765        default,
766        alias = "fffind",
767        skip_serializing_if = "FindToolSettings::is_default"
768    )]
769    pub find: FindToolSettings,
770    #[serde(default, skip_serializing_if = "ListFilesToolSettings::is_default")]
771    pub list_files: ListFilesToolSettings,
772    #[serde(default, skip_serializing_if = "RepoMapToolSettings::is_default")]
773    pub repo_map: RepoMapToolSettings,
774    #[serde(default, skip_serializing_if = "AstGrepToolSettings::is_default")]
775    pub ast_grep: AstGrepToolSettings,
776    #[serde(default, skip_serializing_if = "BashToolSettings::is_default")]
777    pub bash: BashToolSettings,
778    #[serde(
779        default,
780        alias = "parallel_subagents",
781        skip_serializing_if = "SubagentsToolSettings::is_default"
782    )]
783    pub subagents: SubagentsToolSettings,
784    #[serde(
785        default,
786        skip_serializing_if = "ToolOutputCompressionSettings::is_default"
787    )]
788    pub output_compression: ToolOutputCompressionSettings,
789    #[serde(default, skip_serializing_if = "Vec::is_empty")]
790    pub disabled: Vec<String>,
791}
792
793impl ToolSettings {
794    pub fn is_default(&self) -> bool {
795        self == &Self::default()
796    }
797}
798
799pub const DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 2;
800pub const MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 5;
801
802#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
803pub struct SubagentsSettings {
804    #[serde(default, skip_serializing_if = "Vec::is_empty")]
805    pub disabled: Vec<String>,
806    #[serde(default, skip_serializing_if = "Option::is_none")]
807    pub schema_validation_max_retries: Option<u64>,
808    #[serde(default, flatten)]
809    pub(crate) extra: BTreeMap<String, serde_json::Value>,
810}
811
812impl SubagentsSettings {
813    pub fn is_default(&self) -> bool {
814        self == &Self::default()
815    }
816
817    pub(crate) fn schema_validation_max_retries(&self) -> u64 {
818        self.schema_validation_max_retries
819            .unwrap_or(DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES)
820    }
821
822    fn validate(&self) -> anyhow::Result<()> {
823        if let Some(retries) = self.schema_validation_max_retries
824            && retries > MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES
825        {
826            anyhow::bail!(
827                "subagents.schema_validation_max_retries must be between 0 and {MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES}"
828            );
829        }
830        Ok(())
831    }
832}
833
834#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
835pub struct ModelsSettings {
836    #[serde(default, skip_serializing_if = "Vec::is_empty")]
837    pub disabled: Vec<String>,
838    #[serde(default, flatten)]
839    pub(crate) extra: BTreeMap<String, serde_json::Value>,
840}
841
842impl ModelsSettings {
843    pub fn is_default(&self) -> bool {
844        self == &Self::default()
845    }
846}
847
848macro_rules! absolute_path_tool_settings {
849    ($($name:ident),+ $(,)?) => {
850        $(
851            #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
852            pub struct $name {
853                #[serde(default = "default_true")]
854                pub absolute_paths: bool,
855            }
856
857            impl Default for $name {
858                fn default() -> Self {
859                    Self {
860                        absolute_paths: true,
861                    }
862                }
863            }
864            impl $name {
865                pub fn is_default(&self) -> bool {
866                    self == &Self::default()
867                }
868            }
869        )+
870    };
871}
872
873absolute_path_tool_settings!(
874    ReadToolSettings,
875    HashEditToolSettings,
876    WriteToolSettings,
877    GrepToolSettings,
878    FindToolSettings,
879    ListFilesToolSettings,
880    RepoMapToolSettings,
881    AstGrepToolSettings,
882);
883
884#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
885pub struct SubagentsToolSettings {
886    #[serde(default = "default_true")]
887    pub absolute_paths: bool,
888    #[serde(default = "default_subagent_max_depth")]
889    pub max_depth: usize,
890}
891
892impl Default for SubagentsToolSettings {
893    fn default() -> Self {
894        Self {
895            absolute_paths: true,
896            max_depth: DEFAULT_SUBAGENT_MAX_DEPTH,
897        }
898    }
899}
900
901impl SubagentsToolSettings {
902    pub fn is_default(&self) -> bool {
903        self == &Self::default()
904    }
905}
906
907fn default_subagent_max_depth() -> usize {
908    DEFAULT_SUBAGENT_MAX_DEPTH
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
912pub struct BashToolSettings {
913    #[serde(default = "default_true")]
914    pub absolute_paths: bool,
915    #[serde(default = "default_true")]
916    pub shell_expansion: bool,
917}
918
919impl Default for BashToolSettings {
920    fn default() -> Self {
921        Self {
922            absolute_paths: true,
923            shell_expansion: true,
924        }
925    }
926}
927
928impl BashToolSettings {
929    pub fn is_default(&self) -> bool {
930        self == &Self::default()
931    }
932}
933
934fn default_true() -> bool {
935    true
936}
937
938pub const DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
939pub const MAX_VIEW_IMAGE_MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
940
941#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
942pub struct ViewImageToolSettings {
943    #[serde(default, skip_serializing_if = "Option::is_none")]
944    pub vision_model: Option<ViewImageVisionModelSettings>,
945    #[serde(default = "default_true")]
946    pub absolute_paths: bool,
947    #[serde(default = "default_view_image_max_image_bytes")]
948    pub max_image_bytes: u64,
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
952pub struct ViewImageVisionModelSettings {
953    pub provider: String,
954    pub model: String,
955}
956
957impl ViewImageToolSettings {
958    pub fn is_default(&self) -> bool {
959        self == &Self::default()
960    }
961}
962
963impl Default for ViewImageToolSettings {
964    fn default() -> Self {
965        Self {
966            vision_model: None,
967            absolute_paths: true,
968            max_image_bytes: DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES,
969        }
970    }
971}
972
973pub fn default_view_image_max_image_bytes() -> u64 {
974    DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES
975}
976
977pub fn validate_view_image_identifier(field: &str, value: &str) -> anyhow::Result<String> {
978    let trimmed = value.trim();
979    if trimmed.is_empty() {
980        anyhow::bail!("tools.view_image.vision_model.{field} must not be empty");
981    }
982    if trimmed
983        .chars()
984        .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
985    {
986        anyhow::bail!(
987            "tools.view_image.vision_model.{field} must not contain ASCII whitespace or control characters"
988        );
989    }
990    if crate::config::looks_like_secret_value(trimmed) {
991        anyhow::bail!("tools.view_image.vision_model.{field} must not look like a secret value");
992    }
993    Ok(trimmed.to_string())
994}
995
996pub fn validate_view_image_max_image_bytes(value: u64) -> anyhow::Result<u64> {
997    if !(1..=MAX_VIEW_IMAGE_MAX_IMAGE_BYTES).contains(&value) {
998        anyhow::bail!(
999            "tools.view_image.max_image_bytes must be between 1 and {MAX_VIEW_IMAGE_MAX_IMAGE_BYTES}"
1000        );
1001    }
1002    Ok(value)
1003}
1004
1005pub(crate) fn clamp_subagent_max_depth(max_depth: usize) -> usize {
1006    max_depth.clamp(1, MAX_SUBAGENT_MAX_DEPTH)
1007}
1008
1009#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1010pub struct IntegrationsSettings {
1011    #[serde(default, skip_serializing_if = "HerdrSettings::is_default")]
1012    pub herdr: HerdrSettings,
1013    #[serde(default, flatten)]
1014    pub(crate) extra: BTreeMap<String, serde_json::Value>,
1015}
1016
1017impl IntegrationsSettings {
1018    pub fn is_default(&self) -> bool {
1019        self == &Self::default()
1020    }
1021}
1022
1023#[derive(Debug, Default, Deserialize)]
1024struct SettingsWire {
1025    #[serde(default)]
1026    selected_model: SelectedModelSettings,
1027    #[serde(default)]
1028    openai_codex: OpenAiCodexSettings,
1029    #[serde(default)]
1030    openai_responses: OpenAiResponsesSettings,
1031    #[serde(default)]
1032    provider: Option<String>,
1033    #[serde(default)]
1034    model: Option<String>,
1035    #[serde(default)]
1036    no_color: Option<bool>,
1037    #[serde(default)]
1038    anthropic_cache_ttl: Option<AnthropicCacheTtl>,
1039    #[serde(default = "default_file_autocomplete_respects_gitignore")]
1040    file_autocomplete_respects_gitignore: bool,
1041    #[serde(default)]
1042    context: Option<crate::context::ContextBudget>,
1043    #[serde(default)]
1044    session_titles: SessionTitleSettings,
1045    #[serde(default)]
1046    compaction: CompactionSettings,
1047    #[serde(default)]
1048    tools: ToolSettings,
1049    #[serde(default)]
1050    subagents: SubagentsSettings,
1051    #[serde(default)]
1052    models: ModelsSettings,
1053    #[serde(default)]
1054    hooks: HookSettings,
1055    #[serde(default)]
1056    instructions: InstructionsSettings,
1057    #[serde(default)]
1058    skills: SkillsSettings,
1059    #[serde(default)]
1060    custom_providers: BTreeMap<String, CustomProviderConfig>,
1061    #[serde(default)]
1062    mcp_servers: McpServersSettings,
1063    #[serde(default)]
1064    lsp: LspSettings,
1065    #[serde(default)]
1066    integrations: IntegrationsSettings,
1067    #[serde(default)]
1068    herdr: HerdrSettings,
1069    #[serde(default)]
1070    tui: TuiSettings,
1071    #[serde(default)]
1072    provider_stream: ProviderStreamSettings,
1073    #[serde(default)]
1074    ttsr: TtsrSettings,
1075    #[serde(default)]
1076    thinking_level: Option<ThinkingLevel>,
1077    #[serde(default)]
1078    selected_primary_agent: Option<String>,
1079    #[serde(default)]
1080    disabled_skills: Vec<String>,
1081}
1082
1083impl<'de> Deserialize<'de> for Settings {
1084    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1085    where
1086        D: serde::Deserializer<'de>,
1087    {
1088        let value = serde_json::Value::deserialize(deserializer)?;
1089        let mut wire: SettingsWire =
1090            serde_json::from_value(value.clone()).map_err(de::Error::custom)?;
1091        let root = value.as_object();
1092        if !has_path(root, &["selected_model", "provider"]) {
1093            wire.selected_model.provider = wire.provider;
1094        }
1095        if !has_path(root, &["selected_model", "model"]) {
1096            wire.selected_model.model = wire.model;
1097        }
1098        if !has_path(root, &["selected_model", "thinking_level"]) {
1099            wire.selected_model.thinking_level = wire.thinking_level;
1100        }
1101        if !has_path(root, &["integrations", "herdr"]) && wire.integrations.herdr.is_default() {
1102            wire.integrations.herdr = wire.herdr;
1103        }
1104        if !has_path(root, &["skills", "disabled"]) && wire.skills.disabled.is_empty() {
1105            wire.skills.disabled = wire.disabled_skills;
1106        }
1107
1108        let settings = Self {
1109            selected_model: wire.selected_model,
1110            openai_codex: wire.openai_codex,
1111            openai_responses: wire.openai_responses,
1112            no_color: wire.no_color,
1113            anthropic_cache_ttl: wire.anthropic_cache_ttl,
1114            file_autocomplete_respects_gitignore: wire.file_autocomplete_respects_gitignore,
1115            context: wire.context,
1116            session_titles: wire.session_titles,
1117            compaction: wire.compaction,
1118            tools: wire.tools,
1119            subagents: wire.subagents,
1120            models: wire.models,
1121            hooks: wire.hooks,
1122            instructions: wire.instructions,
1123            skills: wire.skills,
1124            custom_providers: wire.custom_providers,
1125            mcp_servers: wire.mcp_servers,
1126            lsp: wire.lsp,
1127            integrations: wire.integrations,
1128            tui: wire.tui,
1129            provider_stream: wire.provider_stream,
1130            ttsr: wire.ttsr,
1131            selected_primary_agent: wire.selected_primary_agent,
1132        };
1133        Ok(settings)
1134    }
1135}
1136
1137fn has_path(root: Option<&serde_json::Map<String, serde_json::Value>>, path: &[&str]) -> bool {
1138    let Some(mut current) = root.and_then(|object| object.get(path[0])) else {
1139        return false;
1140    };
1141    for key in &path[1..] {
1142        let Some(next) = current.as_object().and_then(|object| object.get(*key)) else {
1143            return false;
1144        };
1145        current = next;
1146    }
1147    true
1148}
1149
1150#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1151pub struct HerdrSettings {
1152    #[serde(default)]
1153    pub enabled: bool,
1154    #[serde(default, flatten)]
1155    pub(crate) extra: BTreeMap<String, serde_json::Value>,
1156}
1157
1158impl HerdrSettings {
1159    pub fn is_default(&self) -> bool {
1160        self == &Self::default()
1161    }
1162}
1163
1164#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1165pub struct TuiSettings {
1166    #[serde(default, flatten)]
1167    pub(crate) extra: BTreeMap<String, serde_json::Value>,
1168}
1169
1170impl TuiSettings {
1171    pub fn is_default(&self) -> bool {
1172        self == &Self::default()
1173    }
1174}
1175
1176#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1177pub struct InstructionsSettings {
1178    #[serde(default, skip_serializing_if = "is_false")]
1179    pub subdir_discovery: bool,
1180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1181    pub additional_markdown_paths: Vec<PathBuf>,
1182    #[serde(default, flatten)]
1183    pub(crate) extra: BTreeMap<String, serde_json::Value>,
1184}
1185
1186impl InstructionsSettings {
1187    pub fn is_default(&self) -> bool {
1188        self == &Self::default()
1189    }
1190}
1191
1192#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1193pub struct SkillsSettings {
1194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1195    pub additional_paths: Vec<PathBuf>,
1196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1197    pub disabled: Vec<String>,
1198    #[serde(default, flatten)]
1199    pub(crate) extra: BTreeMap<String, serde_json::Value>,
1200}
1201
1202impl SkillsSettings {
1203    pub fn is_default(&self) -> bool {
1204        self == &Self::default()
1205    }
1206}
1207
1208#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1209pub struct CompactionSettings {
1210    #[serde(default, skip_serializing_if = "Option::is_none")]
1211    pub provider: Option<String>,
1212    #[serde(default, skip_serializing_if = "Option::is_none")]
1213    pub model: Option<String>,
1214}
1215
1216#[derive(Debug, Clone, PartialEq, Eq)]
1217pub(crate) struct CompactionConfig {
1218    pub(crate) provider: String,
1219    pub(crate) model: String,
1220}
1221
1222impl CompactionSettings {
1223    pub fn is_default(&self) -> bool {
1224        self == &Self::default()
1225    }
1226
1227    pub(crate) fn resolve_config(
1228        &self,
1229        active_provider: &str,
1230        active_model: &str,
1231    ) -> Result<CompactionConfig, String> {
1232        let provider = self.provider.as_deref().map(str::trim);
1233        let model = self.model.as_deref().map(str::trim);
1234        match (provider, model) {
1235            (None, None) => Ok(CompactionConfig {
1236                provider: non_blank_active(active_provider, "provider")?.to_string(),
1237                model: non_blank_active(active_model, "model")?.to_string(),
1238            }),
1239            (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
1240                Ok(CompactionConfig {
1241                    provider: provider.to_string(),
1242                    model: model.to_string(),
1243                })
1244            }
1245            _ => Err("compaction.provider and compaction.model must either both be configured and non-blank, or both be omitted to inherit the active provider/model".to_string()),
1246        }
1247    }
1248}
1249
1250fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
1251    let trimmed = value.trim();
1252    if trimmed.is_empty() {
1253        Err(format!(
1254            "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
1255        ))
1256    } else {
1257        Ok(trimmed)
1258    }
1259}
1260
1261#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1262pub struct SessionTitleSettings {
1263    #[serde(default)]
1264    pub enabled: bool,
1265    #[serde(default, skip_serializing_if = "Option::is_none")]
1266    pub provider: Option<String>,
1267    #[serde(default, skip_serializing_if = "Option::is_none")]
1268    pub model: Option<String>,
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq)]
1272pub(crate) struct SessionTitleConfig {
1273    pub(crate) provider: String,
1274    pub(crate) model: String,
1275}
1276
1277impl SessionTitleSettings {
1278    pub fn is_default(&self) -> bool {
1279        self == &Self::default()
1280    }
1281
1282    pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
1283        if !self.enabled {
1284            return Ok(None);
1285        }
1286        let provider = self
1287            .provider
1288            .as_deref()
1289            .map(str::trim)
1290            .filter(|value| !value.is_empty())
1291            .ok_or_else(|| "session_titles.enabled is true but session_titles.provider is missing or blank; set an explicit title provider or disable session_titles".to_string())?;
1292        let model = self
1293            .model
1294            .as_deref()
1295            .map(str::trim)
1296            .filter(|value| !value.is_empty())
1297            .ok_or_else(|| "session_titles.enabled is true but session_titles.model is missing or blank; set an explicit title model or disable session_titles".to_string())?;
1298        Ok(Some(SessionTitleConfig {
1299            provider: provider.to_string(),
1300            model: model.to_string(),
1301        }))
1302    }
1303}
1304
1305pub(crate) fn upsert_custom_provider(
1306    paths: &McPaths,
1307    id: &str,
1308    mut config: CustomProviderConfig,
1309) -> anyhow::Result<()> {
1310    let id = validate_custom_provider_id(id)?;
1311    update_settings_preserving_unknown_top_level_fields(paths, |settings| {
1312        if let Some(existing) = settings.custom_providers.get(&id) {
1313            config.models_dev_provider = existing.models_dev_provider.clone();
1314            config.use_responses_endpoint = existing.use_responses_endpoint;
1315            config.reasoning_protocol = existing.reasoning_protocol;
1316            config.extra_models = existing.extra_models.clone();
1317        }
1318        settings.custom_providers.insert(id, config);
1319    })
1320}
1321
1322pub(crate) fn remove_custom_provider(paths: &McPaths, id: &str) -> anyhow::Result<bool> {
1323    let id = validate_custom_provider_id(id)?;
1324    let mut removed = false;
1325    update_settings_preserving_unknown_top_level_fields(paths, |settings| {
1326        removed = settings.custom_providers.remove(&id).is_some();
1327    })?;
1328    Ok(removed)
1329}
1330
1331pub(crate) const SETTINGS_SCHEMA_RELATIVE_REF: &str = "./state/settings.schema.json";
1332const SETTINGS_SCHEMA_FILE_NAME: &str = "settings.schema.json";
1333
1334pub(crate) fn ensure_settings_schema_files(paths: &McPaths) -> anyhow::Result<()> {
1335    let schema_path = paths.state.join(SETTINGS_SCHEMA_FILE_NAME);
1336    let schema = schemars::schema_for!(Settings);
1337    write_file_if_changed(
1338        &schema_path,
1339        serde_json::to_string_pretty(&schema)?.as_bytes(),
1340    )?;
1341
1342    let settings_lock = settings_file_lock(&paths.settings_file)?;
1343    let _settings_guard = settings_lock
1344        .lock()
1345        .map_err(|_| anyhow::anyhow!("settings lock was poisoned"))?;
1346    let _file_guard = CrossProcessFileLock::acquire(&paths.settings_file)?;
1347    let original = match fs::read_to_string(&paths.settings_file) {
1348        Ok(text) => text,
1349        Err(error) if error.kind() == io::ErrorKind::NotFound => {
1350            let mut raw = serde_json::json!({});
1351            insert_default_schema_ref_if_absent(&mut raw);
1352            atomic_write(
1353                &paths.settings_file,
1354                serde_json::to_string_pretty(&raw)?.as_bytes(),
1355            )?;
1356            return Ok(());
1357        }
1358        Err(error) => {
1359            return Err(error)
1360                .with_context(|| format!("failed to read {}", paths.settings_file.display()));
1361        }
1362    };
1363
1364    let mut raw: serde_json::Value = match serde_json::from_str(&original) {
1365        Ok(value) => value,
1366        Err(_) => return Ok(()),
1367    };
1368    if !raw.is_object() {
1369        return Ok(());
1370    }
1371    let settings: Settings = match serde_json::from_value(raw.clone()) {
1372        Ok(settings) => settings,
1373        Err(_) => return Ok(()),
1374    };
1375    if validate_settings(&settings).is_err() {
1376        return Ok(());
1377    }
1378    if insert_default_schema_ref_if_absent(&mut raw) {
1379        atomic_write(
1380            &paths.settings_file,
1381            serde_json::to_string_pretty(&raw)?.as_bytes(),
1382        )?;
1383    }
1384    Ok(())
1385}
1386
1387fn write_file_if_changed(path: &Path, bytes: &[u8]) -> anyhow::Result<bool> {
1388    match fs::read(path) {
1389        Ok(existing) if existing == bytes => Ok(false),
1390        Ok(_) => {
1391            atomic_write(path, bytes)?;
1392            Ok(true)
1393        }
1394        Err(error) if error.kind() == io::ErrorKind::NotFound => {
1395            atomic_write(path, bytes)?;
1396            Ok(true)
1397        }
1398        Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
1399    }
1400}
1401
1402fn insert_default_schema_ref_if_absent(raw: &mut serde_json::Value) -> bool {
1403    let Some(object) = raw.as_object_mut() else {
1404        return false;
1405    };
1406    if object.contains_key("$schema") {
1407        return false;
1408    }
1409    object.insert(
1410        "$schema".to_string(),
1411        serde_json::Value::String(SETTINGS_SCHEMA_RELATIVE_REF.to_string()),
1412    );
1413    true
1414}
1415
1416pub(crate) fn disabled_skill_names_from_settings(settings: &Settings) -> BTreeSet<String> {
1417    normalized_name_set(&settings.skills.disabled)
1418}
1419
1420pub(crate) fn disabled_tool_names_from_settings(settings: &Settings) -> BTreeSet<String> {
1421    normalized_name_set(&settings.tools.disabled)
1422}
1423
1424pub(crate) fn disabled_subagent_profile_names_from_settings(
1425    settings: &Settings,
1426) -> BTreeSet<String> {
1427    normalized_name_set(&settings.subagents.disabled)
1428}
1429
1430pub(crate) fn disabled_model_ids_from_settings(settings: &Settings) -> BTreeSet<String> {
1431    normalized_name_set(&settings.models.disabled)
1432}
1433
1434fn normalized_name_set(names: &[String]) -> BTreeSet<String> {
1435    names
1436        .iter()
1437        .map(|name| name.trim())
1438        .filter(|name| !name.is_empty())
1439        .map(ToString::to_string)
1440        .collect()
1441}
1442
1443fn disabled_names_from_settings(settings: &Settings, kind: SettingsListKind) -> BTreeSet<String> {
1444    match kind {
1445        SettingsListKind::Skills => disabled_skill_names_from_settings(settings),
1446        SettingsListKind::Tools => disabled_tool_names_from_settings(settings),
1447        SettingsListKind::Subagents => disabled_subagent_profile_names_from_settings(settings),
1448        SettingsListKind::Models => disabled_model_ids_from_settings(settings),
1449    }
1450}
1451
1452#[cfg_attr(not(test), allow(dead_code))]
1453pub(crate) fn disabled_skill_names(paths: &McPaths) -> anyhow::Result<BTreeSet<String>> {
1454    Ok(disabled_skill_names_from_settings(&read_settings(paths)?))
1455}
1456
1457pub(crate) fn selected_primary_agent(paths: &McPaths) -> anyhow::Result<Option<String>> {
1458    Ok(read_settings(paths)?.selected_primary_agent)
1459}
1460
1461pub(crate) fn load_config_with_settings(
1462    paths: McPaths,
1463    cli: super::CliConfigOverrides,
1464) -> anyhow::Result<(super::EffectiveConfig, Settings)> {
1465    let settings = read_settings(&paths)?;
1466    let config = super::EffectiveConfig::from_loaded_settings(paths, cli, settings.clone())?;
1467    Ok((config, settings))
1468}
1469
1470pub(crate) fn set_thinking_level(
1471    paths: &McPaths,
1472    level: ThinkingLevel,
1473) -> anyhow::Result<ThinkingLevel> {
1474    update_settings_preserving_unknown_top_level_fields(paths, |settings| {
1475        settings.selected_model.thinking_level = Some(level);
1476    })?;
1477    Ok(level)
1478}
1479
1480pub(crate) fn set_selected_primary_agent(
1481    paths: &McPaths,
1482    selected: Option<&str>,
1483) -> anyhow::Result<Option<String>> {
1484    let selected = selected
1485        .map(crate::primary_agents::validate_primary_agent_id)
1486        .transpose()?;
1487    update_settings_preserving_unknown_top_level_fields(paths, |settings| {
1488        settings.selected_primary_agent = selected.clone();
1489    })?;
1490    Ok(selected)
1491}
1492
1493#[cfg_attr(not(test), allow(dead_code))]
1494pub(crate) fn set_skill_disabled(
1495    paths: &McPaths,
1496    skill_name: &str,
1497    disabled: bool,
1498) -> anyhow::Result<BTreeSet<String>> {
1499    set_skill_disabled_for_scope(paths, SettingsScope::Global, skill_name, disabled)
1500}
1501
1502pub(crate) fn set_skill_disabled_for_scope(
1503    paths: &McPaths,
1504    scope: SettingsScope,
1505    skill_name: &str,
1506    disabled: bool,
1507) -> anyhow::Result<BTreeSet<String>> {
1508    update_disabled_name_for_scope(paths, scope, SettingsListKind::Skills, skill_name, disabled)
1509}
1510
1511pub(crate) fn set_tool_disabled(
1512    paths: &McPaths,
1513    scope: SettingsScope,
1514    tool_name: &str,
1515    disabled: bool,
1516) -> anyhow::Result<BTreeSet<String>> {
1517    update_disabled_name_for_scope(paths, scope, SettingsListKind::Tools, tool_name, disabled)
1518}
1519
1520pub(crate) fn set_subagent_profile_disabled(
1521    paths: &McPaths,
1522    scope: SettingsScope,
1523    profile_id: &str,
1524    disabled: bool,
1525) -> anyhow::Result<BTreeSet<String>> {
1526    update_disabled_name_for_scope(
1527        paths,
1528        scope,
1529        SettingsListKind::Subagents,
1530        profile_id,
1531        disabled,
1532    )
1533}
1534
1535pub(crate) fn set_model_disabled_for_scope(
1536    paths: &McPaths,
1537    scope: SettingsScope,
1538    model_id: &str,
1539    disabled: bool,
1540) -> anyhow::Result<BTreeSet<String>> {
1541    update_disabled_name_for_scope(paths, scope, SettingsListKind::Models, model_id, disabled)
1542}
1543
1544fn update_disabled_name_for_scope(
1545    paths: &McPaths,
1546    scope: SettingsScope,
1547    kind: SettingsListKind,
1548    name: &str,
1549    disabled: bool,
1550) -> anyhow::Result<BTreeSet<String>> {
1551    let name = name.trim();
1552    if name.is_empty() {
1553        anyhow::bail!("setting name must be non-empty");
1554    }
1555    let seed_from_effective =
1556        scope == SettingsScope::Project && !scope_has_disabled_list(paths, scope, kind)?;
1557    let mut effective = if seed_from_effective {
1558        disabled_names_from_settings(&read_settings(paths)?, kind)
1559    } else {
1560        disabled_names_from_settings(&read_settings_for_scope(paths, scope)?, kind)
1561    };
1562    if disabled {
1563        effective.insert(name.to_string());
1564    } else {
1565        effective.remove(name);
1566    }
1567    let list = effective.iter().cloned().collect::<Vec<_>>();
1568    update_settings_for_scope_preserving_unknown_top_level_fields(paths, scope, |settings| {
1569        match kind {
1570            SettingsListKind::Skills => settings.skills.disabled = list.clone(),
1571            SettingsListKind::Tools => settings.tools.disabled = list.clone(),
1572            SettingsListKind::Subagents => settings.subagents.disabled = list.clone(),
1573            SettingsListKind::Models => settings.models.disabled = list.clone(),
1574        }
1575    })?;
1576    if scope == SettingsScope::Project && list.is_empty() {
1577        preserve_explicit_empty_disabled_list(paths, scope, kind)?;
1578    }
1579    Ok(effective)
1580}
1581
1582pub(crate) fn disabled_names_for_modal_scope(
1583    paths: &McPaths,
1584    scope: SettingsScope,
1585    kind: SettingsListKind,
1586) -> anyhow::Result<BTreeSet<String>> {
1587    if scope == SettingsScope::Project && !scope_has_disabled_list(paths, scope, kind)? {
1588        return Ok(disabled_names_from_settings(&read_settings(paths)?, kind));
1589    }
1590    Ok(disabled_names_from_settings(
1591        &read_settings_for_scope(paths, scope)?,
1592        kind,
1593    ))
1594}
1595
1596pub(crate) fn scope_has_disabled_list(
1597    paths: &McPaths,
1598    scope: SettingsScope,
1599    kind: SettingsListKind,
1600) -> anyhow::Result<bool> {
1601    let raw = read_settings_json_or_empty(&settings_path_for_scope(paths, scope))?;
1602    Ok(match kind {
1603        SettingsListKind::Skills => has_path(raw.as_object(), &["skills", "disabled"]),
1604        SettingsListKind::Tools => has_path(raw.as_object(), &["tools", "disabled"]),
1605        SettingsListKind::Subagents => has_path(raw.as_object(), &["subagents", "disabled"]),
1606        SettingsListKind::Models => has_path(raw.as_object(), &["models", "disabled"]),
1607    })
1608}
1609
1610pub(crate) fn set_mcp_server_enabled(
1611    paths: &McPaths,
1612    name: &str,
1613    enabled: bool,
1614) -> anyhow::Result<()> {
1615    validate_mcp_server_name(name)?;
1616    let name = name.to_string();
1617    let mut found = false;
1618    update_settings_preserving_unknown_top_level_fields(paths, |settings| {
1619        if let Some(config) = settings.mcp_servers.get_mut(&name) {
1620            config.set_enabled(enabled);
1621            found = true;
1622        }
1623    })?;
1624    if !found {
1625        anyhow::bail!("mcp server not found: {name}");
1626    }
1627    Ok(())
1628}
1629
1630pub(crate) fn set_selected_model(
1631    paths: &McPaths,
1632    provider: &str,
1633    model: &str,
1634) -> anyhow::Result<()> {
1635    update_settings_preserving_unknown_top_level_fields(paths, |settings| {
1636        settings.selected_model.provider = Some(provider.to_string());
1637        settings.selected_model.model = Some(model.to_string());
1638    })
1639}
1640
1641pub(crate) fn update_settings_preserving_unknown_top_level_fields(
1642    paths: &McPaths,
1643    mutate: impl FnOnce(&mut Settings),
1644) -> anyhow::Result<()> {
1645    update_settings_for_scope_preserving_unknown_top_level_fields(
1646        paths,
1647        SettingsScope::Global,
1648        mutate,
1649    )
1650}
1651
1652pub(crate) fn read_settings_for_scope(
1653    paths: &McPaths,
1654    scope: SettingsScope,
1655) -> anyhow::Result<Settings> {
1656    let raw = read_settings_json_or_empty(&settings_path_for_scope(paths, scope))?;
1657    let settings: Settings = serde_json::from_value(raw)?;
1658    validate_settings(&settings)?;
1659    Ok(settings)
1660}
1661
1662pub(crate) fn update_settings_for_scope_preserving_unknown_top_level_fields(
1663    paths: &McPaths,
1664    scope: SettingsScope,
1665    mutate: impl FnOnce(&mut Settings),
1666) -> anyhow::Result<()> {
1667    prepare_settings_scope_dir(paths, scope)?;
1668    let target = settings_path_for_scope(paths, scope);
1669    let settings_lock = settings_file_lock(&target)?;
1670    let _settings_guard = settings_lock
1671        .lock()
1672        .map_err(|_| anyhow::anyhow!("settings lock was poisoned"))?;
1673    let _file_guard = CrossProcessFileLock::acquire(&target)?;
1674    let original = read_settings_text_or_empty(&target)?;
1675    let mut raw: serde_json::Value = serde_json::from_str(&original)?;
1676    if !raw.is_object() {
1677        raw = serde_json::json!({});
1678    }
1679    let mut settings: Settings = serde_json::from_value(raw.clone())?;
1680    mutate(&mut settings);
1681    validate_settings(&settings)?;
1682    update_raw_from_settings(&mut raw, &settings, scope)?;
1683    atomic_write(&target, serde_json::to_string_pretty(&raw)?.as_bytes())?;
1684    Ok(())
1685}
1686
1687fn settings_path_for_scope(paths: &McPaths, scope: SettingsScope) -> PathBuf {
1688    match scope {
1689        SettingsScope::Global => paths.settings_file.clone(),
1690        SettingsScope::Project => paths.project_settings_file.clone(),
1691    }
1692}
1693
1694fn prepare_settings_scope_dir(paths: &McPaths, scope: SettingsScope) -> anyhow::Result<()> {
1695    match scope {
1696        SettingsScope::Global => fs::create_dir_all(&paths.root)?,
1697        SettingsScope::Project => {
1698            validate_project_settings_target(paths)?;
1699            if let Some(parent) = paths.project_settings_file.parent() {
1700                fs::create_dir_all(parent)?;
1701            }
1702        }
1703    }
1704    Ok(())
1705}
1706
1707fn validate_project_settings_target(paths: &McPaths) -> anyhow::Result<()> {
1708    let settings = &paths.project_settings_file;
1709    let Some(project_dir) = settings.parent().and_then(Path::parent) else {
1710        anyhow::bail!(
1711            "project settings path has no project directory: {}",
1712            settings.display()
1713        );
1714    };
1715    let marker_dir = project_dir.join(".magi-code");
1716    let canonical_project = project_dir.canonicalize().with_context(|| {
1717        format!(
1718            "failed to canonicalize project dir {}",
1719            project_dir.display()
1720        )
1721    })?;
1722    if marker_dir.exists() {
1723        let canonical_marker = marker_dir.canonicalize().with_context(|| {
1724            format!(
1725                "failed to canonicalize project config dir {}",
1726                marker_dir.display()
1727            )
1728        })?;
1729        if !canonical_marker.starts_with(&canonical_project) {
1730            anyhow::bail!("project config dir escapes cwd: {}", marker_dir.display());
1731        }
1732    }
1733    if settings.exists() {
1734        let canonical_settings = settings.canonicalize().with_context(|| {
1735            format!(
1736                "failed to canonicalize project settings file {}",
1737                settings.display()
1738            )
1739        })?;
1740        if !canonical_settings.starts_with(&canonical_project) {
1741            anyhow::bail!("project settings file escapes cwd: {}", settings.display());
1742        }
1743    }
1744    Ok(())
1745}
1746
1747fn update_raw_from_settings(
1748    raw: &mut serde_json::Value,
1749    settings: &Settings,
1750    scope: SettingsScope,
1751) -> anyhow::Result<()> {
1752    let openai_codex = serde_json::to_value(&settings.openai_codex)?;
1753    let selected_model = serde_json::to_value(&settings.selected_model)?;
1754    let custom = serde_json::to_value(&settings.custom_providers)?;
1755    let mcp_servers = serde_json::to_value(&settings.mcp_servers)?;
1756    let lsp = serde_json::to_value(&settings.lsp)?;
1757    let session_titles = serde_json::to_value(&settings.session_titles)?;
1758    let compaction = serde_json::to_value(&settings.compaction)?;
1759    let context = serde_json::to_value(&settings.context)?;
1760    let tools = tools_value_preserving_unknowns(raw, &settings.tools)?;
1761    let hooks = hooks_value_preserving_unknowns(raw, &settings.hooks)?;
1762    let preserve_default_hooks = settings.hooks.is_default() && hooks_has_unknown_fields(raw);
1763    let instructions = serde_json::to_value(&settings.instructions)?;
1764    let skills = serde_json::to_value(&settings.skills)?;
1765    let subagents = serde_json::to_value(&settings.subagents)?;
1766    let models = serde_json::to_value(&settings.models)?;
1767    let integrations = serde_json::to_value(&settings.integrations)?;
1768    let tui = serde_json::to_value(&settings.tui)?;
1769    let provider_stream = serde_json::to_value(&settings.provider_stream)?;
1770    let ttsr = serde_json::to_value(&settings.ttsr)?;
1771    let selected_primary_agent = serde_json::to_value(&settings.selected_primary_agent)?;
1772    let preserve_tools = raw_group_has_unknown_fields(raw, "tools", KNOWN_TOOL_KEYS);
1773    let preserve_subagents = raw_group_has_unknown_fields(raw, "subagents", KNOWN_SUBAGENTS_KEYS);
1774    let preserve_models = raw_group_has_unknown_fields(raw, "models", KNOWN_MODELS_KEYS);
1775    let object = raw.as_object_mut().expect("settings raw object");
1776    for legacy_key in [
1777        "provider",
1778        "model",
1779        "thinking_level",
1780        "herdr",
1781        "disabled_skills",
1782    ] {
1783        object.remove(legacy_key);
1784    }
1785    if settings.selected_model.is_default() {
1786        object.remove("selected_model");
1787    } else {
1788        object.insert("selected_model".to_string(), selected_model);
1789    }
1790    if settings.openai_codex.is_default() {
1791        object.remove("openai_codex");
1792    } else {
1793        object.insert("openai_codex".to_string(), openai_codex);
1794    }
1795    if let Some(no_color) = settings.no_color {
1796        object.insert("no_color".to_string(), serde_json::to_value(no_color)?);
1797    } else {
1798        object.remove("no_color");
1799    }
1800    if settings.file_autocomplete_respects_gitignore {
1801        object.remove("file_autocomplete_respects_gitignore");
1802    } else {
1803        object.insert(
1804            "file_autocomplete_respects_gitignore".to_string(),
1805            serde_json::to_value(settings.file_autocomplete_respects_gitignore)?,
1806        );
1807    }
1808    object.insert("context".to_string(), context);
1809    if settings.custom_providers.is_empty() {
1810        object.remove("custom_providers");
1811    } else {
1812        object.insert("custom_providers".to_string(), custom);
1813    }
1814    if settings.mcp_servers.is_empty() {
1815        object.remove("mcp_servers");
1816    } else {
1817        object.insert("mcp_servers".to_string(), mcp_servers);
1818    }
1819    if settings.lsp.is_default() {
1820        object.remove("lsp");
1821    } else {
1822        object.insert("lsp".to_string(), lsp);
1823    }
1824    if settings.session_titles.is_default() {
1825        object.remove("session_titles");
1826    } else {
1827        object.insert("session_titles".to_string(), session_titles);
1828    }
1829    if settings.compaction.is_default() {
1830        object.remove("compaction");
1831    } else {
1832        object.insert("compaction".to_string(), compaction);
1833    }
1834    if settings.tools.is_default() && !preserve_tools {
1835        object.remove("tools");
1836    } else {
1837        object.insert("tools".to_string(), tools);
1838    }
1839    if settings.hooks.is_default() && !preserve_default_hooks {
1840        object.remove("hooks");
1841    } else {
1842        object.insert("hooks".to_string(), hooks);
1843    }
1844    if settings.instructions.is_default() {
1845        object.remove("instructions");
1846    } else {
1847        object.insert("instructions".to_string(), instructions);
1848    }
1849    if settings.skills.is_default() {
1850        object.remove("skills");
1851    } else {
1852        object.insert("skills".to_string(), skills);
1853    }
1854    if settings.subagents.is_default() && !preserve_subagents {
1855        object.remove("subagents");
1856    } else {
1857        object.insert("subagents".to_string(), subagents);
1858    }
1859    if settings.models.is_default() && !preserve_models {
1860        object.remove("models");
1861    } else {
1862        object.insert("models".to_string(), models);
1863    }
1864    if settings.integrations.is_default() {
1865        object.remove("integrations");
1866    } else {
1867        object.insert("integrations".to_string(), integrations);
1868    }
1869    if settings.tui.is_default() {
1870        object.remove("tui");
1871    } else {
1872        object.insert("tui".to_string(), tui);
1873    }
1874    if settings.provider_stream.is_default() {
1875        object.remove("provider_stream");
1876    } else {
1877        object.insert("provider_stream".to_string(), provider_stream);
1878    }
1879    if settings.ttsr.is_default() {
1880        object.remove("ttsr");
1881    } else {
1882        object.insert("ttsr".to_string(), ttsr);
1883    }
1884    if settings.selected_primary_agent.is_some() || object.contains_key("selected_primary_agent") {
1885        object.insert("selected_primary_agent".to_string(), selected_primary_agent);
1886    }
1887    if let Some(cache_ttl) = settings.anthropic_cache_ttl {
1888        object.insert(
1889            "anthropic_cache_ttl".to_string(),
1890            serde_json::to_value(cache_ttl)?,
1891        );
1892    } else {
1893        object.remove("anthropic_cache_ttl");
1894    }
1895    if scope == SettingsScope::Global {
1896        insert_default_schema_ref_if_absent(raw);
1897    }
1898    Ok(())
1899}
1900
1901pub(crate) fn read_settings(paths: &McPaths) -> anyhow::Result<Settings> {
1902    let mut raw = read_settings_json_or_empty(&paths.settings_file)?;
1903    let local_path = paths
1904        .local_settings_file
1905        .as_ref()
1906        .filter(|path| path.exists())
1907        .unwrap_or(&paths.project_settings_file);
1908    if local_path.exists() {
1909        let local = read_settings_json_or_empty(local_path)?;
1910        deep_merge_json(&mut raw, &local);
1911    }
1912    let settings: Settings = serde_json::from_value(raw)?;
1913    validate_settings(&settings)?;
1914    Ok(settings)
1915}
1916
1917#[cfg_attr(not(test), allow(dead_code))]
1918pub(crate) fn write_settings(paths: &McPaths, settings: &Settings) -> anyhow::Result<()> {
1919    validate_settings(settings)?;
1920    fs::create_dir_all(&paths.root)?;
1921    let settings_lock = settings_file_lock(&paths.settings_file)?;
1922    let _settings_guard = settings_lock
1923        .lock()
1924        .map_err(|_| anyhow::anyhow!("settings lock was poisoned"))?;
1925    let _file_guard = CrossProcessFileLock::acquire(&paths.settings_file)?;
1926    let mut raw = serde_json::to_value(settings)?;
1927    insert_default_schema_ref_if_absent(&mut raw);
1928    atomic_write(
1929        &paths.settings_file,
1930        serde_json::to_string_pretty(&raw)?.as_bytes(),
1931    )?;
1932    Ok(())
1933}
1934
1935fn preserve_explicit_empty_disabled_list(
1936    paths: &McPaths,
1937    scope: SettingsScope,
1938    kind: SettingsListKind,
1939) -> anyhow::Result<()> {
1940    let target = settings_path_for_scope(paths, scope);
1941    let settings_lock = settings_file_lock(&target)?;
1942    let _settings_guard = settings_lock
1943        .lock()
1944        .map_err(|_| anyhow::anyhow!("settings lock was poisoned"))?;
1945    let _file_guard = CrossProcessFileLock::acquire(&target)?;
1946    let mut raw = read_settings_json_or_empty(&target)?;
1947    if !raw.is_object() {
1948        raw = serde_json::json!({});
1949    }
1950    let (group, key) = match kind {
1951        SettingsListKind::Skills => ("skills", "disabled"),
1952        SettingsListKind::Tools => ("tools", "disabled"),
1953        SettingsListKind::Subagents => ("subagents", "disabled"),
1954        SettingsListKind::Models => ("models", "disabled"),
1955    };
1956    let object = raw.as_object_mut().expect("settings raw object");
1957    let group_value = object
1958        .entry(group.to_string())
1959        .or_insert_with(|| serde_json::json!({}));
1960    if !group_value.is_object() {
1961        *group_value = serde_json::json!({});
1962    }
1963    group_value
1964        .as_object_mut()
1965        .expect("settings group object")
1966        .insert(key.to_string(), serde_json::Value::Array(Vec::new()));
1967    atomic_write(&target, serde_json::to_string_pretty(&raw)?.as_bytes())?;
1968    Ok(())
1969}
1970
1971const KNOWN_TOOL_KEYS: &[&str] = &[
1972    "read",
1973    "view_image",
1974    "hash_edit",
1975    "write",
1976    "grep",
1977    "ffgrep",
1978    "find",
1979    "fffind",
1980    "list_files",
1981    "repo_map",
1982    "ast_grep",
1983    "bash",
1984    "subagents",
1985    "parallel_subagents",
1986    "output_compression",
1987    "disabled",
1988];
1989const KNOWN_SUBAGENTS_KEYS: &[&str] = &["disabled", "schema_validation_max_retries"];
1990const KNOWN_MODELS_KEYS: &[&str] = &["disabled"];
1991
1992fn tools_value_preserving_unknowns(
1993    raw: &serde_json::Value,
1994    tools: &ToolSettings,
1995) -> anyhow::Result<serde_json::Value> {
1996    group_value_preserving_unknowns(raw, "tools", tools)
1997}
1998
1999fn group_value_preserving_unknowns<T: Serialize>(
2000    raw: &serde_json::Value,
2001    group: &str,
2002    value: &T,
2003) -> anyhow::Result<serde_json::Value> {
2004    let mut next_value = serde_json::to_value(value)?;
2005    let Some(original) = raw.get(group).and_then(serde_json::Value::as_object) else {
2006        return Ok(next_value);
2007    };
2008    let Some(next) = next_value.as_object_mut() else {
2009        return Ok(next_value);
2010    };
2011    for (key, value) in original {
2012        next.entry(key.clone()).or_insert_with(|| value.clone());
2013    }
2014    Ok(next_value)
2015}
2016
2017fn raw_group_has_unknown_fields(raw: &serde_json::Value, group: &str, known_keys: &[&str]) -> bool {
2018    raw.get(group)
2019        .and_then(serde_json::Value::as_object)
2020        .is_some_and(|object| object.keys().any(|key| !known_keys.contains(&key.as_str())))
2021}
2022
2023fn hooks_value_preserving_unknowns(
2024    raw: &serde_json::Value,
2025    hooks: &HookSettings,
2026) -> anyhow::Result<serde_json::Value> {
2027    let mut hooks_value = serde_json::to_value(hooks)?;
2028    let Some(original) = raw.get("hooks").and_then(serde_json::Value::as_object) else {
2029        return Ok(hooks_value);
2030    };
2031    let Some(next) = hooks_value.as_object_mut() else {
2032        return Ok(hooks_value);
2033    };
2034    for (key, value) in original {
2035        next.entry(key.clone()).or_insert_with(|| value.clone());
2036    }
2037    Ok(hooks_value)
2038}
2039
2040fn hooks_has_unknown_fields(raw: &serde_json::Value) -> bool {
2041    const KNOWN_HOOK_KEYS: &[&str] = &[
2042        "enabled",
2043        "show_in_tui",
2044        "payload",
2045        "timeout_seconds",
2046        "stdout_max_bytes",
2047        "stderr_max_bytes",
2048        "failure_policy",
2049        "provider_context_injection",
2050        "provider_context_max_bytes",
2051        "injected_content",
2052        "before_tool",
2053        "after_tool",
2054        "after_assistant",
2055        "after_reasoning",
2056    ];
2057    raw.get("hooks")
2058        .and_then(serde_json::Value::as_object)
2059        .is_some_and(|hooks| {
2060            hooks
2061                .keys()
2062                .any(|key| !KNOWN_HOOK_KEYS.contains(&key.as_str()))
2063        })
2064}
2065
2066fn deep_merge_json(base: &mut serde_json::Value, override_val: &serde_json::Value) {
2067    match (base, override_val) {
2068        (serde_json::Value::Object(base_object), serde_json::Value::Object(override_object)) => {
2069            for (key, value) in override_object {
2070                match base_object.get_mut(key) {
2071                    Some(base_value) => deep_merge_json(base_value, value),
2072                    None => {
2073                        base_object.insert(key.clone(), value.clone());
2074                    }
2075                }
2076            }
2077        }
2078        (base_value, override_value) => *base_value = override_value.clone(),
2079    }
2080}
2081
2082fn read_settings_json_or_empty(path: &Path) -> anyhow::Result<serde_json::Value> {
2083    let text = read_settings_text_or_empty(path)?;
2084    serde_json::from_str(&text)
2085        .with_context(|| format!("failed to parse settings JSON from {}", path.display()))
2086}
2087
2088fn read_settings_text_or_empty(path: &Path) -> anyhow::Result<String> {
2089    match fs::read_to_string(path) {
2090        Ok(text) => Ok(text),
2091        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok("{}".to_string()),
2092        Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
2093    }
2094}
2095
2096fn settings_file_lock(path: &Path) -> anyhow::Result<std::sync::Arc<std::sync::Mutex<()>>> {
2097    in_process_file_lock(path, "settings")
2098}
2099
2100#[cfg(test)]
2101mod tests {
2102    use super::*;
2103    use serde_json::json;
2104    use std::path::PathBuf;
2105
2106    fn read_settings_value(paths: &McPaths) -> serde_json::Value {
2107        serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap()
2108    }
2109
2110    fn parse_validated_settings(raw: &str) -> anyhow::Result<Settings> {
2111        let settings = serde_json::from_str(raw)?;
2112        validate_settings(&settings)?;
2113        Ok(settings)
2114    }
2115
2116    #[test]
2117    fn subagents_schema_retry_settings_default_bounds_and_update_preservation() {
2118        let default_settings: Settings = serde_json::from_str("{}").unwrap();
2119        assert_eq!(
2120            default_settings.subagents.schema_validation_max_retries(),
2121            DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES
2122        );
2123
2124        let too_high =
2125            parse_validated_settings(r#"{"subagents":{"schema_validation_max_retries":6}}"#)
2126                .unwrap_err()
2127                .to_string();
2128        assert!(too_high.contains("subagents.schema_validation_max_retries"));
2129
2130        let mut raw = json!({
2131            "subagents": {"schema_validation_max_retries": 5},
2132            "selected_primary_agent": "old"
2133        });
2134        let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
2135        settings.selected_primary_agent = Some("new".to_string());
2136
2137        update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
2138
2139        assert_eq!(raw["subagents"]["schema_validation_max_retries"], 5);
2140        assert!(KNOWN_SUBAGENTS_KEYS.contains(&"schema_validation_max_retries"));
2141    }
2142
2143    #[test]
2144    fn settings_ttsr_defaults_disabled_and_explicit_true_survives() {
2145        let default_settings: Settings = serde_json::from_str("{}").unwrap();
2146        assert!(!default_settings.ttsr.enabled);
2147
2148        let explicit_enabled: Settings =
2149            serde_json::from_str(r#"{"ttsr":{"enabled":true}}"#).unwrap();
2150        assert!(explicit_enabled.ttsr.enabled);
2151    }
2152
2153    #[test]
2154    fn settings_ttsr_rejects_invalid_regex_and_empty_reminder() {
2155        let invalid_regex =
2156            parse_validated_settings(r#"{"ttsr":{"rules":[{"pattern":"(","reminder":"stop"}]}}"#)
2157                .unwrap_err()
2158                .to_string();
2159        assert!(invalid_regex.contains("ttsr.rules[0].pattern"));
2160
2161        let empty_reminder = parse_validated_settings(
2162            r#"{"ttsr":{"rules":[{"pattern":"danger","reminder":"  "}]}}"#,
2163        )
2164        .unwrap_err()
2165        .to_string();
2166        assert!(empty_reminder.contains("ttsr.rules[0].reminder"));
2167    }
2168
2169    #[test]
2170    fn ttsr_settings_survive_update_raw_from_settings() {
2171        let mut raw = json!({
2172            "ttsr": {"rules": [{"pattern": "danger", "reminder": "stop"}]},
2173            "selected_primary_agent": "old"
2174        });
2175        let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
2176        settings.selected_primary_agent = Some("new".to_string());
2177
2178        update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
2179
2180        assert_eq!(raw["ttsr"]["rules"][0]["pattern"], "danger");
2181        assert_eq!(raw["selected_primary_agent"], "new");
2182    }
2183
2184    #[test]
2185    fn provider_stream_settings_defaults_parse_validate_and_resolve() {
2186        let absent: Settings = serde_json::from_str("{}").unwrap();
2187        assert_eq!(absent.provider_stream, ProviderStreamSettings::default());
2188        assert_eq!(
2189            absent.provider_stream.semantic_progress_timeout(),
2190            Duration::from_secs(DEFAULT_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS)
2191        );
2192        assert_eq!(
2193            absent.provider_stream.subagent_semantic_progress_timeout(),
2194            Duration::from_secs(DEFAULT_SUBAGENT_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS)
2195        );
2196
2197        let configured: Settings = serde_json::from_str(
2198            r#"{"provider_stream":{"semantic_progress_timeout_seconds":90,"subagent_semantic_progress_timeout_seconds":180}}"#,
2199        )
2200        .unwrap();
2201        validate_settings(&configured).unwrap();
2202        assert_eq!(
2203            configured.provider_stream.semantic_progress_timeout(),
2204            Duration::from_secs(90)
2205        );
2206        assert_eq!(
2207            configured
2208                .provider_stream
2209                .subagent_semantic_progress_timeout(),
2210            Duration::from_secs(180)
2211        );
2212
2213        let too_low = parse_validated_settings(
2214            r#"{"provider_stream":{"semantic_progress_timeout_seconds":0}}"#,
2215        )
2216        .unwrap_err()
2217        .to_string();
2218        assert!(too_low.contains("provider_stream.semantic_progress_timeout_seconds"));
2219
2220        let too_high = parse_validated_settings(
2221            r#"{"provider_stream":{"subagent_semantic_progress_timeout_seconds":601}}"#,
2222        )
2223        .unwrap_err()
2224        .to_string();
2225        assert!(too_high.contains("provider_stream.subagent_semantic_progress_timeout_seconds"));
2226    }
2227
2228    #[test]
2229    fn write_file_if_changed_skips_identical_content() {
2230        let temp = tempfile::TempDir::new().unwrap();
2231        let path = temp.path().join("schema.json");
2232
2233        assert!(write_file_if_changed(&path, b"one").unwrap());
2234        assert!(!write_file_if_changed(&path, b"one").unwrap());
2235        assert!(write_file_if_changed(&path, b"two").unwrap());
2236        assert_eq!(fs::read(&path).unwrap(), b"two");
2237    }
2238
2239    #[test]
2240    fn settings_schema_generation_writes_state_schema_with_core_properties() {
2241        let temp = tempfile::TempDir::new().unwrap();
2242        let paths = McPaths::from_root(temp.path().join("mc"));
2243        fs::create_dir_all(&paths.state).unwrap();
2244
2245        ensure_settings_schema_files(&paths).unwrap();
2246
2247        let schema_text = fs::read_to_string(paths.state.join("settings.schema.json")).unwrap();
2248        let schema: serde_json::Value = serde_json::from_str(&schema_text).unwrap();
2249        let schema_text = schema.to_string();
2250        for key in [
2251            "selected_model",
2252            "tools",
2253            "hooks",
2254            "custom_providers",
2255            "mcp_servers",
2256            "context",
2257            "tui",
2258            "skills",
2259            "subagents",
2260            "models",
2261            "instructions",
2262            "openai_codex",
2263            "anthropic_cache_ttl",
2264            "lsp",
2265            "ttsr",
2266        ] {
2267            assert!(schema_text.contains(key), "schema missing {key}");
2268        }
2269        assert_eq!(
2270            read_settings_value(&paths)["$schema"],
2271            SETTINGS_SCHEMA_RELATIVE_REF
2272        );
2273        assert_eq!(read_settings(&paths).unwrap(), Settings::default());
2274    }
2275
2276    #[test]
2277    fn openai_codex_text_verbosity_settings_parse_default_invalid_and_schema() {
2278        let absent: Settings = serde_json::from_str("{}").unwrap();
2279        assert_eq!(absent.openai_codex.text_verbosity, TextVerbosity::Low);
2280        assert!(
2281            serde_json::to_value(&absent)
2282                .unwrap()
2283                .get("openai_codex")
2284                .is_none()
2285        );
2286
2287        let medium: Settings =
2288            serde_json::from_str(r#"{"openai_codex":{"text_verbosity":"medium"}}"#).unwrap();
2289        assert_eq!(medium.openai_codex.text_verbosity, TextVerbosity::Medium);
2290        assert_eq!(
2291            serde_json::to_value(&medium).unwrap()["openai_codex"]["text_verbosity"],
2292            "medium"
2293        );
2294
2295        let error =
2296            serde_json::from_str::<Settings>(r#"{"openai_codex":{"text_verbosity":"verbose"}}"#)
2297                .unwrap_err()
2298                .to_string();
2299        assert!(error.contains("expected one of"), "{error}");
2300
2301        let schema = serde_json::to_string(&schemars::schema_for!(Settings)).unwrap();
2302        for value in ["openai_codex", "text_verbosity", "low", "medium", "high"] {
2303            assert!(schema.contains(value), "schema missing {value}: {schema}");
2304        }
2305    }
2306
2307    #[test]
2308    fn openai_responses_text_verbosity_resolves_shared_and_capability_rules() {
2309        let mut settings: Settings = serde_json::from_str(
2310            r#"{
2311                "openai_responses": {"text_verbosity": "medium"},
2312                "openai_codex": {"text_verbosity": "high"},
2313                "custom_providers": {
2314                    "capable": {
2315                        "label": "Capable",
2316                        "base_url": "http://localhost:8080/v1",
2317                        "use_responses_endpoint": true,
2318                        "supports_text_verbosity": true
2319                    },
2320                    "unsupported": {
2321                        "label": "Unsupported",
2322                        "base_url": "http://localhost:8081/v1",
2323                        "use_responses_endpoint": true
2324                    }
2325                }
2326            }"#,
2327        )
2328        .unwrap();
2329        assert_eq!(
2330            settings.text_verbosity_for(crate::providers::OPENAI_CODEX_PROVIDER),
2331            Some(TextVerbosity::Medium)
2332        );
2333        assert_eq!(
2334            settings.text_verbosity_for("capable"),
2335            Some(TextVerbosity::Medium)
2336        );
2337        assert_eq!(settings.text_verbosity_for("unsupported"), None);
2338        assert_eq!(settings.text_verbosity_for("missing"), None);
2339
2340        settings.openai_responses.text_verbosity = None;
2341        assert_eq!(
2342            settings.text_verbosity_for(crate::providers::OPENAI_CODEX_PROVIDER),
2343            Some(TextVerbosity::High)
2344        );
2345    }
2346
2347    #[test]
2348    fn anthropic_cache_ttl_settings_parse_serialize_and_validate_schema() {
2349        let settings: Settings = serde_json::from_str(r#"{"anthropic_cache_ttl":"1h"}"#).unwrap();
2350        assert_eq!(
2351            settings.anthropic_cache_ttl,
2352            Some(AnthropicCacheTtl::OneHour)
2353        );
2354        assert_eq!(
2355            serde_json::to_value(&settings).unwrap()["anthropic_cache_ttl"],
2356            "1h"
2357        );
2358
2359        let five_minute_settings: Settings =
2360            serde_json::from_str(r#"{"anthropic_cache_ttl":"5m"}"#).unwrap();
2361        assert_eq!(
2362            five_minute_settings.anthropic_cache_ttl,
2363            Some(AnthropicCacheTtl::FiveMinutes)
2364        );
2365        assert_eq!(
2366            serde_json::to_value(&five_minute_settings).unwrap()["anthropic_cache_ttl"],
2367            "5m"
2368        );
2369
2370        let default_settings: Settings = serde_json::from_str("{}").unwrap();
2371        assert_eq!(default_settings.anthropic_cache_ttl, None);
2372        assert!(
2373            serde_json::to_value(&default_settings)
2374                .unwrap()
2375                .get("anthropic_cache_ttl")
2376                .is_none()
2377        );
2378        let schema = serde_json::to_string(&schemars::schema_for!(Settings)).unwrap();
2379        assert!(schema.contains("anthropic_cache_ttl"), "{schema}");
2380    }
2381
2382    #[test]
2383    fn settings_schema_setup_inserts_schema_for_valid_settings_and_preserves_values() {
2384        let temp = tempfile::TempDir::new().unwrap();
2385        let paths = McPaths::from_root(temp.path().join("mc"));
2386        fs::create_dir_all(&paths.root).unwrap();
2387        fs::create_dir_all(&paths.state).unwrap();
2388        fs::write(
2389            &paths.settings_file,
2390            r#"{"selected_model":{"provider":"openai-codex","model":"gpt-5.5"},"future_setting":{"keep":true}}"#,
2391        )
2392        .unwrap();
2393
2394        ensure_settings_schema_files(&paths).unwrap();
2395
2396        let value = read_settings_value(&paths);
2397        assert_eq!(value["$schema"], SETTINGS_SCHEMA_RELATIVE_REF);
2398        assert_eq!(value["selected_model"]["provider"], "openai-codex");
2399        assert_eq!(value["selected_model"]["model"], "gpt-5.5");
2400        assert_eq!(value["future_setting"]["keep"], true);
2401    }
2402
2403    #[test]
2404    fn settings_schema_setup_preserves_existing_custom_schema() {
2405        let temp = tempfile::TempDir::new().unwrap();
2406        let paths = McPaths::from_root(temp.path().join("mc"));
2407        fs::create_dir_all(&paths.root).unwrap();
2408        fs::create_dir_all(&paths.state).unwrap();
2409        fs::write(
2410            &paths.settings_file,
2411            r#"{"$schema":"https://example.test/custom.schema.json","selected_model":{"provider":"openai-codex"}}"#,
2412        )
2413        .unwrap();
2414
2415        ensure_settings_schema_files(&paths).unwrap();
2416
2417        assert_eq!(
2418            read_settings_value(&paths)["$schema"],
2419            "https://example.test/custom.schema.json"
2420        );
2421    }
2422
2423    #[test]
2424    fn settings_schema_setup_leaves_invalid_settings_unchanged() {
2425        for original in [
2426            "not json",
2427            "[]",
2428            r#"{"selected_model":{"thinking_level":"maximum"}}"#,
2429            r#"{"custom_providers":{"bad":{"label":"Bad","base_url":"https://provider.test/v1/models"}}}"#,
2430        ] {
2431            let temp = tempfile::TempDir::new().unwrap();
2432            let paths = McPaths::from_root(temp.path().join("mc"));
2433            fs::create_dir_all(&paths.root).unwrap();
2434            fs::create_dir_all(&paths.state).unwrap();
2435            fs::write(&paths.settings_file, original).unwrap();
2436
2437            ensure_settings_schema_files(&paths).unwrap();
2438
2439            assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), original);
2440        }
2441    }
2442
2443    #[test]
2444    fn settings_write_paths_emit_or_preserve_schema_metadata() {
2445        let temp = tempfile::TempDir::new().unwrap();
2446        let paths = McPaths::from_root(temp.path().join("mc"));
2447        write_settings(
2448            &paths,
2449            &Settings {
2450                selected_model: SelectedModelSettings {
2451                    provider: Some("openai-codex".to_string()),
2452                    ..SelectedModelSettings::default()
2453                },
2454                ..Settings::default()
2455            },
2456        )
2457        .unwrap();
2458        let value = read_settings_value(&paths);
2459        assert_eq!(value["$schema"], SETTINGS_SCHEMA_RELATIVE_REF);
2460        assert_eq!(value["selected_model"]["provider"], "openai-codex");
2461
2462        fs::write(
2463            &paths.settings_file,
2464            json!({
2465                "$schema": "https://example.test/custom.schema.json",
2466                "future_setting": true,
2467                "selected_model": {"provider":"old-provider"}
2468            })
2469            .to_string(),
2470        )
2471        .unwrap();
2472        set_selected_model(&paths, "new-provider", "new-model").unwrap();
2473        let value = read_settings_value(&paths);
2474        assert_eq!(value["$schema"], "https://example.test/custom.schema.json");
2475        assert_eq!(value["future_setting"], true);
2476        assert_eq!(value["selected_model"]["provider"], "new-provider");
2477        assert_eq!(value["selected_model"]["model"], "new-model");
2478    }
2479
2480    #[test]
2481    fn settings_update_round_trips_top_level_scalar_context_fields() {
2482        let temp = tempfile::TempDir::new().unwrap();
2483        let paths = McPaths::from_root(temp.path().join("mc"));
2484        fs::create_dir_all(&paths.root).unwrap();
2485        fs::write(&paths.settings_file, r#"{"future_setting":true}"#).unwrap();
2486
2487        update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
2488            settings.no_color = Some(true);
2489            settings.file_autocomplete_respects_gitignore = false;
2490            settings.context = serde_json::from_value(json!({"max_tokens": 128000})).unwrap();
2491        })
2492        .unwrap();
2493
2494        let value = read_settings_value(&paths);
2495        assert_eq!(value["future_setting"], true);
2496        assert_eq!(value["no_color"], true);
2497        assert_eq!(value["file_autocomplete_respects_gitignore"], false);
2498        assert_eq!(value["context"]["max_tokens"], 128000);
2499
2500        update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
2501            settings.no_color = None;
2502            settings.file_autocomplete_respects_gitignore = true;
2503            settings.context = None;
2504        })
2505        .unwrap();
2506
2507        let value = read_settings_value(&paths);
2508        assert!(value.get("no_color").is_none());
2509        assert!(value.get("file_autocomplete_respects_gitignore").is_none());
2510        assert_eq!(value["context"], serde_json::Value::Null);
2511    }
2512
2513    #[test]
2514    fn settings_update_rejects_invalid_custom_provider_mutation_without_write() {
2515        let temp = tempfile::TempDir::new().unwrap();
2516        let paths = McPaths::from_root(temp.path().join("mc"));
2517        fs::create_dir_all(&paths.root).unwrap();
2518        fs::write(
2519            &paths.settings_file,
2520            r#"{"future_setting":true,"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1"}}}"#,
2521        )
2522        .unwrap();
2523        let before = fs::read_to_string(&paths.settings_file).unwrap();
2524
2525        let error = update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
2526            settings.custom_providers.get_mut("local").unwrap().base_url =
2527                "https://user:password@example.test/v1".to_string();
2528        })
2529        .unwrap_err()
2530        .to_string();
2531
2532        assert!(error.contains("custom provider 'local'"), "{error}");
2533        assert!(error.contains("userinfo"), "{error}");
2534        assert!(!error.contains("password"), "{error}");
2535        assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), before);
2536    }
2537
2538    #[test]
2539    fn write_settings_rejects_invalid_custom_provider_settings() {
2540        let temp = tempfile::TempDir::new().unwrap();
2541        let paths = McPaths::from_root(temp.path().join("mc"));
2542        let settings = Settings {
2543            custom_providers: BTreeMap::from([(
2544                "local".to_string(),
2545                CustomProviderConfig {
2546                    label: "Local".to_string(),
2547                    base_url: "https://user:password@example.test/v1".to_string(),
2548                    api_key_env_var: None,
2549                    models_dev_provider: None,
2550                    use_responses_endpoint: false,
2551                    supports_text_verbosity: false,
2552                    reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
2553                    extra_models: Vec::new(),
2554                },
2555            )]),
2556            ..Settings::default()
2557        };
2558
2559        let error = write_settings(&paths, &settings).unwrap_err().to_string();
2560
2561        assert!(error.contains("custom provider 'local'"), "{error}");
2562        assert!(error.contains("userinfo"), "{error}");
2563        assert!(!error.contains("password"), "{error}");
2564        assert!(!paths.settings_file.exists());
2565    }
2566
2567    #[test]
2568    fn upsert_custom_provider_rejects_invalid_config_without_write() {
2569        let temp = tempfile::TempDir::new().unwrap();
2570        let paths = McPaths::from_root(temp.path().join("mc"));
2571        write_settings(&paths, &Settings::default()).unwrap();
2572        let before = fs::read_to_string(&paths.settings_file).unwrap();
2573
2574        let error = upsert_custom_provider(
2575            &paths,
2576            "local",
2577            CustomProviderConfig {
2578                label: "Local".to_string(),
2579                base_url: "https://user:password@example.test/v1".to_string(),
2580                api_key_env_var: None,
2581                models_dev_provider: None,
2582                use_responses_endpoint: false,
2583                supports_text_verbosity: false,
2584                reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
2585                extra_models: Vec::new(),
2586            },
2587        )
2588        .unwrap_err()
2589        .to_string();
2590
2591        assert!(error.contains("custom provider 'local'"), "{error}");
2592        assert!(error.contains("userinfo"), "{error}");
2593        assert!(!error.contains("password"), "{error}");
2594        assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), before);
2595    }
2596
2597    #[test]
2598    fn context_model_overrides_parse_serialize_and_validate_keys() {
2599        let settings: Settings = serde_json::from_str(
2600            r#"{"context":{"max_tokens":128000,"model_overrides":{"openai-codex/gpt-5.5":{"max_tokens":400000,"reserve_tokens":32768},"zai/Qwen/Qwen3":{"keep_recent_tokens":50000}}}}"#,
2601        )
2602        .unwrap();
2603        let budget = settings.context.as_ref().unwrap();
2604        let codex = budget.model_overrides.get("openai-codex/gpt-5.5").unwrap();
2605        assert_eq!(codex.max_tokens, Some(400_000));
2606        assert_eq!(codex.reserve_tokens, Some(32_768));
2607        assert_eq!(codex.keep_recent_tokens, None);
2608        assert_eq!(
2609            budget
2610                .model_overrides
2611                .get("zai/Qwen/Qwen3")
2612                .unwrap()
2613                .keep_recent_tokens,
2614            Some(50_000)
2615        );
2616        let serialized = serde_json::to_value(&settings).unwrap();
2617        assert_eq!(
2618            serialized["context"]["model_overrides"]["openai-codex/gpt-5.5"]["max_tokens"],
2619            400000
2620        );
2621
2622        for raw in [
2623            r#"{"context":{"model_overrides":{"missing-slash":{"max_tokens":1}}}}"#,
2624            r#"{"context":{"model_overrides":{"/missing-provider":{"max_tokens":1}}}}"#,
2625            r#"{"context":{"model_overrides":{"provider/":{"max_tokens":1}}}}"#,
2626            r#"{"context":{"model_overrides":{"provider /model":{"max_tokens":1}}}}"#,
2627            r#"{"context":{"model_overrides":{"provider/model":{}}}}"#,
2628        ] {
2629            assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
2630        }
2631    }
2632
2633    #[test]
2634    fn settings_deserialize_ignores_schema_metadata() {
2635        let settings: Settings = serde_json::from_str(
2636            r#"{"$schema":"./state/settings.schema.json","selected_model":{"provider":"openai-codex"}}"#,
2637        )
2638        .unwrap();
2639
2640        assert_eq!(
2641            settings.selected_model.provider.as_deref(),
2642            Some("openai-codex")
2643        );
2644    }
2645
2646    #[test]
2647    fn instructions_settings_deserialize_additional_markdown_paths() {
2648        let settings: Settings = serde_json::from_str(
2649            r#"{"instructions":{"additional_markdown_paths":["/opt/team.md","/Users/test/review.md"]}}"#,
2650        )
2651        .unwrap();
2652
2653        assert!(!settings.instructions.subdir_discovery);
2654        assert_eq!(
2655            settings.instructions.additional_markdown_paths,
2656            vec![
2657                PathBuf::from("/opt/team.md"),
2658                PathBuf::from("/Users/test/review.md")
2659            ]
2660        );
2661    }
2662
2663    #[test]
2664    fn instructions_settings_deserialize_subdir_discovery() {
2665        let default_settings: Settings = serde_json::from_str("{}").unwrap();
2666        assert!(!default_settings.instructions.subdir_discovery);
2667
2668        let enabled: Settings =
2669            serde_json::from_str(r#"{"instructions":{"subdir_discovery":true}}"#).unwrap();
2670        assert!(enabled.instructions.subdir_discovery);
2671        let serialized = serde_json::to_value(&enabled).unwrap();
2672        assert_eq!(serialized["instructions"]["subdir_discovery"], true);
2673
2674        let schema = serde_json::to_string(&schemars::schema_for!(Settings)).unwrap();
2675        assert!(schema.contains("subdir_discovery"), "{schema}");
2676    }
2677
2678    #[test]
2679    fn skills_settings_deserialize_additional_paths() {
2680        let settings: Settings = serde_json::from_str(
2681            r#"{"skills":{"additional_paths":["/opt/magi-skills","/Users/test/skills"]}}"#,
2682        )
2683        .unwrap();
2684
2685        assert_eq!(
2686            settings.skills.additional_paths,
2687            vec![
2688                PathBuf::from("/opt/magi-skills"),
2689                PathBuf::from("/Users/test/skills")
2690            ]
2691        );
2692    }
2693
2694    #[test]
2695    fn normalized_settings_read_canonical_legacy_and_conflicts() {
2696        let canonical: Settings = serde_json::from_str(
2697            r#"{
2698                "selected_model":{"provider":"canonical-provider","model":"canonical-model","thinking_level":"high"},
2699                "skills":{"disabled":["review"]},
2700                "integrations":{"herdr":{"enabled":true}}
2701            }"#,
2702        )
2703        .unwrap();
2704        assert_eq!(
2705            canonical.selected_model.provider.as_deref(),
2706            Some("canonical-provider")
2707        );
2708        assert_eq!(
2709            canonical.selected_model.model.as_deref(),
2710            Some("canonical-model")
2711        );
2712        assert_eq!(
2713            canonical.selected_model.thinking_level,
2714            Some(ThinkingLevel::High)
2715        );
2716        assert_eq!(canonical.skills.disabled, vec!["review"]);
2717        assert!(canonical.integrations.herdr.enabled);
2718
2719        let legacy: Settings = serde_json::from_str(
2720            r#"{
2721                "provider":"legacy-provider",
2722                "model":"legacy-model",
2723                "thinking_level":"medium",
2724                "disabled_skills":["plan"],
2725                "herdr":{"enabled":true}
2726            }"#,
2727        )
2728        .unwrap();
2729        assert_eq!(
2730            legacy.selected_model.provider.as_deref(),
2731            Some("legacy-provider")
2732        );
2733        assert_eq!(legacy.selected_model.model.as_deref(), Some("legacy-model"));
2734        assert_eq!(
2735            legacy.selected_model.thinking_level,
2736            Some(ThinkingLevel::Medium)
2737        );
2738        assert_eq!(legacy.skills.disabled, vec!["plan"]);
2739        assert!(legacy.integrations.herdr.enabled);
2740
2741        let conflict: Settings = serde_json::from_str(
2742            r#"{
2743                "selected_model":{"provider":"canonical-provider","model":"canonical-model","thinking_level":"low"},
2744                "provider":"legacy-provider",
2745                "model":"legacy-model",
2746                "thinking_level":"high",
2747                "skills":{"disabled":[]},
2748                "disabled_skills":["legacy-skill"],
2749                "integrations":{"herdr":{"enabled":false}},
2750                "herdr":{"enabled":true}
2751            }"#,
2752        )
2753        .unwrap();
2754        assert_eq!(
2755            conflict.selected_model.provider.as_deref(),
2756            Some("canonical-provider")
2757        );
2758        assert_eq!(
2759            conflict.selected_model.model.as_deref(),
2760            Some("canonical-model")
2761        );
2762        assert_eq!(
2763            conflict.selected_model.thinking_level,
2764            Some(ThinkingLevel::Low)
2765        );
2766        assert!(conflict.skills.disabled.is_empty());
2767        assert!(!conflict.integrations.herdr.enabled);
2768    }
2769
2770    #[test]
2771    fn tool_settings_accept_canonical_and_legacy_tool_keys() {
2772        let canonical: Settings = serde_json::from_str(
2773            r#"{"tools":{"grep":{"absolute_paths":false},"find":{"absolute_paths":false},"list_files":{"absolute_paths":false},"subagents":{"absolute_paths":false,"max_depth":2}}}"#,
2774        )
2775        .unwrap();
2776        assert!(!canonical.tools.grep.absolute_paths);
2777        assert!(!canonical.tools.find.absolute_paths);
2778        assert!(!canonical.tools.list_files.absolute_paths);
2779        assert!(!canonical.tools.subagents.absolute_paths);
2780        assert_eq!(canonical.tools.subagents.max_depth, 2);
2781
2782        let legacy: Settings = serde_json::from_str(
2783            r#"{"tools":{"ffgrep":{"absolute_paths":false},"fffind":{"absolute_paths":false},"list_files":{"absolute_paths":false},"parallel_subagents":{"absolute_paths":false,"max_depth":3}}}"#,
2784        )
2785        .unwrap();
2786        assert!(!legacy.tools.grep.absolute_paths);
2787        assert!(!legacy.tools.find.absolute_paths);
2788        assert!(!legacy.tools.list_files.absolute_paths);
2789        assert!(!legacy.tools.subagents.absolute_paths);
2790        assert_eq!(legacy.tools.subagents.max_depth, 3);
2791    }
2792
2793    #[test]
2794    fn normalized_settings_default_serialization_omits_empty_groups() {
2795        let value = serde_json::to_value(Settings::default()).unwrap();
2796        assert!(value.get("selected_model").is_none());
2797        assert!(value.get("integrations").is_none());
2798        assert!(value.get("instructions").is_none());
2799        assert!(value.get("skills").is_none());
2800    }
2801
2802    #[test]
2803    fn normalized_settings_update_preserves_nested_unknowns_and_removes_legacy_keys() {
2804        let temp = tempfile::TempDir::new().unwrap();
2805        let paths = McPaths::from_root(temp.path().join("mc"));
2806        fs::create_dir_all(&paths.root).unwrap();
2807        fs::write(
2808            &paths.settings_file,
2809            r#"{
2810                "future_setting":true,
2811                "provider":"legacy-provider",
2812                "model":"legacy-model",
2813                "thinking_level":"high",
2814                "disabled_skills":["legacy-skill"],
2815                "herdr":{"enabled":true,"legacy_future":"keep"},
2816                "selected_model":{"provider":"canonical-provider","future":"keep"},
2817                "instructions":{"additional_markdown_paths":["/opt/team.md"],"future":"keep"},
2818                "skills":{"additional_paths":["/opt/skills"],"future":"keep"},
2819                "integrations":{"future":"keep","herdr":{"enabled":false,"future":"keep"}},
2820                "tui":{"future":"keep"}
2821            }"#,
2822        )
2823        .unwrap();
2824
2825        set_selected_model(&paths, "new-provider", "new-model").unwrap();
2826        let value: serde_json::Value =
2827            serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
2828        assert_eq!(value["future_setting"], true);
2829        assert_eq!(value["selected_model"]["provider"], "new-provider");
2830        assert_eq!(value["selected_model"]["model"], "new-model");
2831        assert_eq!(value["selected_model"]["thinking_level"], "high");
2832        assert_eq!(value["selected_model"]["future"], "keep");
2833        assert_eq!(
2834            value["instructions"]["additional_markdown_paths"][0],
2835            "/opt/team.md"
2836        );
2837        assert_eq!(value["instructions"]["future"], "keep");
2838        assert_eq!(value["skills"]["disabled"][0], "legacy-skill");
2839        assert_eq!(value["skills"]["future"], "keep");
2840        assert_eq!(value["integrations"]["future"], "keep");
2841        assert_eq!(value["integrations"]["herdr"]["enabled"], false);
2842        assert_eq!(value["integrations"]["herdr"]["future"], "keep");
2843        assert_eq!(value["tui"]["future"], "keep");
2844        for legacy_key in [
2845            "provider",
2846            "model",
2847            "thinking_level",
2848            "disabled_skills",
2849            "herdr",
2850        ] {
2851            assert!(
2852                value.get(legacy_key).is_none(),
2853                "legacy key survived: {legacy_key}"
2854            );
2855        }
2856    }
2857
2858    #[test]
2859    fn settings_update_treats_repo_map_as_known_tool_key() {
2860        let mut raw = json!({
2861            "tools": {"repo_map": {"absolute_paths": true}},
2862            "selected_primary_agent": "old"
2863        });
2864        let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
2865        settings.selected_primary_agent = Some("new".to_string());
2866
2867        update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
2868
2869        assert!(raw.get("tools").is_none());
2870        assert_eq!(raw["selected_primary_agent"], "new");
2871        assert!(KNOWN_TOOL_KEYS.contains(&"repo_map"));
2872    }
2873
2874    #[test]
2875    fn hook_settings_preserve_unknown_fields_on_settings_update() {
2876        let temp = tempfile::TempDir::new().unwrap();
2877        let paths = McPaths::from_root(temp.path().join("mc"));
2878        fs::create_dir_all(&paths.root).unwrap();
2879        fs::write(
2880            &paths.settings_file,
2881            r#"{"hooks":{"future":{"keep":true}}}"#,
2882        )
2883        .unwrap();
2884
2885        set_selected_model(&paths, "provider", "model").unwrap();
2886
2887        let value = read_settings_value(&paths);
2888        assert_eq!(value["hooks"]["future"]["keep"], true);
2889        assert_eq!(value["selected_model"]["provider"], "provider");
2890        assert_eq!(value["selected_model"]["model"], "model");
2891    }
2892
2893    #[test]
2894    fn hook_settings_default_without_unknown_fields_is_omitted_on_settings_update() {
2895        let temp = tempfile::TempDir::new().unwrap();
2896        let paths = McPaths::from_root(temp.path().join("mc"));
2897        fs::create_dir_all(&paths.root).unwrap();
2898        fs::write(&paths.settings_file, r#"{}"#).unwrap();
2899
2900        set_selected_model(&paths, "provider", "model").unwrap();
2901
2902        let value = read_settings_value(&paths);
2903        assert!(value.get("hooks").is_none());
2904    }
2905
2906    #[test]
2907    fn hook_settings_known_fields_override_old_raw_values_on_settings_update() {
2908        let temp = tempfile::TempDir::new().unwrap();
2909        let paths = McPaths::from_root(temp.path().join("mc"));
2910        fs::create_dir_all(&paths.root).unwrap();
2911        fs::write(
2912            &paths.settings_file,
2913            r#"{"hooks":{"enabled":false,"future":{"keep":true}}}"#,
2914        )
2915        .unwrap();
2916
2917        update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
2918            settings.hooks.enabled = true;
2919        })
2920        .unwrap();
2921
2922        let value = read_settings_value(&paths);
2923        assert_eq!(value["hooks"]["enabled"], true);
2924        assert_eq!(value["hooks"]["future"]["keep"], true);
2925    }
2926
2927    #[test]
2928    fn settings_read_defaults_only_when_file_is_missing() {
2929        let temp = tempfile::TempDir::new().unwrap();
2930        let paths = McPaths::from_root(temp.path().join("mc"));
2931        assert_eq!(read_settings(&paths).unwrap(), Settings::default());
2932        fs::create_dir_all(&paths.root).unwrap();
2933        fs::create_dir(&paths.settings_file).unwrap();
2934
2935        let error = read_settings(&paths).unwrap_err().to_string();
2936
2937        assert!(error.contains("failed to read"), "{error}");
2938    }
2939
2940    fn paths_with_local_settings(temp: &tempfile::TempDir) -> (McPaths, PathBuf) {
2941        let mut paths = McPaths::from_root(temp.path().join("mc"));
2942        let local_settings = temp.path().join("project/.magi-code/settings.json");
2943        paths.project_settings_file = local_settings.clone();
2944        paths.local_settings_file = Some(local_settings.clone());
2945        fs::create_dir_all(&paths.root).unwrap();
2946        fs::create_dir_all(local_settings.parent().unwrap()).unwrap();
2947        (paths, local_settings)
2948    }
2949
2950    #[test]
2951    fn deep_merge_json_recurses_objects_and_replaces_non_objects() {
2952        let mut base = json!({
2953            "object": {"keep": true, "replace": {"old": true}},
2954            "array": ["a"],
2955            "scalar": true
2956        });
2957        let override_val = json!({
2958            "object": {"replace": {"new": true}},
2959            "array": ["b"],
2960            "scalar": null
2961        });
2962
2963        deep_merge_json(&mut base, &override_val);
2964
2965        assert_eq!(base["object"]["keep"], true);
2966        assert_eq!(base["object"]["replace"], json!({"old": true, "new": true}));
2967        assert_eq!(base["array"], json!(["b"]));
2968        assert_eq!(base["scalar"], serde_json::Value::Null);
2969    }
2970
2971    #[test]
2972    fn read_settings_local_scalar_overrides_global() {
2973        let temp = tempfile::TempDir::new().unwrap();
2974        let (paths, local_settings) = paths_with_local_settings(&temp);
2975        fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
2976        fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
2977
2978        let settings = read_settings(&paths).unwrap();
2979
2980        assert_eq!(settings.no_color, Some(false));
2981    }
2982
2983    #[test]
2984    fn read_settings_local_omission_keeps_global_nested_values() {
2985        let temp = tempfile::TempDir::new().unwrap();
2986        let (paths, local_settings) = paths_with_local_settings(&temp);
2987        fs::write(
2988            &paths.settings_file,
2989            r#"{"tools":{"read":{"absolute_paths":false}}}"#,
2990        )
2991        .unwrap();
2992        fs::write(&local_settings, r#"{"no_color":true}"#).unwrap();
2993
2994        let settings = read_settings(&paths).unwrap();
2995
2996        assert!(!settings.tools.read.absolute_paths);
2997        assert_eq!(settings.no_color, Some(true));
2998    }
2999
3000    #[test]
3001    fn read_settings_local_map_union_and_key_override() {
3002        let temp = tempfile::TempDir::new().unwrap();
3003        let (paths, local_settings) = paths_with_local_settings(&temp);
3004        fs::write(
3005            &paths.settings_file,
3006            r#"{"mcp_servers":{"server1":{"type":"stdio","command":"global"}}}"#,
3007        )
3008        .unwrap();
3009        fs::write(
3010            &local_settings,
3011            r#"{"mcp_servers":{"server1":{"type":"stdio","command":"local"},"server2":{"type":"stdio","command":"second"}}}"#,
3012        )
3013        .unwrap();
3014
3015        let settings = read_settings(&paths).unwrap();
3016
3017        assert_eq!(settings.mcp_servers.len(), 2);
3018        let McpServerConfig::Stdio(server1) = settings.mcp_servers.get("server1").unwrap() else {
3019            panic!("expected stdio server1");
3020        };
3021        let McpServerConfig::Stdio(server2) = settings.mcp_servers.get("server2").unwrap() else {
3022            panic!("expected stdio server2");
3023        };
3024        assert_eq!(server1.command, "local");
3025        assert_eq!(server2.command, "second");
3026    }
3027
3028    #[test]
3029    fn read_settings_local_vec_replaces_global_vec() {
3030        let temp = tempfile::TempDir::new().unwrap();
3031        let (paths, local_settings) = paths_with_local_settings(&temp);
3032        fs::write(
3033            &paths.settings_file,
3034            r#"{"skills":{"additional_paths":["a"]}}"#,
3035        )
3036        .unwrap();
3037        fs::write(&local_settings, r#"{"skills":{"additional_paths":["b"]}}"#).unwrap();
3038
3039        let settings = read_settings(&paths).unwrap();
3040
3041        assert_eq!(settings.skills.additional_paths, vec![PathBuf::from("b")]);
3042    }
3043
3044    #[test]
3045    fn scoped_project_update_creates_exact_cwd_settings_and_preserves_unknowns() {
3046        let temp = tempfile::TempDir::new().unwrap();
3047        let mut paths = McPaths::from_root(temp.path().join("mc"));
3048        paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
3049        fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
3050        fs::write(
3051            &paths.project_settings_file,
3052            r#"{"future":true,"tools":{"future_tool":true},"subagents":{"future_subagent":true}}"#,
3053        )
3054        .unwrap();
3055
3056        set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
3057        set_subagent_profile_disabled(&paths, SettingsScope::Project, "reviewer", true).unwrap();
3058
3059        let value: serde_json::Value =
3060            serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap())
3061                .unwrap();
3062        assert_eq!(value["future"], true);
3063        assert_eq!(value["tools"]["future_tool"], true);
3064        assert_eq!(value["tools"]["disabled"], json!(["bash"]));
3065        assert_eq!(value["subagents"]["future_subagent"], true);
3066        assert_eq!(value["subagents"]["disabled"], json!(["reviewer"]));
3067        assert!(value.get("$schema").is_none());
3068    }
3069
3070    #[test]
3071    fn project_disabled_empty_list_overrides_global_disabled() {
3072        let temp = tempfile::TempDir::new().unwrap();
3073        let mut paths = McPaths::from_root(temp.path().join("mc"));
3074        paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
3075        fs::create_dir_all(&paths.root).unwrap();
3076        fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
3077        fs::write(&paths.settings_file, r#"{"tools":{"disabled":["bash"]}}"#).unwrap();
3078
3079        set_tool_disabled(&paths, SettingsScope::Project, "bash", false).unwrap();
3080
3081        let local: serde_json::Value =
3082            serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap())
3083                .unwrap();
3084        assert_eq!(local["tools"]["disabled"], json!([]));
3085        assert!(disabled_tool_names_from_settings(&read_settings(&paths).unwrap()).is_empty());
3086    }
3087
3088    #[test]
3089    fn scoped_project_update_rejects_magi_code_symlink_escape() {
3090        let temp = tempfile::TempDir::new().unwrap();
3091        let mut paths = McPaths::from_root(temp.path().join("mc"));
3092        let project = temp.path().join("project");
3093        let outside = temp.path().join("outside");
3094        fs::create_dir_all(&project).unwrap();
3095        fs::create_dir_all(&outside).unwrap();
3096        #[cfg(unix)]
3097        std::os::unix::fs::symlink(&outside, project.join(".magi-code")).unwrap();
3098        #[cfg(windows)]
3099        std::os::windows::fs::symlink_dir(&outside, project.join(".magi-code")).unwrap();
3100        paths.project_settings_file = project.join(".magi-code/settings.json");
3101
3102        let error = set_skill_disabled_for_scope(&paths, SettingsScope::Project, "review", true)
3103            .unwrap_err()
3104            .to_string();
3105
3106        assert!(error.contains("escapes cwd"), "{error}");
3107    }
3108
3109    #[test]
3110    fn project_modal_scope_inherits_effective_until_local_list_exists() {
3111        let temp = tempfile::TempDir::new().unwrap();
3112        let mut paths = McPaths::from_root(temp.path().join("mc"));
3113        paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
3114        fs::create_dir_all(&paths.root).unwrap();
3115        fs::write(&paths.settings_file, r#"{"skills":{"disabled":["audit"]}}"#).unwrap();
3116
3117        let inherited = disabled_names_for_modal_scope(
3118            &paths,
3119            SettingsScope::Project,
3120            SettingsListKind::Skills,
3121        )
3122        .unwrap();
3123
3124        assert!(inherited.contains("audit"));
3125        assert!(!paths.project_settings_file.exists());
3126    }
3127
3128    #[test]
3129    fn project_update_does_not_serialize_full_default_tools_block() {
3130        let temp = tempfile::TempDir::new().unwrap();
3131        let mut paths = McPaths::from_root(temp.path().join("mc"));
3132        paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
3133        fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
3134
3135        set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
3136
3137        let value: serde_json::Value =
3138            serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap())
3139                .unwrap();
3140        assert_eq!(value["tools"]["disabled"], json!(["bash"]));
3141        assert!(value["tools"].get("read").is_none());
3142        assert!(value["tools"].get("subagents").is_none());
3143    }
3144
3145    #[test]
3146    fn read_settings_for_scope_reads_only_target_file() {
3147        let temp = tempfile::TempDir::new().unwrap();
3148        let (paths, local_settings) = paths_with_local_settings(&temp);
3149        fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
3150        fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
3151
3152        assert_eq!(
3153            read_settings_for_scope(&paths, SettingsScope::Global)
3154                .unwrap()
3155                .no_color,
3156            Some(true)
3157        );
3158        assert_eq!(
3159            read_settings_for_scope(&paths, SettingsScope::Project)
3160                .unwrap()
3161                .no_color,
3162            Some(false)
3163        );
3164    }
3165
3166    #[test]
3167    fn read_settings_accepts_local_only_settings() {
3168        let temp = tempfile::TempDir::new().unwrap();
3169        let (paths, local_settings) = paths_with_local_settings(&temp);
3170        fs::write(&local_settings, r#"{"no_color":true}"#).unwrap();
3171
3172        let settings = read_settings(&paths).unwrap();
3173
3174        assert_eq!(settings.no_color, Some(true));
3175    }
3176
3177    #[test]
3178    fn read_settings_keeps_global_when_local_is_empty_or_missing() {
3179        let temp = tempfile::TempDir::new().unwrap();
3180        let (paths, local_settings) = paths_with_local_settings(&temp);
3181        fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
3182        fs::write(&local_settings, r#"{}"#).unwrap();
3183
3184        assert_eq!(read_settings(&paths).unwrap().no_color, Some(true));
3185        fs::remove_file(&local_settings).unwrap();
3186        assert_eq!(read_settings(&paths).unwrap().no_color, Some(true));
3187    }
3188
3189    #[test]
3190    fn read_settings_invalid_local_json_error_names_local_path() {
3191        let temp = tempfile::TempDir::new().unwrap();
3192        let (paths, local_settings) = paths_with_local_settings(&temp);
3193        fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
3194        fs::write(&local_settings, "not json").unwrap();
3195
3196        let error = read_settings(&paths).unwrap_err().to_string();
3197
3198        assert!(
3199            error.contains(&local_settings.display().to_string()),
3200            "{error}"
3201        );
3202    }
3203
3204    #[test]
3205    fn write_settings_writes_global_only_when_local_settings_exists() {
3206        let temp = tempfile::TempDir::new().unwrap();
3207        let (paths, local_settings) = paths_with_local_settings(&temp);
3208        fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
3209        let local_before = fs::read_to_string(&local_settings).unwrap();
3210
3211        write_settings(
3212            &paths,
3213            &Settings {
3214                no_color: Some(true),
3215                ..Settings::default()
3216            },
3217        )
3218        .unwrap();
3219
3220        assert_eq!(fs::read_to_string(&local_settings).unwrap(), local_before);
3221        assert_eq!(read_settings_value(&paths)["no_color"], true);
3222    }
3223
3224    #[test]
3225    fn concurrent_settings_updates_preserve_independent_fields() {
3226        let temp = tempfile::TempDir::new().unwrap();
3227        let paths = McPaths::from_root(temp.path().join("mc"));
3228        let left = paths.clone();
3229        let right = paths.clone();
3230        let left = std::thread::spawn(move || {
3231            update_settings_preserving_unknown_top_level_fields(&left, |settings| {
3232                settings.selected_model.provider = Some("provider-a".to_string());
3233            })
3234            .unwrap();
3235        });
3236        let right = std::thread::spawn(move || {
3237            update_settings_preserving_unknown_top_level_fields(&right, |settings| {
3238                settings.session_titles = SessionTitleSettings {
3239                    enabled: true,
3240                    provider: Some("title-provider".to_string()),
3241                    model: Some("title-model".to_string()),
3242                };
3243            })
3244            .unwrap();
3245        });
3246        left.join().unwrap();
3247        right.join().unwrap();
3248
3249        let settings = read_settings(&paths).unwrap();
3250        assert_eq!(
3251            settings.selected_model.provider.as_deref(),
3252            Some("provider-a")
3253        );
3254        assert!(settings.session_titles.enabled);
3255        assert_eq!(
3256            settings.session_titles.provider.as_deref(),
3257            Some("title-provider")
3258        );
3259        assert_eq!(
3260            settings.session_titles.model.as_deref(),
3261            Some("title-model")
3262        );
3263    }
3264
3265    #[test]
3266    fn mcp_servers_parse_defaults_validate_and_serialize() {
3267        let settings: Settings = serde_json::from_str(
3268            r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30}}}"#,
3269        )
3270        .unwrap();
3271        let config = settings.mcp_servers.get("mock").unwrap();
3272        match config {
3273            McpServerConfig::Stdio(stdio) => {
3274                assert_eq!(stdio.command, "node");
3275                assert_eq!(stdio.args, vec!["server.js"]);
3276                assert!(stdio.env.is_empty());
3277                assert!(stdio.enabled);
3278                assert_eq!(stdio.timeout, Some(30));
3279            }
3280            McpServerConfig::Http(_) => panic!("expected stdio config"),
3281        }
3282
3283        let absent: Settings = serde_json::from_str("{}").unwrap();
3284        assert!(absent.mcp_servers.is_empty());
3285        let serialized = serde_json::to_value(settings).unwrap();
3286        assert_eq!(serialized["mcp_servers"]["mock"]["type"], "stdio");
3287    }
3288
3289    #[test]
3290    fn mcp_http_servers_parse_defaults_validate_serialize_and_redact_debug() {
3291        let settings: Settings = serde_json::from_str(
3292            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp?token=url-secret#frag","headers":{"Authorization":"{env:MCP_TOKEN}","X-Team":"platform"}}}}"#,
3293        )
3294        .unwrap();
3295        let config = settings.mcp_servers.get("remote").unwrap();
3296        match config {
3297            McpServerConfig::Http(http) => {
3298                assert_eq!(
3299                    http.url,
3300                    "https://mcp.example.test/mcp?token=url-secret#frag"
3301                );
3302                assert!(http.enabled);
3303                assert_eq!(http.timeout, None);
3304                assert_eq!(http.headers["Authorization"], "{env:MCP_TOKEN}");
3305                assert!(http.oauth.is_none());
3306                let debug = format!("{http:?}");
3307                assert!(debug.contains("[REDACTED]"), "{debug}");
3308                assert!(!debug.contains("platform"), "{debug}");
3309                assert!(!debug.contains("MCP_TOKEN"), "{debug}");
3310                assert!(!debug.contains("url-secret"), "{debug}");
3311                assert!(!debug.contains("token="), "{debug}");
3312            }
3313            McpServerConfig::Stdio(_) => panic!("expected http config"),
3314        }
3315        let serialized = serde_json::to_value(settings).unwrap();
3316        assert_eq!(serialized["mcp_servers"]["remote"]["type"], "http");
3317    }
3318
3319    #[test]
3320    fn mcp_http_servers_reject_invalid_url_headers_and_timeout() {
3321        for raw in [
3322            r#"{"mcp_servers":{"remote":{"type":"http","url":"ftp://mcp.example.test/mcp"}}}"#,
3323            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://user:pass@mcp.example.test/mcp"}}}"#,
3324            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Bad:Name":"x"}}}}"#,
3325            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"literal"}}}}"#,
3326            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"{env:1BAD}"}}}}"#,
3327            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","timeout":301}}}"#,
3328        ] {
3329            assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
3330        }
3331
3332        for raw in [
3333            r#"{"mcp_servers":{"local":{"type":"http","url":"http://localhost:8787/mcp"}}}"#,
3334            r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.1:8787/mcp"}}}"#,
3335            r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.2:8787/mcp"}}}"#,
3336            r#"{"mcp_servers":{"local":{"type":"http","url":"http://[::1]:8787/mcp"}}}"#,
3337        ] {
3338            assert!(parse_validated_settings(raw).is_ok(), "rejected {raw}");
3339        }
3340        let remote =
3341            r#"{"mcp_servers":{"remote":{"type":"http","url":"http://mcp.example.test/mcp"}}}"#;
3342        assert!(
3343            parse_validated_settings(remote).is_err(),
3344            "accepted {remote}"
3345        );
3346
3347        let userinfo = r#"{"mcp_servers":{"remote":{"type":"http","url":"https://user:pass@mcp.example.test/mcp"}}}"#;
3348        assert!(
3349            parse_validated_settings(userinfo).is_err(),
3350            "accepted {userinfo}"
3351        );
3352    }
3353    #[test]
3354    fn mcp_http_oauth_config_parses_validates_serializes_and_redacts_debug() {
3355        let settings: Settings = serde_json::from_str(
3356            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"X-Team":"platform"},"oauth":{"client_id":"public-client","scopes":["search","offline_access"],"authorization_server":"https://auth.example.test"}}}}"#,
3357        )
3358        .unwrap();
3359        let config = settings.mcp_servers.get("remote").unwrap();
3360        let McpServerConfig::Http(http) = config else {
3361            panic!("expected http config");
3362        };
3363        let oauth = http.oauth.as_ref().unwrap();
3364        assert_eq!(oauth.client_id.as_deref(), Some("public-client"));
3365        assert_eq!(oauth.scopes, vec!["search", "offline_access"]);
3366        assert_eq!(
3367            oauth.authorization_server.as_deref(),
3368            Some("https://auth.example.test")
3369        );
3370        let debug = format!("{oauth:?}");
3371        assert!(debug.contains("[REDACTED]"), "{debug}");
3372        assert!(!debug.contains("public-client"), "{debug}");
3373
3374        let serialized = serde_json::to_value(settings).unwrap();
3375        assert_eq!(
3376            serialized["mcp_servers"]["remote"]["oauth"]["client_id"],
3377            "public-client"
3378        );
3379    }
3380
3381    #[test]
3382    fn mcp_http_oauth_rejects_authorization_headers_and_invalid_fields() {
3383        for raw in [
3384            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"{env:MCP_TOKEN}"},"oauth":{}}}}"#,
3385            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Proxy-Authorization":"{env:MCP_TOKEN}"},"oauth":{}}}}"#,
3386            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"client_id":" "}}}}"#,
3387            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"scopes":[""]}}}}"#,
3388            r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"scopes":["bad\u0007scope"]}}}}"#,
3389        ] {
3390            assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
3391        }
3392
3393        for raw in [
3394            r#"{"mcp_servers":{"local":{"type":"http","url":"http://localhost:8787/mcp","oauth":{"authorization_server":"http://localhost:8788"}}}}"#,
3395            r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.1:8787/mcp","oauth":{"authorization_server":"http://127.0.0.2:8788"}}}}"#,
3396        ] {
3397            assert!(parse_validated_settings(raw).is_ok(), "rejected {raw}");
3398        }
3399        let remote = r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"authorization_server":"http://auth.example.test"}}}}"#;
3400        assert!(
3401            parse_validated_settings(remote).is_err(),
3402            "accepted {remote}"
3403        );
3404    }
3405
3406    #[test]
3407    fn settings_schema_generation_includes_mcp_http_config() {
3408        let schema = schemars::schema_for!(Settings);
3409        let schema_text = serde_json::to_string(&schema).unwrap();
3410        assert!(schema_text.contains("McpHttpServerConfig"), "{schema_text}");
3411        assert!(schema_text.contains("url"), "{schema_text}");
3412        assert!(schema_text.contains("headers"), "{schema_text}");
3413        assert!(schema_text.contains("McpOAuthConfig"), "{schema_text}");
3414        assert!(schema_text.contains("client_id"), "{schema_text}");
3415        assert!(schema_text.contains("scopes"), "{schema_text}");
3416        assert!(
3417            schema_text.contains("authorization_server"),
3418            "{schema_text}"
3419        );
3420    }
3421
3422    #[test]
3423    fn mcp_servers_reject_invalid_names_command_and_timeout() {
3424        for raw in [
3425            r#"{"mcp_servers":{"bad/name":{"type":"stdio","command":"node"}}}"#,
3426            r#"{"mcp_servers":{"bad__name":{"type":"stdio","command":"node"}}}"#,
3427            r#"{"mcp_servers":{"mock":{"type":"stdio","command":" "}}}"#,
3428            r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","timeout":0}}}"#,
3429            r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","timeout":301}}}"#,
3430        ] {
3431            assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
3432        }
3433    }
3434
3435    #[test]
3436    fn lsp_settings_parse_defaults_validate_and_serialize() {
3437        let absent: Settings = serde_json::from_str("{}").unwrap();
3438        assert_eq!(absent.lsp, LspSettings::default());
3439        assert!(!absent.lsp.enabled);
3440        assert!(absent.lsp.inject_diagnostics_on_edit);
3441        assert_eq!(
3442            absent.lsp.diagnostics_wait_ms,
3443            DEFAULT_LSP_DIAGNOSTICS_WAIT_MS
3444        );
3445        assert_eq!(
3446            absent.lsp.idle_shutdown_minutes,
3447            DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES
3448        );
3449        assert!(serde_json::to_value(&absent).unwrap().get("lsp").is_none());
3450
3451        let settings: Settings = serde_json::from_str(
3452            r#"{"lsp":{"enabled":true,"inject_diagnostics_on_edit":false,"diagnostics_wait_ms":1500,"idle_shutdown_minutes":30,"servers":{"rust-analyzer":{"command":"rust-analyzer","args":["--log-file","ra.log"],"enabled":false}}}}"#,
3453        )
3454        .unwrap();
3455        validate_settings(&settings).unwrap();
3456        assert!(settings.lsp.enabled);
3457        assert!(!settings.lsp.inject_diagnostics_on_edit);
3458        assert_eq!(settings.lsp.diagnostics_wait_ms, 1500);
3459        assert_eq!(settings.lsp.idle_shutdown_minutes, 30);
3460        let rust_analyzer = settings.lsp.servers.get("rust-analyzer").unwrap();
3461        assert_eq!(rust_analyzer.command, "rust-analyzer");
3462        assert_eq!(rust_analyzer.args, vec!["--log-file", "ra.log"]);
3463        assert!(!rust_analyzer.enabled);
3464
3465        let serialized = serde_json::to_value(settings).unwrap();
3466        assert_eq!(serialized["lsp"]["enabled"], true);
3467        assert_eq!(
3468            serialized["lsp"]["servers"]["rust-analyzer"]["command"],
3469            "rust-analyzer"
3470        );
3471    }
3472
3473    #[test]
3474    fn lsp_settings_reject_invalid_values() {
3475        for raw in [
3476            r#"{"lsp":{"diagnostics_wait_ms":0}}"#,
3477            r#"{"lsp":{"diagnostics_wait_ms":30001}}"#,
3478            r#"{"lsp":{"idle_shutdown_minutes":0}}"#,
3479            r#"{"lsp":{"idle_shutdown_minutes":241}}"#,
3480            r#"{"lsp":{"servers":{"":{"command":"rust-analyzer"}}}}"#,
3481            r#"{"lsp":{"servers":{"bad__name":{"command":"rust-analyzer"}}}}"#,
3482            r#"{"lsp":{"servers":{"rust-analyzer":{"command":" "}}}}"#,
3483        ] {
3484            assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
3485        }
3486    }
3487
3488    #[test]
3489    fn lsp_settings_preserved_and_removed_by_settings_update() {
3490        let mut raw = json!({
3491            "lsp": {
3492                "enabled": true,
3493                "servers": {"rust-analyzer": {"command": "rust-analyzer"}}
3494            },
3495            "selected_primary_agent": "old"
3496        });
3497        let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
3498        settings.selected_primary_agent = Some("new".to_string());
3499
3500        update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
3501
3502        assert_eq!(raw["lsp"]["enabled"], true);
3503        assert_eq!(
3504            raw["lsp"]["servers"]["rust-analyzer"]["command"],
3505            "rust-analyzer"
3506        );
3507        assert_eq!(raw["selected_primary_agent"], "new");
3508
3509        settings.lsp = LspSettings::default();
3510        update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
3511        assert!(raw.get("lsp").is_none());
3512    }
3513
3514    #[test]
3515    fn mcp_servers_preserved_by_settings_update() {
3516        let temp = tempfile::TempDir::new().unwrap();
3517        let paths = McPaths::from_root(temp.path().join("mc"));
3518        fs::create_dir_all(&paths.root).unwrap();
3519        fs::write(
3520            &paths.settings_file,
3521            r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30}},"future_setting":true}"#,
3522        )
3523        .unwrap();
3524
3525        set_selected_model(&paths, "provider", "model").unwrap();
3526
3527        let value: serde_json::Value =
3528            serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
3529        assert_eq!(value["mcp_servers"]["mock"]["command"], "node");
3530        assert_eq!(value["future_setting"], true);
3531    }
3532
3533    #[test]
3534    fn set_mcp_server_enabled_updates_stdio_http_and_preserves_unknowns() {
3535        let temp = tempfile::TempDir::new().unwrap();
3536        let paths = McPaths::from_root(temp.path().join("mc"));
3537        fs::create_dir_all(&paths.root).unwrap();
3538        fs::write(
3539            &paths.settings_file,
3540            r#"{
3541                "future_setting":{"keep":true},
3542                "mcp_servers":{
3543                    "mock":{"type":"stdio","command":"node","enabled":true},
3544                    "remote":{"type":"http","url":"https://mcp.example.test/mcp","enabled":false}
3545                }
3546            }"#,
3547        )
3548        .unwrap();
3549
3550        set_mcp_server_enabled(&paths, "mock", false).unwrap();
3551        set_mcp_server_enabled(&paths, "remote", true).unwrap();
3552
3553        let value = read_settings_value(&paths);
3554        assert_eq!(value["future_setting"]["keep"], true);
3555        assert_eq!(value["mcp_servers"]["mock"]["enabled"], false);
3556        assert_eq!(value["mcp_servers"]["remote"]["enabled"], true);
3557    }
3558
3559    #[test]
3560    fn set_mcp_server_enabled_rejects_missing_or_invalid_name() {
3561        let temp = tempfile::TempDir::new().unwrap();
3562        let paths = McPaths::from_root(temp.path().join("mc"));
3563        fs::create_dir_all(&paths.root).unwrap();
3564        fs::write(&paths.settings_file, r#"{"mcp_servers":{}}"#).unwrap();
3565
3566        let missing = set_mcp_server_enabled(&paths, "missing", true)
3567            .unwrap_err()
3568            .to_string();
3569        assert!(
3570            missing.contains("mcp server not found: missing"),
3571            "{missing}"
3572        );
3573
3574        let invalid = set_mcp_server_enabled(&paths, "bad/name", true)
3575            .unwrap_err()
3576            .to_string();
3577        assert!(invalid.contains("mcp server name"), "{invalid}");
3578    }
3579
3580    #[test]
3581    fn herdr_settings_default_enabled_and_unknown_fields() {
3582        let absent: Settings = serde_json::from_str("{}").unwrap();
3583        assert!(absent.integrations.herdr.is_default());
3584        assert!(!absent.integrations.herdr.enabled);
3585
3586        let enabled: Settings =
3587            serde_json::from_str(r#"{"integrations":{"herdr":{"enabled":true,"future":"kept"}}}"#)
3588                .unwrap();
3589        assert!(enabled.integrations.herdr.enabled);
3590        let value = serde_json::to_value(&enabled).unwrap();
3591        assert_eq!(value["integrations"]["herdr"]["enabled"], true);
3592        assert_eq!(value["integrations"]["herdr"]["future"], "kept");
3593
3594        let default_value = serde_json::to_value(Settings::default()).unwrap();
3595        assert!(default_value.get("integrations").is_none());
3596    }
3597}