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