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