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