1use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::PathBuf;
10
11use serde::de;
12use serde::{Deserialize, Deserializer};
13use serde_json::{Map, Value};
14
15use crate::config::{
16 expand_index_root_path, normalize_git_co_author, BackupConfig, Config, GhShimConfig, GitConfig,
17 GithubConfig, IdleConfig, IndexConfig, IndexKind, IndexRootConfig, InspectConfig,
18 SandboxConfig, SemanticBackend, SemanticBackendConfig, UserServerDef, WorktreeConfig,
19 DEFAULT_BASH_WATCH_SYNC_MAX_MS, DEFAULT_IDLE_LSP_TTL_MINUTES, DEFAULT_IDLE_ROOT_TTL_MINUTES,
20 DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MAX_BASH_WATCH_SYNC_MAX_MS, MAX_IDLE_LSP_TTL_MINUTES,
21 MAX_IDLE_ROOT_TTL_MINUTES, MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS,
22 MIN_BASH_WATCH_SYNC_MAX_MS, MIN_IDLE_LSP_TTL_MINUTES, MIN_IDLE_ROOT_TTL_MINUTES,
23 MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
24};
25use crate::harness::Harness;
26use crate::jsonc::strip_jsonc;
27
28const FOREGROUND_WAIT_WINDOW_DEFAULT_MS: u64 = 15_000;
29const FOREGROUND_WAIT_WINDOW_MIN_MS: u64 = 5_000;
30
31const MAX_SEMANTIC_TIMEOUT_MS: u64 = 120_000;
35const MAX_SEMANTIC_BATCH_SIZE: usize = 1_024;
36
37const USER_ONLY_REASON: &str =
38 "security: this setting only honors user-level config and project values are ignored";
39const SEMANTIC_SECRET_REASON: &str =
40 "security: semantic backend credentials and endpoints must come from user-level config";
41const LSP_USER_ONLY_REASON: &str =
42 "security: LSP executable-origin and diagnostic-suppression settings must come from user-level config";
43
44#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ConfigTier {
50 pub tier: String,
51 pub source: String,
52 pub doc: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct DroppedKey {
58 pub key: String,
59 pub tier: String,
60 pub reason: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ConfigWarning {
66 pub code: &'static str,
67 pub key: &'static str,
68 pub tier: String,
69 pub value: String,
70 pub message: String,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
75pub struct ResolveDiagnostics {
76 pub dropped: Vec<DroppedKey>,
77 pub warnings: Vec<ConfigWarning>,
78}
79
80#[derive(Debug, Clone)]
82pub struct ResolveResult {
83 pub config: Config,
84 pub dropped: Vec<DroppedKey>,
85 pub warnings: Vec<ConfigWarning>,
86}
87
88#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
92#[serde(default, deny_unknown_fields)]
93pub struct RawAftConfig {
94 #[serde(rename = "$schema")]
95 pub schema: Option<String>,
96 pub enabled: Option<bool>,
101 pub edit_mode: Option<RawEditMode>,
102 pub format_on_edit: Option<bool>,
103 #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
104 pub formatter_timeout_secs: Option<u32>,
105 #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
106 pub type_checker_timeout_secs: Option<u32>,
107 pub validate_on_edit: Option<RawValidateOnEdit>,
108 pub formatter: Option<HashMap<String, RawFormatter>>,
109 pub checker: Option<HashMap<String, RawChecker>>,
110 pub configure_warnings_delivery: Option<RawConfigureWarningsDelivery>,
111 pub hoist_builtin_tools: Option<bool>,
112 pub tool_surface: Option<RawToolSurface>,
113 pub disabled_tools: Option<Vec<String>>,
114 pub restrict_to_project_root: Option<bool>,
115 pub search_index: Option<bool>,
116 pub index: Option<RawIndex>,
117 pub semantic_search: Option<bool>,
118 pub views: Option<RawViews>,
119 pub callgraph_store: Option<bool>,
120 #[serde(deserialize_with = "deserialize_opt_usize")]
121 pub callgraph_chunk_size: Option<usize>,
122 pub inspect: Option<RawInspect>,
123 pub idle: Option<RawIdle>,
124 pub backup: Option<RawBackup>,
125 pub worktree: Option<RawWorktree>,
126 pub github: Option<RawGithub>,
127 pub gh_shim: Option<RawGhShim>,
128 pub gh_read: Option<RawGhRead>,
129 pub git: Option<RawGit>,
130 pub pi: Option<RawPi>,
131 pub sandbox: Option<RawSandbox>,
132 pub bash: Option<RawBash>,
133 pub experimental: Option<RawExperimental>,
134 pub lsp: Option<RawLsp>,
135 pub url_fetch_allow_private: Option<bool>,
136 pub semantic: Option<RawSemantic>,
137 pub auto_update: Option<bool>,
138 pub bridge: Option<RawBridge>,
139 pub subc: Option<RawSubc>,
140 pub harnesses: Option<BTreeMap<String, Value>>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum RawEditMode {
147 Default,
148 Hashline,
149 Unknown(String),
150}
151
152impl<'de> Deserialize<'de> for RawEditMode {
153 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
154 where
155 D: Deserializer<'de>,
156 {
157 let value = String::deserialize(deserializer)?;
158 Ok(match value.as_str() {
159 "default" => Self::Default,
160 "hashline" => Self::Hashline,
161 _ => Self::Unknown(value),
162 })
163 }
164}
165
166#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
167#[serde(rename_all = "snake_case")]
168pub enum RawValidateOnEdit {
169 Syntax,
170 Full,
171}
172
173impl RawValidateOnEdit {
174 const fn as_str(self) -> &'static str {
175 match self {
176 Self::Syntax => "syntax",
177 Self::Full => "full",
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
183#[serde(rename_all = "snake_case")]
184pub enum RawFormatter {
185 Biome,
186 Oxfmt,
187 Prettier,
188 Deno,
189 Ruff,
190 Black,
191 Rustfmt,
192 Goimports,
193 Gofmt,
194 None,
195}
196
197impl RawFormatter {
198 const fn as_str(self) -> &'static str {
199 match self {
200 Self::Biome => "biome",
201 Self::Oxfmt => "oxfmt",
202 Self::Prettier => "prettier",
203 Self::Deno => "deno",
204 Self::Ruff => "ruff",
205 Self::Black => "black",
206 Self::Rustfmt => "rustfmt",
207 Self::Goimports => "goimports",
208 Self::Gofmt => "gofmt",
209 Self::None => "none",
210 }
211 }
212}
213
214#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
215#[serde(rename_all = "snake_case")]
216pub enum RawChecker {
217 Tsc,
218 Tsgo,
219 Biome,
220 Pyright,
221 Ruff,
222 Cargo,
223 Go,
224 Staticcheck,
225 None,
226}
227
228impl RawChecker {
229 const fn as_str(self) -> &'static str {
230 match self {
231 Self::Tsc => "tsc",
232 Self::Tsgo => "tsgo",
233 Self::Biome => "biome",
234 Self::Pyright => "pyright",
235 Self::Ruff => "ruff",
236 Self::Cargo => "cargo",
237 Self::Go => "go",
238 Self::Staticcheck => "staticcheck",
239 Self::None => "none",
240 }
241 }
242}
243
244#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
245#[serde(rename_all = "snake_case")]
246pub enum RawConfigureWarningsDelivery {
247 Toast,
248 Log,
249 Chat,
250}
251
252#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
253#[serde(rename_all = "snake_case")]
254pub enum RawToolSurface {
255 Minimal,
256 Recommended,
257 All,
258}
259
260impl RawToolSurface {
261 pub const fn as_str(self) -> &'static str {
262 match self {
263 Self::Minimal => "minimal",
264 Self::Recommended => "recommended",
265 Self::All => "all",
266 }
267 }
268}
269
270#[derive(Debug, Clone, Deserialize, PartialEq)]
271pub struct RawSemantic {
276 pub backend: Option<SemanticBackend>,
277 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
278 pub model: Option<String>,
279 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
280 pub base_url: Option<String>,
281 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
282 pub api_key_env: Option<String>,
283 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
286 pub timeout_ms: Option<u64>,
287 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
288 pub query_timeout_ms: Option<u64>,
289 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
290 pub query_instruction: Option<String>,
291 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
292 pub max_batch_size: Option<usize>,
293 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
294 pub max_input_tokens: Option<usize>,
295 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
296 pub max_files: Option<usize>,
297}
298
299impl RawSemantic {
300 fn is_empty(&self) -> bool {
301 self.backend.is_none()
302 && self.model.is_none()
303 && self.base_url.is_none()
304 && self.api_key_env.is_none()
305 && self.timeout_ms.is_none()
306 && self.query_timeout_ms.is_none()
307 && self.query_instruction.is_none()
308 && self.max_batch_size.is_none()
309 && self.max_input_tokens.is_none()
310 && self.max_files.is_none()
311 }
312}
313
314#[derive(Debug, Clone, Deserialize, PartialEq)]
315pub struct RawLsp {
316 #[serde(default, deserialize_with = "deserialize_opt_lsp_servers")]
317 pub servers: Option<BTreeMap<String, RawLspServerEntry>>,
318 #[serde(
319 default,
320 deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec"
321 )]
322 pub disabled: Option<Vec<String>>,
323 pub python: Option<RawPythonLsp>,
324 pub diagnostics_on_edit: Option<bool>,
325 pub auto_install: Option<bool>,
326 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
327 pub grace_days: Option<u64>,
328 #[serde(default, deserialize_with = "deserialize_opt_versions_map")]
329 pub versions: Option<HashMap<String, String>>,
330}
331
332impl RawLsp {
333 fn is_empty(&self) -> bool {
334 self.servers.is_none()
335 && self.disabled.is_none()
336 && self.python.is_none()
337 && self.diagnostics_on_edit.is_none()
338 && self.auto_install.is_none()
339 && self.grace_days.is_none()
340 && self.versions.is_none()
341 }
342}
343
344#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
345#[serde(rename_all = "snake_case")]
346pub enum RawPythonLsp {
347 Pyright,
348 Ty,
349 Auto,
350}
351
352#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
353#[serde(default)]
354pub struct RawLspServerEntry {
355 #[serde(deserialize_with = "deserialize_opt_lsp_extensions")]
356 pub extensions: Option<Vec<String>>,
357 #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
358 pub binary: Option<String>,
359 pub args: Option<Vec<String>>,
360 #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec")]
361 pub root_markers: Option<Vec<String>>,
362 pub disabled: Option<bool>,
363 pub env: Option<HashMap<String, String>>,
364 pub initialization_options: Option<Value>,
365}
366
367#[derive(Debug, Clone, Deserialize, PartialEq)]
368#[serde(untagged)]
369pub enum RawBash {
370 Bool(bool),
371 Features(RawBashFeatures),
372}
373
374#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
375#[serde(default)]
376pub struct RawBashFeatures {
377 pub rewrite: Option<bool>,
378 pub compress: Option<bool>,
379 pub background: Option<bool>,
380 pub host_fallback: Option<bool>,
381 pub subagent_background: Option<bool>,
382 pub detach_on_user_message: Option<bool>,
383 pub long_running_reminder_enabled: Option<bool>,
384 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
385 pub long_running_reminder_interval_ms: Option<u64>,
386 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
387 pub foreground_wait_window_ms: Option<u64>,
388 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
389 pub watch_sync_max_ms: Option<u64>,
390 pub linux_scope: Option<bool>,
391 pub powershell_tool: Option<bool>,
392}
393
394#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
395#[serde(default)]
396pub struct RawExperimental {
397 pub bash: Option<RawExperimentalBash>,
398 pub lsp_ty: Option<bool>,
399}
400
401impl RawExperimental {
402 fn is_empty(&self) -> bool {
403 self.bash.is_none() && self.lsp_ty.is_none()
404 }
405}
406
407#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
408#[serde(default)]
409pub struct RawExperimentalBash {
410 pub rewrite: Option<bool>,
411 pub compress: Option<bool>,
412 pub background: Option<bool>,
413 pub long_running_reminder_enabled: Option<bool>,
414 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
415 pub long_running_reminder_interval_ms: Option<u64>,
416}
417
418impl RawExperimentalBash {
419 fn has_any_value(&self) -> bool {
420 self.rewrite.is_some()
421 || self.compress.is_some()
422 || self.background.is_some()
423 || self.long_running_reminder_enabled.is_some()
424 || self.long_running_reminder_interval_ms.is_some()
425 }
426
427 fn has_legacy_feature_flag(&self) -> bool {
428 self.rewrite.is_some() || self.compress.is_some() || self.background.is_some()
429 }
430}
431
432#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
433#[serde(default)]
434pub struct RawInspect {
435 pub enabled: Option<bool>,
436 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
437 pub diagnostics_timeout_ms: Option<u64>,
438 #[serde(deserialize_with = "deserialize_opt_nonnegative_f64")]
439 pub tier2_idle_minutes: Option<f64>,
440 pub categories: Option<HashMap<String, bool>>,
441 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
442 pub tier2_soft_deadline_ms: Option<u64>,
443 #[serde(deserialize_with = "deserialize_opt_drill_down_items")]
444 pub max_drill_down_items: Option<usize>,
445 pub duplicates: Option<RawInspectDuplicates>,
446}
447
448impl RawInspect {
449 fn is_empty(&self) -> bool {
450 self.enabled.is_none()
451 && self.diagnostics_timeout_ms.is_none()
452 && self.tier2_idle_minutes.is_none()
453 && self.categories.is_none()
454 && self.tier2_soft_deadline_ms.is_none()
455 && self.max_drill_down_items.is_none()
456 && self.duplicates.is_none()
457 }
458}
459
460#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
465#[serde(default)]
466pub struct RawInspectDuplicates {
467 pub expected_mirrors: Option<Vec<[String; 2]>>,
468}
469
470impl RawInspectDuplicates {
471 fn is_empty(&self) -> bool {
472 self.expected_mirrors.is_none()
473 }
474}
475
476#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
477#[serde(default)]
478pub struct RawIdle {
479 pub root_ttl_minutes: Option<Value>,
480 pub lsp_ttl_minutes: Option<Value>,
481}
482
483impl RawIdle {
484 fn is_empty(&self) -> bool {
485 self.root_ttl_minutes.is_none() && self.lsp_ttl_minutes.is_none()
486 }
487}
488
489#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
490#[serde(default)]
491pub struct RawBridge {
492 #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
493 pub request_timeout_ms: Option<u64>,
494 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
495 pub hang_threshold: Option<u64>,
496}
497
498#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
499#[serde(default)]
500pub struct RawSubc {
501 pub connection_file: Option<String>,
502 pub client_reaper: Option<bool>,
503}
504
505#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
506#[serde(default)]
507pub struct RawViews {
508 pub enabled: Option<bool>,
509}
510
511#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
512#[serde(default)]
513pub struct RawWorktree {
514 pub ram_overlay: Option<bool>,
515}
516
517impl RawWorktree {
518 fn is_empty(&self) -> bool {
519 self.ram_overlay.is_none()
520 }
521}
522
523#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
524#[serde(default)]
525pub struct RawGithub {
526 pub enabled: Option<bool>,
527 pub shim: Option<bool>,
528 pub read: Option<bool>,
529 pub write: Option<bool>,
530}
531
532#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
533#[serde(default)]
534pub struct RawGhShim {
535 pub enabled: Option<bool>,
536 #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
537 pub binary_path: Option<String>,
538}
539
540#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
541#[serde(default)]
542pub struct RawGhRead {
543 pub enabled: Option<bool>,
544}
545
546#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
547#[serde(default)]
548pub struct RawGit {
549 #[serde(deserialize_with = "deserialize_opt_git_co_author")]
550 pub co_author: Option<String>,
551}
552
553#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
554#[serde(default)]
555pub struct RawPi {
556 pub tool_presentation: Option<RawPiToolPresentation>,
557}
558
559impl RawPi {
560 fn is_empty(&self) -> bool {
561 self.tool_presentation.is_none()
562 }
563}
564
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub enum RawPiToolPresentation {
567 TopLevel,
568 HostDefault,
569 Unknown(String),
570}
571
572impl<'de> Deserialize<'de> for RawPiToolPresentation {
573 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
574 where
575 D: Deserializer<'de>,
576 {
577 let s = String::deserialize(deserializer)?;
578 match s.as_str() {
579 "top_level" => Ok(Self::TopLevel),
580 "host_default" => Ok(Self::HostDefault),
581 other => Ok(Self::Unknown(other.to_string())),
582 }
583 }
584}
585
586#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
587#[serde(default)]
588pub struct RawBackup {
589 pub enabled: Option<bool>,
590 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
591 pub max_depth: Option<usize>,
592 pub max_file_size: Option<u64>,
593}
594
595#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
596#[serde(default)]
597pub struct RawSandbox {
598 pub enabled: Option<bool>,
599 pub write_allow: Option<Vec<PathBuf>>,
600 pub read_deny: Option<Vec<PathBuf>>,
601}
602
603#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
607#[serde(default)]
608pub struct RawIndex {
609 pub roots: Option<Vec<RawIndexRoot>>,
610}
611
612#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
613#[serde(default)]
614pub struct RawIndexRoot {
615 pub path: Option<String>,
616 pub indexes: Option<Vec<String>>,
617 #[serde(flatten)]
618 pub unknown: BTreeMap<String, Value>,
619}
620
621pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
628 resolve_config_for_harness(tiers, None)
629}
630
631pub fn resolve_config_for_harness(
634 tiers: &[ConfigTier],
635 harness: Option<&Harness>,
636) -> ResolveResult {
637 let mut merged = RawAftConfig::default();
638 let mut dropped = Vec::new();
639 let mut warnings = Vec::new();
640
641 for tier in tiers {
642 let Some(mut raw) = parse_tier(tier) else {
643 continue;
644 };
645 apply_harness_override(&mut raw, harness, tier, &mut warnings);
646 apply_github_aliases(&mut raw, tier, &mut warnings);
647 if let Some(RawEditMode::Unknown(value)) = raw.edit_mode.as_ref() {
648 warnings.push(ConfigWarning {
649 code: "invalid_edit_mode",
650 key: "edit_mode",
651 tier: tier.tier.clone(),
652 value: value.clone(),
653 message: format!("Unknown edit_mode value {value:?}; falling back to \"default\""),
654 });
655 }
656
657 if tier.tier == "user" {
658 merge_trusted_config(&mut merged, raw);
659 } else {
660 record_project_drops(&raw, &tier.tier, &mut dropped);
661 merge_project_config(&mut merged, raw);
662 }
663 }
664
665 let mut config = Config::default();
666 apply_resolved_config(&merged, &mut config, &mut warnings);
667 config.index = resolve_index_config(merged.index.as_ref(), &mut warnings);
668 config.idle = resolve_idle_config(merged.idle.as_ref(), &mut warnings);
669 ResolveResult {
670 config,
671 dropped,
672 warnings,
673 }
674}
675
676pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
705 resolve_config_onto_with_diagnostics(tiers, base).dropped
706}
707
708pub fn resolve_config_onto_for_harness(
711 tiers: &[ConfigTier],
712 harness: &Harness,
713 base: &mut Config,
714) -> Vec<DroppedKey> {
715 resolve_config_onto_with_diagnostics_for_harness(tiers, Some(harness), base).dropped
716}
717
718pub fn resolve_config_onto_with_diagnostics(
721 tiers: &[ConfigTier],
722 base: &mut Config,
723) -> ResolveDiagnostics {
724 resolve_config_onto_with_diagnostics_for_harness(tiers, None, base)
725}
726
727pub fn resolve_config_onto_with_diagnostics_for_harness(
729 tiers: &[ConfigTier],
730 harness: Option<&Harness>,
731 base: &mut Config,
732) -> ResolveDiagnostics {
733 let ResolveResult {
734 mut config,
735 dropped,
736 warnings,
737 } = resolve_config_for_harness(tiers, harness);
738 carry_process_state(base, &mut config);
739 *base = config;
740 ResolveDiagnostics { dropped, warnings }
741}
742
743fn carry_process_state(base: &Config, resolved: &mut Config) {
749 resolved.project_root = base.project_root.clone();
750 resolved.harness = base.harness.clone();
751 resolved.validation_depth = base.validation_depth;
752 resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
753 resolved.max_symbol_depth = base.max_symbol_depth;
754 resolved.diagnostic_cache_size = base.diagnostic_cache_size;
755 resolved.aft_search_registered = base.aft_search_registered;
756 resolved.max_background_bash_tasks = base.max_background_bash_tasks;
757 resolved.bash_permissions = base.bash_permissions;
758 resolved.search_index_max_file_size = base.search_index_max_file_size;
759 resolved.storage_dir = base.storage_dir.clone();
760 resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
761 resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
762 resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
763}
764
765fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
766 let stripped = strip_jsonc(&tier.doc);
767 let value = serde_json::from_str::<Value>(&stripped).ok()?;
768 let Value::Object(map) = value else {
769 return None;
770 };
771
772 match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
773 Ok(config) => Some(config),
774 Err(_) => Some(parse_config_partially(map)),
775 }
776}
777
778fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
779 let mut partial = RawAftConfig::default();
780
781 for (key, value) in raw_config {
782 let mut one_field = Map::new();
783 one_field.insert(key, value);
784 if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
785 merge_trusted_config(&mut partial, section);
786 }
787 }
788
789 partial
790}
791
792fn apply_github_aliases(
793 raw: &mut RawAftConfig,
794 tier: &ConfigTier,
795 warnings: &mut Vec<ConfigWarning>,
796) {
797 let legacy_shim = raw.gh_shim.as_ref().and_then(|legacy| legacy.enabled);
798 if let Some(value) = legacy_shim {
799 warnings.push(ConfigWarning {
800 code: "deprecated_config_key",
801 key: "gh_shim.enabled",
802 tier: tier.tier.clone(),
803 value: value.to_string(),
804 message: "gh_shim.enabled is deprecated; use github.shim instead (the alias is removed in v0.57.0)".to_string(),
805 });
806 let github = raw.github.get_or_insert_with(RawGithub::default);
807 if github.shim.is_none() {
808 github.shim = Some(value);
809 }
810 if let Some(legacy) = raw.gh_shim.as_mut() {
811 legacy.enabled = None;
812 }
813 if raw
814 .gh_shim
815 .as_ref()
816 .is_some_and(|legacy| legacy.binary_path.is_none())
817 {
818 raw.gh_shim = None;
819 }
820 }
821
822 if let Some(value) = raw.gh_read.as_ref().and_then(|legacy| legacy.enabled) {
823 warnings.push(ConfigWarning {
824 code: "deprecated_config_key",
825 key: "gh_read.enabled",
826 tier: tier.tier.clone(),
827 value: value.to_string(),
828 message: "gh_read.enabled is deprecated; use github.read instead (the alias is removed in v0.57.0)".to_string(),
829 });
830 let github = raw.github.get_or_insert_with(RawGithub::default);
831 if github.read.is_none() {
832 github.read = Some(value);
833 }
834 raw.gh_read = None;
835 }
836}
837
838fn apply_harness_override(
839 raw: &mut RawAftConfig,
840 harness: Option<&Harness>,
841 tier: &ConfigTier,
842 warnings: &mut Vec<ConfigWarning>,
843) {
844 let Some(overrides) = raw.harnesses.take() else {
845 return;
846 };
847 let Some(harness) = harness else {
848 return;
849 };
850 let key = harness.wire_label();
851 let Some(value) = overrides.get(&key) else {
852 return;
853 };
854 let Value::Object(mut override_map) = value.clone() else {
855 warnings.push(ConfigWarning {
856 code: "invalid_harness_override",
857 key: "harnesses",
858 tier: tier.tier.clone(),
859 value: key,
860 message: "Ignoring non-object harness override; overrides must be config objects"
861 .to_string(),
862 });
863 return;
864 };
865
866 if override_map.remove("harnesses").is_some() {
867 warnings.push(ConfigWarning {
868 code: "nested_harnesses_ignored",
869 key: "harnesses",
870 tier: tier.tier.clone(),
871 value: key.clone(),
872 message: format!(
873 "Ignoring nested harnesses in harnesses.{key}; harness overrides cannot recurse"
874 ),
875 });
876 }
877
878 match serde_json::from_value::<RawAftConfig>(Value::Object(override_map)) {
879 Ok(override_config) => merge_trusted_config(raw, override_config),
880 Err(_) => warnings.push(ConfigWarning {
881 code: "invalid_harness_override",
882 key: "harnesses",
883 tier: tier.tier.clone(),
884 value: key,
885 message: "Ignoring invalid harness override; it must use the root config shape"
886 .to_string(),
887 }),
888 }
889}
890
891fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
892 if override_config.harnesses.is_some() {
893 base.harnesses = override_config.harnesses.clone();
894 }
895 if override_config.schema.is_some() {
896 base.schema = override_config.schema;
897 }
898 if override_config.enabled.is_some() {
899 base.enabled = override_config.enabled;
900 }
901 if override_config.edit_mode.is_some() {
902 base.edit_mode = override_config.edit_mode;
903 }
904 if override_config.format_on_edit.is_some() {
905 base.format_on_edit = override_config.format_on_edit;
906 }
907 if override_config.formatter_timeout_secs.is_some() {
908 base.formatter_timeout_secs = override_config.formatter_timeout_secs;
909 }
910 if override_config.type_checker_timeout_secs.is_some() {
911 base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
912 }
913 if override_config.validate_on_edit.is_some() {
914 base.validate_on_edit = override_config.validate_on_edit;
915 }
916 if override_config.formatter.is_some() {
917 base.formatter = override_config.formatter;
918 }
919 if override_config.checker.is_some() {
920 base.checker = override_config.checker;
921 }
922 if override_config.configure_warnings_delivery.is_some() {
923 base.configure_warnings_delivery = override_config.configure_warnings_delivery;
924 }
925 if override_config.hoist_builtin_tools.is_some() {
926 base.hoist_builtin_tools = override_config.hoist_builtin_tools;
927 }
928 if override_config.tool_surface.is_some() {
929 base.tool_surface = override_config.tool_surface;
930 }
931 if override_config.disabled_tools.is_some() {
932 base.disabled_tools = override_config.disabled_tools;
933 }
934 if override_config.restrict_to_project_root.is_some() {
935 base.restrict_to_project_root = override_config.restrict_to_project_root;
936 }
937 if override_config.search_index.is_some() {
938 base.search_index = override_config.search_index;
939 }
940 if override_config.index.is_some() {
941 base.index = override_config.index;
942 }
943 if override_config.semantic_search.is_some() {
944 base.semantic_search = override_config.semantic_search;
945 }
946 if override_config.views.is_some() {
947 base.views = override_config.views;
948 }
949 if override_config.callgraph_store.is_some() {
950 base.callgraph_store = override_config.callgraph_store;
951 }
952 if override_config.callgraph_chunk_size.is_some() {
953 base.callgraph_chunk_size = override_config.callgraph_chunk_size;
954 }
955 if override_config.inspect.is_some() {
956 base.inspect = override_config.inspect;
957 }
958 if override_config.idle.is_some() {
959 base.idle = override_config.idle;
960 }
961 if override_config.backup.is_some() {
962 base.backup = override_config.backup;
963 }
964 if override_config.worktree.is_some() {
965 base.worktree = override_config.worktree;
966 }
967 if override_config.github.is_some() {
968 base.github = override_config.github;
969 }
970 if override_config.gh_shim.is_some() {
971 base.gh_shim = override_config.gh_shim;
972 }
973 if override_config.gh_read.is_some() {
974 base.gh_read = override_config.gh_read;
975 }
976 if override_config.git.is_some() {
977 base.git = override_config.git;
978 }
979 if override_config.pi.is_some() {
980 base.pi = override_config.pi;
981 }
982 if override_config.sandbox.is_some() {
983 base.sandbox = override_config.sandbox;
984 }
985 if override_config.bash.is_some() {
986 base.bash = override_config.bash;
987 }
988 if override_config.experimental.is_some() {
989 base.experimental = override_config.experimental;
990 }
991 if override_config.lsp.is_some() {
992 base.lsp = override_config.lsp;
993 }
994 if override_config.url_fetch_allow_private.is_some() {
995 base.url_fetch_allow_private = override_config.url_fetch_allow_private;
996 }
997 if override_config.semantic.is_some() {
998 base.semantic = override_config.semantic;
999 }
1000 if override_config.auto_update.is_some() {
1001 base.auto_update = override_config.auto_update;
1002 }
1003 if override_config.bridge.is_some() {
1004 base.bridge = override_config.bridge;
1005 }
1006 if override_config.subc.is_some() {
1007 base.subc = override_config.subc;
1008 }
1009}
1010
1011fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
1012 if project.enabled.is_some() {
1014 base.enabled = project.enabled;
1015 }
1016 if project.edit_mode.is_some() {
1017 base.edit_mode = project.edit_mode;
1018 }
1019 if project.format_on_edit.is_some() {
1020 base.format_on_edit = project.format_on_edit;
1021 }
1022 if project.validate_on_edit.is_some() {
1023 base.validate_on_edit = project.validate_on_edit;
1024 }
1025 if project.configure_warnings_delivery.is_some() {
1026 base.configure_warnings_delivery = project.configure_warnings_delivery;
1027 }
1028 if project.hoist_builtin_tools.is_some() {
1029 base.hoist_builtin_tools = project.hoist_builtin_tools;
1030 }
1031 if project.tool_surface.is_some() {
1032 base.tool_surface = project.tool_surface;
1033 }
1034 if project.search_index.is_some() {
1035 base.search_index = project.search_index;
1036 }
1037 if project.semantic_search.is_some() {
1038 base.semantic_search = project.semantic_search;
1039 }
1040 if project.views.is_some() {
1041 base.views = project.views;
1042 }
1043 if project.callgraph_store.is_some() {
1044 base.callgraph_store = project.callgraph_store;
1045 }
1046 if project.callgraph_chunk_size.is_some() {
1047 base.callgraph_chunk_size = project.callgraph_chunk_size;
1048 }
1049
1050 merge_formatter_map(&mut base.formatter, project.formatter);
1051 merge_checker_map(&mut base.checker, project.checker);
1052 merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
1053 base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
1054 base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
1055 base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
1056 base.bash = merge_bash_config(base.bash.clone(), project_safe_bash(project.bash));
1057 base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
1058 base.idle = merge_idle_config(base.idle.clone(), project.idle);
1059 base.worktree = merge_worktree_config(base.worktree.clone(), project.worktree);
1060 base.backup = merge_project_backup_config(base.backup.clone(), project.backup);
1061 if project.git.is_some() {
1062 base.git = project.git;
1063 }
1064 base.pi = merge_pi_config(base.pi.clone(), project.pi);
1065 base.sandbox = merge_project_sandbox(base.sandbox.clone(), project.sandbox);
1066}
1067
1068fn merge_project_backup_config(
1069 base: Option<RawBackup>,
1070 project: Option<RawBackup>,
1071) -> Option<RawBackup> {
1072 let Some(project_max_file_size) = project.and_then(|backup| backup.max_file_size) else {
1073 return base;
1074 };
1075 let mut backup = base.unwrap_or_default();
1076 backup.max_file_size = Some(project_max_file_size);
1077 Some(backup)
1078}
1079
1080fn merge_project_sandbox(
1081 base: Option<RawSandbox>,
1082 project: Option<RawSandbox>,
1083) -> Option<RawSandbox> {
1084 let Some(project) = project else {
1085 return base;
1086 };
1087 let mut sandbox = base.unwrap_or_default();
1088 if let Some(project_denies) = project.read_deny {
1089 let denies = sandbox.read_deny.get_or_insert_with(Vec::new);
1090 for path in project_denies {
1091 if !denies.contains(&path) {
1092 denies.push(path);
1093 }
1094 }
1095 }
1096 if project.enabled == Some(true) {
1100 sandbox.enabled = Some(true);
1101 }
1102 (sandbox.enabled.is_some() || sandbox.write_allow.is_some() || sandbox.read_deny.is_some())
1103 .then_some(sandbox)
1104}
1105
1106fn merge_formatter_map(
1107 base: &mut Option<HashMap<String, RawFormatter>>,
1108 override_map: Option<HashMap<String, RawFormatter>>,
1109) {
1110 let Some(override_map) = override_map else {
1111 return;
1112 };
1113 if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
1114 return;
1115 }
1116 let target = base.get_or_insert_with(HashMap::new);
1117 target.extend(override_map);
1118}
1119
1120fn merge_checker_map(
1121 base: &mut Option<HashMap<String, RawChecker>>,
1122 override_map: Option<HashMap<String, RawChecker>>,
1123) {
1124 let Some(override_map) = override_map else {
1125 return;
1126 };
1127 if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
1128 return;
1129 }
1130 let target = base.get_or_insert_with(HashMap::new);
1131 target.extend(override_map);
1132}
1133
1134fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
1135 let Some(override_tools) = override_tools else {
1136 return;
1137 };
1138 let mut merged = Vec::new();
1139 let mut seen = HashSet::new();
1140 for tool in base.iter().flatten() {
1141 if seen.insert(tool.clone()) {
1142 merged.push(tool.clone());
1143 }
1144 }
1145 for tool in override_tools
1146 .iter()
1147 .filter(|tool| tool.as_str() != "aft_safety")
1148 {
1149 if seen.insert(tool.clone()) {
1150 merged.push(tool.clone());
1151 }
1152 }
1153 if !merged.is_empty() {
1154 *base = Some(merged);
1155 }
1156}
1157
1158fn merge_semantic_config(
1159 base: Option<RawSemantic>,
1160 override_semantic: Option<RawSemantic>,
1161) -> Option<RawSemantic> {
1162 let mut semantic = base.unwrap_or(RawSemantic {
1163 backend: None,
1164 model: None,
1165 base_url: None,
1166 api_key_env: None,
1167 timeout_ms: None,
1168 query_timeout_ms: None,
1169 query_instruction: None,
1170 max_batch_size: None,
1171 max_input_tokens: None,
1172 max_files: None,
1173 });
1174
1175 if let Some(project) = override_semantic {
1176 if project.model.is_some() {
1177 semantic.model = project.model;
1178 }
1179 if project.timeout_ms.is_some() {
1180 semantic.timeout_ms = project.timeout_ms;
1181 }
1182 if project.query_instruction.is_some() {
1183 semantic.query_instruction = project.query_instruction;
1184 }
1185 if project.max_batch_size.is_some() {
1186 semantic.max_batch_size = project.max_batch_size;
1187 }
1188 if project.max_input_tokens.is_some() {
1189 semantic.max_input_tokens = project.max_input_tokens;
1190 }
1191 if project.max_files.is_some() {
1192 semantic.max_files = project.max_files;
1193 }
1194 }
1195
1196 (!semantic.is_empty()).then_some(semantic)
1197}
1198
1199fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
1200 let mut lsp = base.unwrap_or(RawLsp {
1201 servers: None,
1202 disabled: None,
1203 python: None,
1204 diagnostics_on_edit: None,
1205 auto_install: None,
1206 grace_days: None,
1207 versions: None,
1208 });
1209
1210 if let Some(project) = override_lsp {
1211 if project.python.is_some() {
1212 lsp.python = project.python;
1213 }
1214 if project.diagnostics_on_edit.is_some() {
1215 lsp.diagnostics_on_edit = project.diagnostics_on_edit;
1216 }
1217 }
1218
1219 (!lsp.is_empty()).then_some(lsp)
1220}
1221
1222fn merge_experimental_config(
1223 base: Option<RawExperimental>,
1224 override_experimental: Option<RawExperimental>,
1225) -> Option<RawExperimental> {
1226 let Some(override_experimental) = override_experimental else {
1227 return base;
1228 };
1229
1230 let mut experimental = base.unwrap_or_default();
1231 experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
1232 experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
1233
1234 (!experimental.is_empty()).then_some(experimental)
1235}
1236
1237fn merge_experimental_bash(
1238 base: Option<RawExperimentalBash>,
1239 override_bash: Option<RawExperimentalBash>,
1240) -> Option<RawExperimentalBash> {
1241 let Some(override_bash) = override_bash else {
1242 return base;
1243 };
1244 let mut bash = base.unwrap_or_default();
1245 bash.rewrite = override_bash.rewrite.or(bash.rewrite);
1246 bash.compress = override_bash.compress.or(bash.compress);
1247 bash.background = override_bash.background.or(bash.background);
1248 bash.long_running_reminder_enabled = override_bash
1249 .long_running_reminder_enabled
1250 .or(bash.long_running_reminder_enabled);
1251 bash.long_running_reminder_interval_ms = override_bash
1252 .long_running_reminder_interval_ms
1253 .or(bash.long_running_reminder_interval_ms);
1254
1255 bash.has_any_value().then_some(bash)
1256}
1257
1258fn project_safe_bash(project: Option<RawBash>) -> Option<RawBash> {
1259 project.map(|bash| match bash {
1260 RawBash::Bool(enabled) => RawBash::Bool(enabled),
1261 RawBash::Features(mut features) => {
1262 features.linux_scope = None;
1263 RawBash::Features(features)
1264 }
1265 })
1266}
1267
1268fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
1269 match (base, override_bash) {
1270 (None, None) => None,
1271 (None, Some(override_bash)) => Some(override_bash),
1272 (Some(base), None) => Some(base),
1273 (Some(base), Some(override_bash)) => {
1274 let base = expand_bash_for_merge(&base);
1275 let override_features = expand_bash_for_merge(&override_bash);
1276 Some(RawBash::Features(RawBashFeatures {
1277 rewrite: override_features.rewrite.or(base.rewrite),
1278 compress: override_features.compress.or(base.compress),
1279 background: override_features.background.or(base.background),
1280 host_fallback: override_features.host_fallback.or(base.host_fallback),
1281 subagent_background: override_features
1282 .subagent_background
1283 .or(base.subagent_background),
1284 detach_on_user_message: override_features
1285 .detach_on_user_message
1286 .or(base.detach_on_user_message),
1287 long_running_reminder_enabled: override_features
1288 .long_running_reminder_enabled
1289 .or(base.long_running_reminder_enabled),
1290 long_running_reminder_interval_ms: override_features
1291 .long_running_reminder_interval_ms
1292 .or(base.long_running_reminder_interval_ms),
1293 foreground_wait_window_ms: override_features
1294 .foreground_wait_window_ms
1295 .or(base.foreground_wait_window_ms),
1296 watch_sync_max_ms: override_features
1297 .watch_sync_max_ms
1298 .or(base.watch_sync_max_ms),
1299 linux_scope: override_features.linux_scope.or(base.linux_scope),
1300 powershell_tool: override_features.powershell_tool.or(base.powershell_tool),
1301 }))
1302 }
1303 }
1304}
1305
1306fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
1307 match value {
1308 RawBash::Bool(enabled) => RawBashFeatures {
1309 rewrite: Some(*enabled),
1310 compress: Some(*enabled),
1311 background: Some(*enabled),
1312 host_fallback: None,
1313 subagent_background: None,
1314 detach_on_user_message: None,
1315 long_running_reminder_enabled: None,
1316 long_running_reminder_interval_ms: None,
1317 foreground_wait_window_ms: None,
1318 watch_sync_max_ms: None,
1319 linux_scope: None,
1320 powershell_tool: None,
1321 },
1322 RawBash::Features(features) => features.clone(),
1323 }
1324}
1325
1326fn merge_idle_config(base: Option<RawIdle>, override_idle: Option<RawIdle>) -> Option<RawIdle> {
1327 let Some(override_idle) = override_idle else {
1328 return base;
1329 };
1330 let mut idle = base.unwrap_or_default();
1331 if override_idle.root_ttl_minutes.is_some() {
1332 idle.root_ttl_minutes = override_idle.root_ttl_minutes;
1333 }
1334 if override_idle.lsp_ttl_minutes.is_some() {
1335 idle.lsp_ttl_minutes = override_idle.lsp_ttl_minutes;
1336 }
1337 (!idle.is_empty()).then_some(idle)
1338}
1339
1340fn merge_inspect_config(
1341 base: Option<RawInspect>,
1342 override_inspect: Option<RawInspect>,
1343) -> Option<RawInspect> {
1344 let Some(override_inspect) = override_inspect else {
1345 return base;
1346 };
1347
1348 let mut inspect = base.unwrap_or_default();
1349 inspect.enabled = override_inspect.enabled.or(inspect.enabled);
1350 if let Some(project_timeout) = override_inspect.diagnostics_timeout_ms {
1351 inspect.diagnostics_timeout_ms = Some(
1354 project_timeout.max(
1355 inspect
1356 .diagnostics_timeout_ms
1357 .unwrap_or(DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS),
1358 ),
1359 );
1360 }
1361 inspect.tier2_idle_minutes = override_inspect
1362 .tier2_idle_minutes
1363 .or(inspect.tier2_idle_minutes);
1364 inspect.categories = override_inspect.categories.or(inspect.categories);
1365 inspect.tier2_soft_deadline_ms = override_inspect
1366 .tier2_soft_deadline_ms
1367 .or(inspect.tier2_soft_deadline_ms);
1368 inspect.max_drill_down_items = override_inspect
1369 .max_drill_down_items
1370 .or(inspect.max_drill_down_items);
1371 inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
1372
1373 (!inspect.is_empty()).then_some(inspect)
1374}
1375
1376fn merge_worktree_config(
1377 base: Option<RawWorktree>,
1378 override_worktree: Option<RawWorktree>,
1379) -> Option<RawWorktree> {
1380 let Some(override_worktree) = override_worktree else {
1381 return base;
1382 };
1383
1384 let mut worktree = base.unwrap_or_default();
1385 worktree.ram_overlay = override_worktree.ram_overlay.or(worktree.ram_overlay);
1386 (!worktree.is_empty()).then_some(worktree)
1387}
1388
1389fn merge_pi_config(base: Option<RawPi>, override_pi: Option<RawPi>) -> Option<RawPi> {
1390 let Some(override_pi) = override_pi else {
1391 return base;
1392 };
1393
1394 let mut pi = base.unwrap_or_default();
1395 pi.tool_presentation = override_pi.tool_presentation.or(pi.tool_presentation);
1396 (!pi.is_empty()).then_some(pi)
1397}
1398
1399fn merge_inspect_duplicates(
1400 base: Option<RawInspectDuplicates>,
1401 override_duplicates: Option<RawInspectDuplicates>,
1402) -> Option<RawInspectDuplicates> {
1403 let Some(override_duplicates) = override_duplicates else {
1404 return base;
1405 };
1406
1407 let mut duplicates = base.unwrap_or_default();
1408 duplicates.expected_mirrors = override_duplicates
1409 .expected_mirrors
1410 .or(duplicates.expected_mirrors);
1411
1412 (!duplicates.is_empty()).then_some(duplicates)
1413}
1414
1415fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
1416 if raw.restrict_to_project_root.is_some() {
1417 push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
1418 }
1419 if raw.url_fetch_allow_private.is_some() {
1420 push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
1421 }
1422 if raw.formatter_timeout_secs.is_some() {
1423 push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
1424 }
1425 if raw.type_checker_timeout_secs.is_some() {
1426 push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
1427 }
1428 if raw.auto_update.is_some() {
1429 push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
1430 }
1431 if raw.bridge.is_some() {
1432 push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
1433 }
1434 if raw.subc.is_some() {
1435 push_drop(dropped, "subc", tier, USER_ONLY_REASON);
1436 }
1437 if raw
1438 .backup
1439 .as_ref()
1440 .is_some_and(|backup| backup.enabled.is_some() || backup.max_depth.is_some())
1441 {
1442 push_drop(dropped, "backup", tier, USER_ONLY_REASON);
1443 }
1444 if raw.github.is_some() {
1445 push_drop(dropped, "github", tier, USER_ONLY_REASON);
1446 }
1447 if raw.gh_shim.is_some() {
1448 push_drop(dropped, "gh_shim", tier, USER_ONLY_REASON);
1449 }
1450 if raw.gh_read.is_some() {
1451 push_drop(dropped, "gh_read", tier, USER_ONLY_REASON);
1452 }
1453 if raw
1454 .index
1455 .as_ref()
1456 .and_then(|index| index.roots.as_ref())
1457 .is_some()
1458 {
1459 push_drop(dropped, "index.roots", tier, USER_ONLY_REASON);
1460 }
1461 if let Some(sandbox) = &raw.sandbox {
1462 if sandbox.enabled == Some(false) {
1465 push_drop(dropped, "sandbox.enabled", tier, USER_ONLY_REASON);
1466 }
1467 if sandbox.write_allow.is_some() {
1468 push_drop(dropped, "sandbox.write_allow", tier, USER_ONLY_REASON);
1469 }
1470 }
1471 if raw.bash.as_ref().is_some_and(
1472 |bash| matches!(bash, RawBash::Features(features) if features.linux_scope.is_some()),
1473 ) {
1474 push_drop(dropped, "bash.linux_scope", tier, USER_ONLY_REASON);
1475 }
1476 if raw
1477 .disabled_tools
1478 .as_ref()
1479 .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
1480 {
1481 push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
1482 }
1483
1484 if let Some(semantic) = &raw.semantic {
1485 if semantic.backend.is_some() {
1486 push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
1487 }
1488 if semantic.base_url.is_some() {
1489 push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
1490 }
1491 if semantic.api_key_env.is_some() {
1492 push_drop(
1493 dropped,
1494 "semantic.api_key_env",
1495 tier,
1496 SEMANTIC_SECRET_REASON,
1497 );
1498 }
1499 if semantic.query_timeout_ms.is_some() {
1500 push_drop(dropped, "semantic.query_timeout_ms", tier, USER_ONLY_REASON);
1501 }
1502 }
1503
1504 if let Some(lsp) = &raw.lsp {
1505 if lsp.servers.is_some() {
1506 push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
1507 }
1508 if lsp.versions.is_some() {
1509 push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
1510 }
1511 if lsp.auto_install.is_some() {
1512 push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
1513 }
1514 if lsp.grace_days.is_some() {
1515 push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
1516 }
1517 if lsp.disabled.is_some() {
1518 push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
1519 }
1520 }
1521}
1522
1523fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
1524 dropped.push(DroppedKey {
1525 key: key.to_string(),
1526 tier: tier.to_string(),
1527 reason: reason.to_string(),
1528 });
1529}
1530
1531fn apply_resolved_config(
1536 raw: &RawAftConfig,
1537 config: &mut Config,
1538 warnings: &mut Vec<ConfigWarning>,
1539) {
1540 config.hashline_enabled = matches!(raw.edit_mode, Some(RawEditMode::Hashline));
1541 if let Some(value) = raw.hoist_builtin_tools {
1542 config.hoist_builtin_tools = value;
1543 }
1544 if let Some(value) = raw.tool_surface {
1545 config.tool_surface = value.as_str().to_string();
1546 }
1547 if let Some(value) = &raw.disabled_tools {
1548 config.disabled_tools = value.clone();
1549 }
1550 if let Some(value) = raw.format_on_edit {
1551 config.format_on_edit = value;
1552 }
1553 if let Some(value) = raw.formatter_timeout_secs {
1554 config.formatter_timeout_secs = value;
1555 }
1556 if let Some(value) = raw.type_checker_timeout_secs {
1557 config.type_checker_timeout_secs = value;
1558 }
1559 if let Some(value) = raw.validate_on_edit {
1560 config.validate_on_edit = Some(value.as_str().to_string());
1561 }
1562 if let Some(formatter) = &raw.formatter {
1563 config.formatter = formatter
1564 .iter()
1565 .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
1566 .collect();
1567 }
1568 if let Some(checker) = &raw.checker {
1569 config.checker = checker
1570 .iter()
1571 .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
1572 .collect();
1573 }
1574 if let Some(value) = raw.restrict_to_project_root {
1575 config.restrict_to_project_root = value;
1576 }
1577 if let Some(value) = raw.search_index {
1578 config.search_index = value;
1579 }
1580 if let Some(value) = raw.semantic_search {
1581 config.semantic_search = value;
1582 }
1583 if let Some(value) = raw.views.as_ref().and_then(|views| views.enabled) {
1584 config.views.enabled = value;
1585 }
1586 if let Some(value) = raw.callgraph_store {
1587 config.callgraph_store = value;
1588 }
1589 if let Some(value) = raw.callgraph_chunk_size {
1590 config.callgraph_chunk_size = value;
1591 }
1592 if let Some(value) = raw.url_fetch_allow_private {
1593 config.url_fetch_allow_private = value;
1594 }
1595 config.semantic = resolve_semantic_config(raw.semantic.as_ref(), raw.subc.as_ref());
1596 config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1597 config.backup = resolve_backup_config(raw.backup.as_ref());
1598 config.worktree = resolve_worktree_config(raw.worktree.as_ref());
1599 config.github = resolve_github_config(raw.github.as_ref(), warnings);
1600 config.gh_shim = resolve_gh_shim_config(raw.gh_shim.as_ref());
1601 config.gh_shim.enabled = config.github.shim;
1602 config.gh_read.enabled = config.github.read;
1603 config.git = resolve_git_config(raw.git.as_ref());
1604 config.sandbox = resolve_sandbox_config(raw.sandbox.as_ref());
1605 resolve_lsp_config(raw, config);
1606 resolve_bash_fields(raw, config, warnings);
1607}
1608
1609fn resolve_index_config(raw: Option<&RawIndex>, warnings: &mut Vec<ConfigWarning>) -> IndexConfig {
1610 let Some(raw) = raw else {
1611 return IndexConfig::default();
1612 };
1613 let Some(roots) = raw.roots.as_ref() else {
1614 return IndexConfig::default();
1615 };
1616
1617 let home = crate::environment::non_empty_os_var("HOME")
1618 .or_else(|| crate::environment::non_empty_os_var("USERPROFILE"))
1619 .map(PathBuf::from);
1620 let mut normalized_roots = Vec::with_capacity(roots.len());
1621
1622 for (position, root) in roots.iter().enumerate() {
1623 for field in root.unknown.keys() {
1624 warnings.push(ConfigWarning {
1625 code: "unknown_index_root_field",
1626 key: "index.roots",
1627 tier: "user".to_string(),
1628 value: field.clone(),
1629 message: format!(
1630 "Ignoring unknown field index.roots[{position}].{field}; only path and indexes are defined"
1631 ),
1632 });
1633 }
1634
1635 let result = (|| -> Result<IndexRootConfig, String> {
1636 let path = root.path.as_deref().ok_or_else(|| {
1637 "index.roots entry is missing required string field path".to_string()
1638 })?;
1639 expand_index_root_path(path, home.as_deref())?;
1640
1641 let indexes = root.indexes.as_ref().ok_or_else(|| {
1642 "index.roots entry is missing required non-empty indexes array".to_string()
1643 })?;
1644 if indexes.is_empty() {
1645 return Err("index.roots indexes must be a non-empty array".to_string());
1646 }
1647
1648 let mut normalized = Vec::with_capacity(indexes.len() + 1);
1649 for name in indexes {
1650 let kind = IndexKind::from_name(name).ok_or_else(|| {
1651 format!(
1652 "index.roots indexes contains unknown name {name:?}; valid names: search, semantic, callgraph"
1653 )
1654 })?;
1655 if normalized.contains(&kind) {
1656 return Err(format!(
1657 "index.roots indexes contains duplicate name {name:?}"
1658 ));
1659 }
1660 normalized.push(kind);
1661 }
1662 if normalized.contains(&IndexKind::Semantic) && !normalized.contains(&IndexKind::Search)
1663 {
1664 normalized.push(IndexKind::Search);
1665 warnings.push(ConfigWarning {
1666 code: "index_dependency_closure",
1667 key: "index.roots",
1668 tier: "user".to_string(),
1669 value: path.to_string(),
1670 message: format!(
1671 "Added search to index.roots[{position}].indexes because semantic depends on search"
1672 ),
1673 });
1674 }
1675 normalized.sort_unstable();
1676 Ok(IndexRootConfig {
1677 path: path.to_string(),
1678 indexes: normalized,
1679 })
1680 })();
1681
1682 match result {
1683 Ok(root) => normalized_roots.push(root),
1684 Err(message) => {
1685 warnings.push(ConfigWarning {
1686 code: "invalid_index_roots",
1687 key: "index.roots",
1688 tier: "user".to_string(),
1689 value: position.to_string(),
1690 message,
1691 });
1692 return IndexConfig::default();
1693 }
1694 }
1695 }
1696
1697 IndexConfig {
1698 roots: normalized_roots,
1699 }
1700}
1701
1702fn resolve_semantic_config(
1703 raw: Option<&RawSemantic>,
1704 subc: Option<&RawSubc>,
1705) -> SemanticBackendConfig {
1706 let mut semantic = SemanticBackendConfig::default();
1707 semantic.subc_connection_file = subc
1708 .and_then(|subc| subc.connection_file.as_deref())
1709 .map(str::trim)
1710 .filter(|path| !path.is_empty())
1711 .map(PathBuf::from);
1712 let Some(raw) = raw else {
1713 return semantic;
1714 };
1715
1716 if let Some(value) = raw.backend {
1717 semantic.backend = value;
1718 if value == SemanticBackend::Synapse && raw.model.is_none() {
1719 semantic.model.clear();
1722 }
1723 }
1724 if let Some(value) = &raw.model {
1725 semantic.model = value.clone();
1726 }
1727 if let Some(value) = &raw.base_url {
1728 semantic.base_url = Some(value.clone());
1729 }
1730 if let Some(value) = &raw.api_key_env {
1731 semantic.api_key_env = Some(value.clone());
1732 }
1733 if let Some(value) = raw.timeout_ms {
1734 semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1735 }
1736 if let Some(value) = &raw.query_instruction {
1737 semantic.query_instruction = value.clone();
1738 }
1739 if let Some(value) = raw.query_timeout_ms {
1740 semantic.query_timeout_ms =
1741 value.clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS);
1742 }
1743 if let Some(value) = raw.max_batch_size {
1744 semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1745 }
1746 if let Some(value) = raw.max_input_tokens {
1747 semantic.max_input_tokens = Some(value);
1748 }
1749 if let Some(value) = raw.max_files {
1750 semantic.max_files = value;
1751 }
1752
1753 semantic
1754}
1755
1756fn resolve_idle_config(raw: Option<&RawIdle>, warnings: &mut Vec<ConfigWarning>) -> IdleConfig {
1757 let mut idle = IdleConfig::default();
1758 let Some(raw) = raw else {
1759 return idle;
1760 };
1761 idle.root_ttl_minutes = resolve_clamped_minutes(
1762 raw.root_ttl_minutes.as_ref(),
1763 "idle.root_ttl_minutes",
1764 DEFAULT_IDLE_ROOT_TTL_MINUTES,
1765 MIN_IDLE_ROOT_TTL_MINUTES,
1766 MAX_IDLE_ROOT_TTL_MINUTES,
1767 warnings,
1768 );
1769 idle.lsp_ttl_minutes = resolve_clamped_minutes(
1770 raw.lsp_ttl_minutes.as_ref(),
1771 "idle.lsp_ttl_minutes",
1772 DEFAULT_IDLE_LSP_TTL_MINUTES,
1773 MIN_IDLE_LSP_TTL_MINUTES,
1774 MAX_IDLE_LSP_TTL_MINUTES,
1775 warnings,
1776 );
1777 idle
1778}
1779
1780fn resolve_clamped_minutes(
1781 raw: Option<&Value>,
1782 key: &'static str,
1783 default: u32,
1784 min: u32,
1785 max: u32,
1786 warnings: &mut Vec<ConfigWarning>,
1787) -> u32 {
1788 let Some(value) = raw else {
1789 return default;
1790 };
1791 let Some(parsed) = json_integer(value) else {
1792 warnings.push(ConfigWarning {
1793 code: "invalid_idle_ttl",
1794 key,
1795 tier: "config".to_string(),
1796 value: value.to_string(),
1797 message: format!("{key} must be an integer; using default {default}"),
1798 });
1799 return default;
1800 };
1801 let clamped = parsed.clamp(i64::from(min), i64::from(max)) as u32;
1802 if i64::from(clamped) != parsed {
1803 warnings.push(ConfigWarning {
1804 code: "clamped_idle_ttl",
1805 key,
1806 tier: "config".to_string(),
1807 value: parsed.to_string(),
1808 message: format!("{key}={parsed} is outside {min}..={max}; clamped to {clamped}"),
1809 });
1810 }
1811 clamped
1812}
1813
1814fn resolve_clamped_bash_watch_sync_max_ms(
1815 raw: Option<u64>,
1816 warnings: &mut Vec<ConfigWarning>,
1817) -> u64 {
1818 let Some(raw) = raw else {
1819 return DEFAULT_BASH_WATCH_SYNC_MAX_MS;
1820 };
1821 let clamped = raw.clamp(MIN_BASH_WATCH_SYNC_MAX_MS, MAX_BASH_WATCH_SYNC_MAX_MS);
1822 if clamped != raw {
1823 warnings.push(ConfigWarning {
1824 code: "clamped_bash_watch_sync_max_ms",
1825 key: "bash.watch_sync_max_ms",
1826 tier: "config".to_string(),
1827 value: raw.to_string(),
1828 message: format!(
1829 "bash.watch_sync_max_ms={raw} is outside {MIN_BASH_WATCH_SYNC_MAX_MS}..={MAX_BASH_WATCH_SYNC_MAX_MS}; clamped to {clamped}"
1830 ),
1831 });
1832 }
1833 clamped
1834}
1835
1836fn json_integer(value: &Value) -> Option<i64> {
1837 match value {
1838 Value::Number(number) if number.is_i64() => number.as_i64(),
1839 Value::Number(number) if number.is_u64() => {
1840 number.as_u64().and_then(|value| i64::try_from(value).ok())
1841 }
1842 _ => None,
1843 }
1844}
1845
1846fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1847 let mut inspect = InspectConfig::default();
1848 let Some(raw) = raw else {
1849 return inspect;
1850 };
1851 if let Some(enabled) = raw.enabled {
1852 inspect.enabled = enabled;
1853 }
1854 if let Some(value) = raw.diagnostics_timeout_ms {
1855 inspect.diagnostics_timeout_ms = value.clamp(
1856 MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1857 MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1858 );
1859 }
1860 if let Some(expected_mirrors) = raw
1861 .duplicates
1862 .as_ref()
1863 .and_then(|duplicates| duplicates.expected_mirrors.clone())
1864 {
1865 inspect.duplicates.expected_mirrors = expected_mirrors;
1866 }
1867 inspect
1868}
1869
1870fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1871 let mut backup = BackupConfig::default();
1872 if let Some(raw) = raw {
1873 if raw.enabled.is_some() {
1874 backup.enabled = raw.enabled;
1875 }
1876 if raw.max_depth.is_some() {
1877 backup.max_depth = raw.max_depth;
1878 }
1879 if raw.max_file_size.is_some() {
1880 backup.max_file_size = raw.max_file_size;
1881 }
1882 }
1883 backup
1884}
1885
1886fn resolve_worktree_config(raw: Option<&RawWorktree>) -> WorktreeConfig {
1887 let mut worktree = WorktreeConfig::default();
1888 if let Some(value) = raw.and_then(|raw| raw.ram_overlay) {
1889 worktree.ram_overlay = value;
1890 }
1891 worktree
1892}
1893
1894fn resolve_github_config(
1895 raw: Option<&RawGithub>,
1896 warnings: &mut Vec<ConfigWarning>,
1897) -> GithubConfig {
1898 let mut github = GithubConfig::default();
1899 if let Some(value) = raw.and_then(|raw| raw.enabled) {
1900 github.enabled = value;
1901 }
1902 if let Some(value) = raw.and_then(|raw| raw.shim) {
1903 github.shim = value;
1904 }
1905 if let Some(value) = raw.and_then(|raw| raw.read) {
1906 github.read = value;
1907 }
1908 if let Some(value) = raw.and_then(|raw| raw.write) {
1909 github.write = value;
1910 }
1911
1912 if !github.enabled {
1913 github.shim = false;
1914 github.read = false;
1915 github.write = false;
1916 } else if github.write && !github.read {
1917 github.read = true;
1918 warnings.push(ConfigWarning {
1919 code: "github_write_requires_read",
1920 key: "github.write",
1921 tier: "user".to_string(),
1922 value: "true".to_string(),
1923 message: "github.write=true requires github.read=true; treating github.read as enabled"
1924 .to_string(),
1925 });
1926 }
1927
1928 github
1929}
1930
1931fn resolve_gh_shim_config(raw: Option<&RawGhShim>) -> GhShimConfig {
1932 let mut gh_shim = GhShimConfig::default();
1933 if let Some(value) = raw.and_then(|raw| raw.enabled) {
1934 gh_shim.enabled = value;
1935 }
1936 gh_shim.binary_path = raw
1937 .and_then(|raw| raw.binary_path.as_ref())
1938 .map(PathBuf::from);
1939 gh_shim
1940}
1941
1942fn resolve_git_config(raw: Option<&RawGit>) -> GitConfig {
1943 GitConfig {
1944 co_author: raw
1945 .and_then(|raw| raw.co_author.clone())
1946 .unwrap_or_else(|| "off".to_string()),
1947 }
1948}
1949
1950fn resolve_sandbox_config(raw: Option<&RawSandbox>) -> SandboxConfig {
1951 let Some(raw) = raw else {
1952 return SandboxConfig::default();
1953 };
1954 SandboxConfig {
1955 enabled: raw.enabled.unwrap_or(false),
1956 write_allow: raw.write_allow.clone().unwrap_or_default(),
1957 read_deny: raw.read_deny.clone().unwrap_or_default(),
1958 }
1959}
1960
1961fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1962 let lsp = raw.lsp.as_ref();
1963 let mut disabled: HashSet<String> = lsp
1964 .and_then(|lsp| lsp.disabled.as_ref())
1965 .into_iter()
1966 .flatten()
1967 .map(|value| value.to_ascii_lowercase())
1968 .collect();
1969 let mut experimental_ty = raw
1970 .experimental
1971 .as_ref()
1972 .and_then(|experimental| experimental.lsp_ty);
1973
1974 match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1975 RawPythonLsp::Ty => {
1976 experimental_ty = Some(true);
1977 disabled.insert("python".to_string());
1978 }
1979 RawPythonLsp::Pyright => {
1980 experimental_ty = Some(false);
1981 disabled.insert("ty".to_string());
1982 }
1983 RawPythonLsp::Auto => {}
1984 }
1985
1986 if let Some(value) = experimental_ty {
1987 config.experimental_lsp_ty = value;
1988 }
1989
1990 if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1991 config.diagnostics_on_edit = value;
1992 }
1993
1994 if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1995 config.lsp_servers = servers
1996 .iter()
1997 .map(|(id, server)| UserServerDef {
1998 id: id.clone(),
1999 extensions: server
2000 .extensions
2001 .clone()
2002 .unwrap_or_default()
2003 .into_iter()
2004 .map(|extension| extension.trim_start_matches('.').to_string())
2005 .collect(),
2006 binary: server.binary.clone().unwrap_or_default(),
2007 args: server.args.clone().unwrap_or_default(),
2008 root_markers: server
2009 .root_markers
2010 .clone()
2011 .unwrap_or_else(|| vec![".git".to_string()]),
2012 env: server.env.clone().unwrap_or_default(),
2013 initialization_options: server.initialization_options.clone(),
2014 disabled: server.disabled.unwrap_or(false),
2015 })
2016 .collect();
2017 }
2018
2019 if !disabled.is_empty() {
2020 config.disabled_lsp = disabled;
2021 }
2022}
2023
2024#[derive(Debug, Clone, PartialEq, Eq)]
2025struct ResolvedBashConfig {
2026 enabled: bool,
2027 rewrite: bool,
2028 compress: bool,
2029 background: bool,
2030 host_fallback: bool,
2031 subagent_background: bool,
2032 detach_on_user_message: bool,
2033 long_running_reminder_enabled: Option<bool>,
2034 long_running_reminder_interval_ms: Option<u64>,
2035 foreground_wait_window_ms: u64,
2036 watch_sync_max_ms: u64,
2037 linux_scope: bool,
2038 powershell_tool: bool,
2039}
2040
2041fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config, warnings: &mut Vec<ConfigWarning>) {
2042 let bash = resolve_bash_config(raw, warnings);
2043 let _registration_only = (bash.enabled, bash.subagent_background);
2047 config.bash.host_fallback = bash.host_fallback;
2048 config.bash.detach_on_user_message = bash.detach_on_user_message;
2049 config.bash.watch_sync_max_ms = bash.watch_sync_max_ms;
2050 config.bash.linux_scope = bash.linux_scope;
2051 config.bash.powershell_tool = bash.powershell_tool;
2052 config.experimental_bash_rewrite = bash.rewrite;
2053 config.experimental_bash_compress = bash.compress;
2054 config.experimental_bash_background = bash.background;
2055 config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
2056 if let Some(value) = bash.long_running_reminder_enabled {
2057 config.bash_long_running_reminder_enabled = value;
2058 }
2059 if let Some(value) = bash.long_running_reminder_interval_ms {
2060 config.bash_long_running_reminder_interval_ms = value;
2061 }
2062}
2063
2064fn resolve_bash_config(
2065 raw: &RawAftConfig,
2066 warnings: &mut Vec<ConfigWarning>,
2067) -> ResolvedBashConfig {
2068 let top = raw.bash.as_ref();
2069 let legacy = raw
2070 .experimental
2071 .as_ref()
2072 .and_then(|experimental| experimental.bash.as_ref());
2073 let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
2074 let surface_default_enabled = surface != RawToolSurface::Minimal;
2075
2076 let top_features = match top {
2077 Some(RawBash::Features(features)) => Some(features),
2078 _ => None,
2079 };
2080 let reminder_enabled = top_features
2081 .and_then(|features| features.long_running_reminder_enabled)
2082 .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
2083 let reminder_interval = top_features
2084 .and_then(|features| features.long_running_reminder_interval_ms)
2085 .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
2086 let top_host_fallback = top_features
2087 .and_then(|features| features.host_fallback)
2088 .unwrap_or(false);
2089 let top_subagent_background = top_features
2090 .and_then(|features| features.subagent_background)
2091 .unwrap_or(true);
2092 let top_detach_on_user_message = top_features
2093 .and_then(|features| features.detach_on_user_message)
2094 .unwrap_or(true);
2095 let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
2096 let raw_watch_sync_max = top_features.and_then(|features| features.watch_sync_max_ms);
2097 let watch_sync_max_ms = resolve_clamped_bash_watch_sync_max_ms(raw_watch_sync_max, warnings);
2098 let top_linux_scope = top_features
2099 .and_then(|features| features.linux_scope)
2100 .unwrap_or(false);
2101 let top_powershell_tool = top_features
2102 .and_then(|features| features.powershell_tool)
2103 .unwrap_or(false);
2104 let foreground_wait_window_ms = raw_foreground_wait
2105 .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
2106 .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
2107
2108 let base = ResolvedBashConfig {
2109 enabled: false,
2110 rewrite: false,
2111 compress: false,
2112 background: false,
2113 host_fallback: false,
2114 subagent_background: true,
2115 detach_on_user_message: true,
2116 long_running_reminder_enabled: reminder_enabled,
2117 long_running_reminder_interval_ms: reminder_interval,
2118 foreground_wait_window_ms,
2119 watch_sync_max_ms,
2120 linux_scope: top_linux_scope,
2121 powershell_tool: false,
2122 };
2123
2124 match top {
2125 Some(RawBash::Bool(false)) => base,
2126 Some(RawBash::Bool(true)) => ResolvedBashConfig {
2127 enabled: true,
2128 rewrite: true,
2129 compress: true,
2130 background: true,
2131 ..base
2132 },
2133 Some(RawBash::Features(features)) => ResolvedBashConfig {
2134 enabled: true,
2135 rewrite: features.rewrite.unwrap_or(true),
2136 compress: features.compress.unwrap_or(true),
2137 background: features.background.unwrap_or(true),
2138 host_fallback: top_host_fallback,
2139 subagent_background: top_subagent_background,
2140 detach_on_user_message: top_detach_on_user_message,
2141 powershell_tool: top_powershell_tool,
2142 ..base
2143 },
2144 None => {
2145 if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
2146 let legacy = legacy.cloned().unwrap_or_default();
2147 let rewrite = legacy.rewrite == Some(true);
2148 let compress = legacy.compress == Some(true);
2149 let background = legacy.background == Some(true);
2150 return ResolvedBashConfig {
2151 enabled: rewrite || compress || background,
2152 rewrite,
2153 compress,
2154 background,
2155 ..base
2156 };
2157 }
2158
2159 ResolvedBashConfig {
2160 enabled: surface_default_enabled,
2161 rewrite: surface_default_enabled,
2162 compress: surface_default_enabled,
2163 background: surface_default_enabled,
2164 ..base
2165 }
2166 }
2167 }
2168}
2169
2170fn deserialize_opt_git_co_author<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
2171where
2172 D: Deserializer<'de>,
2173{
2174 let value = Option::<String>::deserialize(deserializer)?;
2175 value
2176 .map(|value| {
2177 normalize_git_co_author(&value).ok_or_else(|| {
2178 de::Error::custom(
2179 "git.co_author must be 'off', 'auto', or an explicit 'Name <email>' identity",
2180 )
2181 })
2182 })
2183 .transpose()
2184}
2185
2186fn deserialize_opt_trimmed_non_empty_string<'de, D>(
2187 deserializer: D,
2188) -> Result<Option<String>, D::Error>
2189where
2190 D: Deserializer<'de>,
2191{
2192 let value = Option::<String>::deserialize(deserializer)?;
2193 value
2194 .map(|value| {
2195 let trimmed = value.trim().to_string();
2196 if trimmed.is_empty() {
2197 Err(de::Error::custom("must be a non-empty string"))
2198 } else {
2199 Ok(trimmed)
2200 }
2201 })
2202 .transpose()
2203}
2204
2205fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
2206 deserializer: D,
2207) -> Result<Option<Vec<String>>, D::Error>
2208where
2209 D: Deserializer<'de>,
2210{
2211 let value = Option::<Vec<String>>::deserialize(deserializer)?;
2212 value
2213 .map(|values| {
2214 values
2215 .into_iter()
2216 .map(|value| {
2217 let trimmed = value.trim().to_string();
2218 if trimmed.is_empty() {
2219 Err(de::Error::custom("array entries must be non-empty strings"))
2220 } else {
2221 Ok(trimmed)
2222 }
2223 })
2224 .collect()
2225 })
2226 .transpose()
2227}
2228
2229fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
2230where
2231 D: Deserializer<'de>,
2232{
2233 let value = Option::<Vec<String>>::deserialize(deserializer)?;
2234 value
2235 .map(|values| {
2236 if values.is_empty() {
2237 return Err(de::Error::custom(
2238 "extensions must contain at least one entry",
2239 ));
2240 }
2241 values
2242 .into_iter()
2243 .map(|value| {
2244 let trimmed = value.trim().to_string();
2245 if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
2246 Err(de::Error::custom(
2247 "extension must include characters other than leading dots",
2248 ))
2249 } else {
2250 Ok(trimmed)
2251 }
2252 })
2253 .collect()
2254 })
2255 .transpose()
2256}
2257
2258fn deserialize_opt_lsp_servers<'de, D>(
2259 deserializer: D,
2260) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
2261where
2262 D: Deserializer<'de>,
2263{
2264 let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
2265 value
2266 .map(|entries| {
2267 entries
2268 .into_iter()
2269 .map(|(key, value)| {
2270 let trimmed = key.trim().to_string();
2271 if trimmed.is_empty() {
2272 Err(de::Error::custom(
2273 "lsp.servers keys must be non-empty strings",
2274 ))
2275 } else {
2276 Ok((trimmed, value))
2277 }
2278 })
2279 .collect()
2280 })
2281 .transpose()
2282}
2283
2284fn deserialize_opt_versions_map<'de, D>(
2285 deserializer: D,
2286) -> Result<Option<HashMap<String, String>>, D::Error>
2287where
2288 D: Deserializer<'de>,
2289{
2290 let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
2291 value
2292 .map(|entries| {
2293 entries
2294 .into_iter()
2295 .map(|(key, value)| {
2296 let trimmed_key = key.trim().to_string();
2297 let trimmed_value = value.trim().to_string();
2298 if trimmed_key.is_empty() || trimmed_value.is_empty() {
2299 Err(de::Error::custom(
2300 "lsp.versions keys and values must be non-empty strings",
2301 ))
2302 } else {
2303 Ok((trimmed_key, trimmed_value))
2304 }
2305 })
2306 .collect()
2307 })
2308 .transpose()
2309}
2310
2311fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
2312where
2313 D: Deserializer<'de>,
2314{
2315 let value = Option::<u64>::deserialize(deserializer)?;
2316 value
2317 .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
2318 .transpose()
2319}
2320
2321fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
2322where
2323 D: Deserializer<'de>,
2324{
2325 let value = Option::<u64>::deserialize(deserializer)?;
2326 match value {
2327 Some(0) => Err(de::Error::custom("must be a positive integer")),
2328 other => Ok(other),
2329 }
2330}
2331
2332fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
2333where
2334 D: Deserializer<'de>,
2335{
2336 let value = deserialize_opt_positive_u64(deserializer)?;
2337 value
2338 .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
2339 .transpose()
2340}
2341
2342fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
2343where
2344 D: Deserializer<'de>,
2345{
2346 let value = Option::<u64>::deserialize(deserializer)?;
2347 match value {
2348 Some(value) if !(1..=600).contains(&value) => {
2349 Err(de::Error::custom("timeout must be in 1..=600 seconds"))
2350 }
2351 Some(value) => u32::try_from(value)
2352 .map(Some)
2353 .map_err(|_| de::Error::custom("timeout is too large")),
2354 None => Ok(None),
2355 }
2356}
2357
2358fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
2359 deserializer: D,
2360) -> Result<Option<u64>, D::Error>
2361where
2362 D: Deserializer<'de>,
2363{
2364 let value = Option::<u64>::deserialize(deserializer)?;
2365 match value {
2366 Some(value) if value < 1_000 => Err(de::Error::custom(
2367 "bridge.request_timeout_ms must be at least 1000",
2368 )),
2369 other => Ok(other),
2370 }
2371}
2372
2373fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
2374where
2375 D: Deserializer<'de>,
2376{
2377 let value = Option::<f64>::deserialize(deserializer)?;
2378 match value {
2379 Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
2380 other => Ok(other),
2381 }
2382}
2383
2384fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
2385where
2386 D: Deserializer<'de>,
2387{
2388 let value = Option::<u64>::deserialize(deserializer)?;
2389 match value {
2390 Some(value) if value == 0 || value > 100 => {
2391 Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
2392 }
2393 Some(value) => usize::try_from(value)
2394 .map(Some)
2395 .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
2396 None => Ok(None),
2397 }
2398}
2399
2400#[cfg(test)]
2401mod tests {
2402 use super::*;
2403
2404 fn tier(tier: &str, doc: &str) -> ConfigTier {
2405 ConfigTier {
2406 tier: tier.to_string(),
2407 source: format!("/tmp/{tier}/aft.jsonc"),
2408 doc: doc.to_string(),
2409 }
2410 }
2411
2412 fn drop_keys(result: &ResolveResult) -> Vec<String> {
2413 result
2414 .dropped
2415 .iter()
2416 .map(|dropped| dropped.key.clone())
2417 .collect()
2418 }
2419
2420 #[test]
2427 fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
2428 let nested = resolve_config(&[tier(
2434 "user",
2435 r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
2436 )]);
2437 assert!(nested.config.experimental_bash_rewrite);
2438 assert!(nested.config.experimental_bash_compress);
2439 assert!(nested.config.experimental_bash_background);
2440
2441 let smuggle = resolve_config(&[
2445 tier("user", r#"{ "search_index": true }"#),
2446 tier(
2447 "project",
2448 r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
2449 ),
2450 ]);
2451 assert!(!smuggle.config.search_index);
2453 assert!(smuggle.config.storage_dir.is_none());
2455 assert!(!smuggle.config.bash_permissions);
2456 }
2457
2458 #[test]
2459 fn config_resolve_empty_tiers_applies_bash_surface_default() {
2460 let result = resolve_config(&[]);
2465 let default_config = Config::default();
2466
2467 assert!(result.dropped.is_empty());
2468 assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
2470 assert_eq!(result.config.search_index, default_config.search_index);
2471 assert_eq!(
2472 result.config.semantic_search,
2473 default_config.semantic_search
2474 );
2475 assert_eq!(result.config.semantic, default_config.semantic);
2476 assert_eq!(
2477 result.config.inspect.enabled,
2478 default_config.inspect.enabled
2479 );
2480 assert_eq!(result.config.lsp_servers.len(), 0);
2481 assert!(result.config.experimental_bash_rewrite);
2483 assert!(result.config.experimental_bash_compress);
2484 assert!(result.config.experimental_bash_background);
2485 }
2486
2487 #[test]
2488 fn bash_watch_sync_max_defaults_clamps_and_warns() {
2489 let default_result = resolve_config(&[]);
2490 assert_eq!(
2491 default_result.config.bash.watch_sync_max_ms,
2492 DEFAULT_BASH_WATCH_SYNC_MAX_MS
2493 );
2494 assert!(default_result
2495 .warnings
2496 .iter()
2497 .all(|warning| warning.key != "bash.watch_sync_max_ms"));
2498
2499 let clamped = resolve_config(&[tier("user", r#"{ "bash": { "watch_sync_max_ms": 5 } }"#)]);
2500 assert_eq!(
2501 clamped.config.bash.watch_sync_max_ms,
2502 MIN_BASH_WATCH_SYNC_MAX_MS
2503 );
2504 assert!(clamped.warnings.iter().any(|warning| {
2505 warning.code == "clamped_bash_watch_sync_max_ms"
2506 && warning.key == "bash.watch_sync_max_ms"
2507 && warning.value == "5"
2508 }));
2509
2510 let project_override = resolve_config(&[
2511 tier("user", r#"{ "bash": { "watch_sync_max_ms": 120000 } }"#),
2512 tier("project", r#"{ "bash": { "watch_sync_max_ms": 1800000 } }"#),
2513 ]);
2514 assert_eq!(
2515 project_override.config.bash.watch_sync_max_ms,
2516 MAX_BASH_WATCH_SYNC_MAX_MS
2517 );
2518 }
2519
2520 #[test]
2521 fn config_resolve_user_only_config_applies_fields() {
2522 let result = resolve_config(&[tier(
2523 "user",
2524 r#"{
2525 "$schema": "https://example.test/aft.schema.json",
2526 "format_on_edit": false,
2527 "formatter_timeout_secs": 42,
2528 "type_checker_timeout_secs": 43,
2529 "validate_on_edit": "full",
2530 "formatter": { "rust": "rustfmt", "typescript": "prettier" },
2531 "checker": { "rust": "cargo", "typescript": "tsc" },
2532 "restrict_to_project_root": true,
2533 "search_index": true,
2534 "semantic_search": true,
2535 "callgraph_store": false,
2536 "callgraph_chunk_size": 17,
2537 "url_fetch_allow_private": true,
2538 "semantic": {
2539 "backend": "openai_compatible",
2540 "model": " user-model ",
2541 "base_url": "https://semantic.example.test",
2542 "api_key_env": "AFT_API_KEY",
2543 "timeout_ms": 12345,
2544 "query_timeout_ms": 2345,
2545 "max_batch_size": 12,
2546 "max_input_tokens": 2048,
2547 "max_files": 3456
2548 },
2549 "inspect": { "enabled": false, "diagnostics_timeout_ms": 15000 },
2550 "experimental": { "lsp_ty": true },
2551 "lsp": {
2552 "servers": {
2553 "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
2554 },
2555 "disabled": ["Python"],
2556 "python": "pyright"
2557 },
2558 "bash": { "rewrite": false, "compress": true, "background": false,
2559 "long_running_reminder_enabled": false,
2560 "long_running_reminder_interval_ms": 123000 }
2561 }"#,
2562 )]);
2563
2564 assert!(result.dropped.is_empty());
2565 assert!(!result.config.format_on_edit);
2566 assert_eq!(result.config.formatter_timeout_secs, 42);
2567 assert_eq!(result.config.type_checker_timeout_secs, 43);
2568 assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
2569 assert_eq!(
2570 result.config.formatter.get("rust").map(String::as_str),
2571 Some("rustfmt")
2572 );
2573 assert_eq!(
2574 result.config.checker.get("typescript").map(String::as_str),
2575 Some("tsc")
2576 );
2577 assert!(result.config.restrict_to_project_root);
2578 assert!(result.config.search_index);
2579 assert!(result.config.semantic_search);
2580 assert!(!result.config.callgraph_store);
2581 assert_eq!(result.config.callgraph_chunk_size, 17);
2582 assert!(result.config.url_fetch_allow_private);
2583 assert_eq!(
2584 result.config.semantic.backend,
2585 SemanticBackend::OpenAiCompatible
2586 );
2587 assert_eq!(result.config.semantic.model, "user-model");
2588 assert_eq!(
2589 result.config.semantic.base_url.as_deref(),
2590 Some("https://semantic.example.test")
2591 );
2592 assert_eq!(
2593 result.config.semantic.api_key_env.as_deref(),
2594 Some("AFT_API_KEY")
2595 );
2596 assert_eq!(result.config.semantic.timeout_ms, 12345);
2597 assert_eq!(result.config.semantic.query_timeout_ms, 2345);
2598 assert_eq!(result.config.semantic.max_batch_size, 12);
2599 assert_eq!(result.config.semantic.max_input_tokens, Some(2048));
2600 assert_eq!(result.config.semantic.max_files, 3456);
2601 assert!(!result.config.inspect.enabled);
2602 assert_eq!(result.config.inspect.diagnostics_timeout_ms, 15_000);
2603 assert!(!result.config.experimental_lsp_ty);
2604 assert!(result.config.disabled_lsp.contains("ty"));
2605 assert_eq!(result.config.lsp_servers.len(), 1);
2606 assert_eq!(result.config.lsp_servers[0].id, "rust");
2607 assert_eq!(
2608 result.config.lsp_servers[0].extensions,
2609 vec!["rs".to_string()]
2610 );
2611 assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
2612 assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
2613 assert_eq!(
2614 result.config.lsp_servers[0].root_markers,
2615 vec![".git".to_string()]
2616 );
2617 assert!(!result.config.experimental_bash_rewrite);
2618 assert!(result.config.experimental_bash_compress);
2619 assert!(!result.config.experimental_bash_background);
2620 assert!(!result.config.bash_long_running_reminder_enabled);
2621 assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
2622 }
2623
2624 #[test]
2625 fn edit_mode_resolves_at_user_and_project_tiers_with_project_precedence() {
2626 let hashline = resolve_config(&[
2627 tier("user", r#"{"edit_mode":"default"}"#),
2628 tier("project", r#"{"edit_mode":"hashline"}"#),
2629 ]);
2630 assert!(hashline.config.hashline_enabled);
2631 assert!(hashline.dropped.is_empty());
2632
2633 let default = resolve_config(&[
2634 tier("user", r#"{"edit_mode":"hashline"}"#),
2635 tier("project", r#"{"edit_mode":"default"}"#),
2636 ]);
2637 assert!(!default.config.hashline_enabled);
2638 assert!(default.dropped.is_empty());
2639 }
2640
2641 #[test]
2642 fn harness_overrides_select_the_active_harness_and_preserve_tier_order() {
2643 let tiers = [
2644 tier(
2645 "user",
2646 r#"{
2647 "hoist_builtin_tools": false,
2648 "harnesses": {
2649 "opencode": { "hoist_builtin_tools": true },
2650 "pi": { "hoist_builtin_tools": false }
2651 }
2652 }"#,
2653 ),
2654 tier(
2655 "project",
2656 r#"{
2657 "hoist_builtin_tools": false,
2658 "harnesses": { "opencode": { "hoist_builtin_tools": true } }
2659 }"#,
2660 ),
2661 ];
2662
2663 let opencode = resolve_config_for_harness(&tiers, Some(&Harness::Opencode));
2664 let pi = resolve_config_for_harness(&tiers, Some(&Harness::Pi));
2665
2666 assert!(opencode.config.hoist_builtin_tools);
2667 assert!(!pi.config.hoist_builtin_tools);
2668 }
2669
2670 #[test]
2671 fn project_harness_overrides_are_filtered_at_the_existing_trust_boundary() {
2672 let result = resolve_config_for_harness(
2673 &[
2674 tier(
2675 "user",
2676 r#"{
2677 "restrict_to_project_root": true,
2678 "semantic": {
2679 "backend": "ollama",
2680 "base_url": "http://localhost:11434",
2681 "api_key_env": "USER_KEY"
2682 },
2683 "sandbox": { "enabled": true, "write_allow": ["/user/write"] }
2684 }"#,
2685 ),
2686 tier(
2687 "project",
2688 r#"{
2689 "harnesses": {
2690 "opencode": {
2691 "edit_mode": "hashline",
2692 "restrict_to_project_root": false,
2693 "semantic": {
2694 "backend": "openai_compatible",
2695 "base_url": "https://evil.example.test",
2696 "api_key_env": "EVIL_KEY"
2697 },
2698 "subc": { "connection_file": "/tmp/evil-subc.json" },
2699 "sandbox": { "enabled": false, "write_allow": ["/project/write"] }
2700 }
2701 }
2702 }"#,
2703 ),
2704 ],
2705 Some(&Harness::Opencode),
2706 );
2707
2708 assert!(result.config.hashline_enabled);
2709 assert!(result.config.restrict_to_project_root);
2710 assert_eq!(result.config.semantic.backend, SemanticBackend::Ollama);
2711 assert_eq!(
2712 result.config.semantic.api_key_env.as_deref(),
2713 Some("USER_KEY")
2714 );
2715 assert!(result.config.sandbox.enabled);
2716 assert_eq!(
2717 result.config.sandbox.write_allow,
2718 vec![PathBuf::from("/user/write")]
2719 );
2720 let keys = drop_keys(&result);
2721 for key in [
2722 "restrict_to_project_root",
2723 "semantic.backend",
2724 "semantic.base_url",
2725 "semantic.api_key_env",
2726 "subc",
2727 "sandbox.enabled",
2728 "sandbox.write_allow",
2729 ] {
2730 assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
2731 }
2732 }
2733
2734 #[test]
2735 fn github_is_user_only_and_records_project_drops() {
2736 let remains_disabled = resolve_config(&[
2737 tier(
2738 "user",
2739 r#"{"github":{"enabled":true,"shim":false,"read":false,"write":false}}"#,
2740 ),
2741 tier(
2742 "project",
2743 r#"{"github":{"enabled":true,"shim":true,"read":true,"write":true}}"#,
2744 ),
2745 ]);
2746 assert!(remains_disabled.config.github.enabled);
2747 assert!(!remains_disabled.config.github.shim);
2748 assert!(!remains_disabled.config.github.read);
2749 assert!(!remains_disabled.config.github.write);
2750 assert!(!remains_disabled.config.gh_shim.enabled);
2751 assert!(!remains_disabled.config.gh_read.enabled);
2752 assert_eq!(drop_keys(&remains_disabled), vec!["github"]);
2753 assert_eq!(remains_disabled.dropped[0].tier, "project");
2754 assert_eq!(remains_disabled.dropped[0].reason, USER_ONLY_REASON);
2755 }
2756
2757 #[test]
2758 fn github_master_off_overrides_every_subfeature() {
2759 let result = resolve_config(&[tier(
2760 "user",
2761 r#"{"github":{"enabled":false,"shim":true,"read":true,"write":true}}"#,
2762 )]);
2763
2764 assert_eq!(
2765 result.config.github,
2766 GithubConfig {
2767 enabled: false,
2768 shim: false,
2769 read: false,
2770 write: false,
2771 }
2772 );
2773 assert!(!result.config.gh_shim.enabled);
2774 assert!(!result.config.gh_read.enabled);
2775 assert!(result.warnings.is_empty());
2776 }
2777
2778 #[test]
2779 fn github_write_forces_read_and_records_a_warning_naming_both_keys() {
2780 let result = resolve_config(&[tier("user", r#"{"github":{"write":true,"read":false}}"#)]);
2781
2782 assert!(result.config.github.write);
2783 assert!(result.config.github.read);
2784 let warning = result
2785 .warnings
2786 .iter()
2787 .find(|warning| warning.code == "github_write_requires_read")
2788 .expect("write-implies-read warning");
2789 assert_eq!(warning.key, "github.write");
2790 assert!(warning.message.contains("github.write"));
2791 assert!(warning.message.contains("github.read"));
2792 }
2793
2794 #[test]
2795 fn github_legacy_aliases_apply_warn_and_lose_to_new_keys() {
2796 let aliases = resolve_config(&[tier(
2797 "user",
2798 r#"{"gh_shim":{"enabled":false},"gh_read":{"enabled":true}}"#,
2799 )]);
2800 assert!(!aliases.config.github.shim);
2801 assert!(aliases.config.github.read);
2802 assert_eq!(
2803 aliases
2804 .warnings
2805 .iter()
2806 .filter(|warning| warning.code == "deprecated_config_key")
2807 .count(),
2808 2
2809 );
2810 assert!(aliases.warnings.iter().any(|warning| {
2811 warning.key == "gh_shim.enabled" && warning.message.contains("github.shim")
2812 }));
2813 assert!(aliases.warnings.iter().any(|warning| {
2814 warning.key == "gh_read.enabled" && warning.message.contains("github.read")
2815 }));
2816
2817 let new_keys_win = resolve_config(&[tier(
2818 "user",
2819 r#"{
2820 "github":{"shim":true,"read":false},
2821 "gh_shim":{"enabled":false},
2822 "gh_read":{"enabled":true}
2823 }"#,
2824 )]);
2825 assert!(new_keys_win.config.github.shim);
2826 assert!(!new_keys_win.config.github.read);
2827 }
2828
2829 #[test]
2830 fn github_legacy_alias_at_project_tier_is_warned_and_ignored() {
2831 let result = resolve_config(&[
2832 tier("user", r#"{"github":{"read":false}}"#),
2833 tier("project", r#"{"gh_read":{"enabled":true}}"#),
2834 ]);
2835
2836 assert!(!result.config.github.read);
2837 assert_eq!(drop_keys(&result), vec!["github"]);
2838 assert!(result.warnings.iter().any(|warning| {
2839 warning.key == "gh_read.enabled"
2840 && warning.tier == "project"
2841 && warning.message.contains("github.read")
2842 }));
2843 }
2844
2845 #[test]
2846 fn git_co_author_accepts_project_precedence_and_rejects_invalid_identities() {
2847 let resolved = resolve_config(&[
2848 tier("user", r#"{"git":{"co_author":"auto"}}"#),
2849 tier(
2850 "project",
2851 r#"{"git":{"co_author":"Pair Agent <pair@example.test>"}}"#,
2852 ),
2853 ]);
2854 assert_eq!(
2855 resolved.config.git.co_author,
2856 "Pair Agent <pair@example.test>"
2857 );
2858 assert!(resolved.dropped.is_empty());
2859
2860 let invalid = resolve_config(&[tier(
2861 "user",
2862 r#"{"git":{"co_author":"not-an-identity"},"search_index":true}"#,
2863 )]);
2864 assert_eq!(invalid.config.git.co_author, "off");
2865 assert!(invalid.config.search_index);
2866 }
2867
2868 #[test]
2869 fn unknown_edit_mode_warns_and_falls_back_without_dropping_valid_keys() {
2870 let result = resolve_config(&[tier(
2871 "project",
2872 r#"{"edit_mode":"future","format_on_edit":true}"#,
2873 )]);
2874
2875 assert!(!result.config.hashline_enabled);
2876 assert!(result.config.format_on_edit);
2877 assert_eq!(result.warnings.len(), 1);
2878 assert_eq!(result.warnings[0].code, "invalid_edit_mode");
2879 assert_eq!(result.warnings[0].key, "edit_mode");
2880 assert_eq!(result.warnings[0].tier, "project");
2881 assert_eq!(result.warnings[0].value, "future");
2882 }
2883
2884 #[test]
2885 fn synapse_requires_explicit_model_and_receives_user_subc_connection_file() {
2886 let without_model = resolve_config(&[tier(
2887 "user",
2888 r#"{
2889 "semantic": { "backend": "synapse" },
2890 "subc": { "connection_file": "/tmp/subc-connection.json" }
2891 }"#,
2892 )]);
2893 assert_eq!(
2894 without_model.config.semantic.backend,
2895 SemanticBackend::Synapse
2896 );
2897 assert!(without_model.config.semantic.model.is_empty());
2898 assert_eq!(
2899 without_model.config.semantic.subc_connection_file,
2900 Some(PathBuf::from("/tmp/subc-connection.json"))
2901 );
2902
2903 let with_model = resolve_config(&[tier(
2904 "user",
2905 r#"{
2906 "semantic": { "backend": "synapse", "model": "configured-model" },
2907 "subc": { "connection_file": "/tmp/subc-connection.json" }
2908 }"#,
2909 )]);
2910 assert_eq!(with_model.config.semantic.model, "configured-model");
2911 }
2912
2913 #[test]
2914 fn semantic_query_instruction_resolves_at_user_and_project_tiers() {
2915 let result = resolve_config(&[
2916 tier(
2917 "user",
2918 r#"{"semantic":{"query_instruction":"user retrieval task"}}"#,
2919 ),
2920 tier(
2921 "project",
2922 r#"{"semantic":{"query_instruction":"project retrieval task"}}"#,
2923 ),
2924 ]);
2925
2926 assert_eq!(
2927 result.config.semantic.query_instruction,
2928 "project retrieval task"
2929 );
2930 assert_eq!(
2931 resolve_config(&[]).config.semantic.query_instruction,
2932 crate::config::DEFAULT_SEMANTIC_QUERY_INSTRUCTION
2933 );
2934 }
2935
2936 #[test]
2937 fn semantic_query_timeout_clamps_to_interactive_budget_range() {
2938 let below_min =
2939 resolve_config(&[tier("user", r#"{ "semantic": { "query_timeout_ms": 1 } }"#)]);
2940 assert_eq!(
2941 below_min.config.semantic.query_timeout_ms,
2942 MIN_SEMANTIC_QUERY_TIMEOUT_MS
2943 );
2944
2945 let above_max = resolve_config(&[tier(
2946 "user",
2947 r#"{ "semantic": { "query_timeout_ms": 50000 } }"#,
2948 )]);
2949 assert_eq!(
2950 above_max.config.semantic.query_timeout_ms,
2951 MAX_SEMANTIC_QUERY_TIMEOUT_MS
2952 );
2953 }
2954
2955 #[test]
2956 fn inspect_diagnostics_timeout_clamps_to_blocking_phase_range() {
2957 let below_min = resolve_config(&[tier(
2958 "user",
2959 r#"{ "inspect": { "diagnostics_timeout_ms": 1 } }"#,
2960 )]);
2961 assert_eq!(
2962 below_min.config.inspect.diagnostics_timeout_ms,
2963 MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS
2964 );
2965
2966 let above_max = resolve_config(&[tier(
2967 "user",
2968 r#"{ "inspect": { "diagnostics_timeout_ms": 700000 } }"#,
2969 )]);
2970 assert_eq!(
2971 above_max.config.inspect.diagnostics_timeout_ms,
2972 MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS
2973 );
2974 }
2975
2976 #[test]
2977 fn idle_root_ttl_clamps_to_five_through_thirty() {
2978 let below = resolve_config(&[tier("user", r#"{ "idle": { "root_ttl_minutes": 1 } }"#)]);
2979 assert_eq!(
2980 below.config.idle.root_ttl_minutes,
2981 MIN_IDLE_ROOT_TTL_MINUTES
2982 );
2983 assert!(below
2984 .warnings
2985 .iter()
2986 .any(|warning| warning.code == "clamped_idle_ttl"
2987 && warning.key == "idle.root_ttl_minutes"));
2988
2989 let above = resolve_config(&[tier("user", r#"{ "idle": { "root_ttl_minutes": 60 } }"#)]);
2990 assert_eq!(
2991 above.config.idle.root_ttl_minutes,
2992 MAX_IDLE_ROOT_TTL_MINUTES
2993 );
2994 assert!(above
2995 .warnings
2996 .iter()
2997 .any(|warning| warning.code == "clamped_idle_ttl"
2998 && warning.key == "idle.root_ttl_minutes"));
2999
3000 let at_min = resolve_config(&[tier("user", r#"{ "idle": { "root_ttl_minutes": 5 } }"#)]);
3001 assert_eq!(at_min.config.idle.root_ttl_minutes, 5);
3002 assert!(at_min.warnings.is_empty());
3003
3004 let at_max = resolve_config(&[tier("user", r#"{ "idle": { "root_ttl_minutes": 30 } }"#)]);
3005 assert_eq!(at_max.config.idle.root_ttl_minutes, 30);
3006 assert!(at_max.warnings.is_empty());
3007 }
3008
3009 #[test]
3010 fn idle_lsp_ttl_clamps_to_one_through_ten() {
3011 let below = resolve_config(&[tier("user", r#"{ "idle": { "lsp_ttl_minutes": 0 } }"#)]);
3012 assert_eq!(below.config.idle.lsp_ttl_minutes, MIN_IDLE_LSP_TTL_MINUTES);
3013 assert!(below
3014 .warnings
3015 .iter()
3016 .any(|warning| warning.code == "clamped_idle_ttl"
3017 && warning.key == "idle.lsp_ttl_minutes"));
3018
3019 let above = resolve_config(&[tier("user", r#"{ "idle": { "lsp_ttl_minutes": 20 } }"#)]);
3020 assert_eq!(above.config.idle.lsp_ttl_minutes, MAX_IDLE_LSP_TTL_MINUTES);
3021 assert!(above
3022 .warnings
3023 .iter()
3024 .any(|warning| warning.code == "clamped_idle_ttl"
3025 && warning.key == "idle.lsp_ttl_minutes"));
3026
3027 let at_min = resolve_config(&[tier("user", r#"{ "idle": { "lsp_ttl_minutes": 1 } }"#)]);
3028 assert_eq!(at_min.config.idle.lsp_ttl_minutes, 1);
3029 assert!(at_min.warnings.is_empty());
3030
3031 let at_max = resolve_config(&[tier("user", r#"{ "idle": { "lsp_ttl_minutes": 10 } }"#)]);
3032 assert_eq!(at_max.config.idle.lsp_ttl_minutes, 10);
3033 assert!(at_max.warnings.is_empty());
3034 }
3035
3036 #[test]
3037 fn idle_non_integer_ttl_is_dropped_with_warning() {
3038 let result = resolve_config(&[tier("user", r#"{ "idle": { "root_ttl_minutes": 12.5 } }"#)]);
3039 assert_eq!(
3040 result.config.idle.root_ttl_minutes,
3041 DEFAULT_IDLE_ROOT_TTL_MINUTES
3042 );
3043 assert!(result
3044 .warnings
3045 .iter()
3046 .any(|warning| warning.code == "invalid_idle_ttl"
3047 && warning.key == "idle.root_ttl_minutes"));
3048 }
3049
3050 #[test]
3051 fn idle_project_tier_overrides_user_ttl() {
3052 let result = resolve_config(&[
3053 tier("user", r#"{ "idle": { "lsp_ttl_minutes": 8 } }"#),
3054 tier("project", r#"{ "idle": { "lsp_ttl_minutes": 3 } }"#),
3055 ]);
3056 assert_eq!(result.config.idle.lsp_ttl_minutes, 3);
3057 assert_eq!(
3058 result.config.idle.root_ttl_minutes,
3059 DEFAULT_IDLE_ROOT_TTL_MINUTES
3060 );
3061 }
3062
3063 #[test]
3064 fn project_inspect_diagnostics_timeout_can_raise_but_never_lower_user_value() {
3065 let lower = resolve_config(&[
3066 tier(
3067 "user",
3068 r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
3069 ),
3070 tier(
3071 "project",
3072 r#"{ "inspect": { "diagnostics_timeout_ms": 90000 } }"#,
3073 ),
3074 ]);
3075 assert_eq!(lower.config.inspect.diagnostics_timeout_ms, 180_000);
3076
3077 let higher = resolve_config(&[
3078 tier(
3079 "user",
3080 r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
3081 ),
3082 tier(
3083 "project",
3084 r#"{ "inspect": { "diagnostics_timeout_ms": 240000 } }"#,
3085 ),
3086 ]);
3087 assert_eq!(higher.config.inspect.diagnostics_timeout_ms, 240_000);
3088 }
3089
3090 #[test]
3091 fn config_resolve_project_allowed_search_index_wins() {
3092 let result = resolve_config(&[
3093 tier("user", r#"{ "search_index": false }"#),
3094 tier("project", r#"{ "search_index": true }"#),
3095 ]);
3096
3097 assert!(result.config.search_index);
3098 assert!(result.dropped.is_empty());
3099 }
3100
3101 #[test]
3102 fn worktree_ram_overlay_resolves_at_user_and_project_tiers() {
3103 assert!(!resolve_config(&[]).config.worktree.ram_overlay);
3104
3105 let user = resolve_config(&[tier("user", r#"{ "worktree": { "ram_overlay": true } }"#)]);
3106 assert!(user.config.worktree.ram_overlay);
3107 assert!(user.dropped.is_empty());
3108
3109 let project = resolve_config(&[
3110 tier("user", r#"{ "worktree": { "ram_overlay": false } }"#),
3111 tier("project", r#"{ "worktree": { "ram_overlay": true } }"#),
3112 ]);
3113 assert!(project.config.worktree.ram_overlay);
3114 assert!(project.dropped.is_empty());
3115 }
3116
3117 #[test]
3118 fn project_sandbox_can_add_read_denies_but_not_enable_or_add_writes() {
3119 let result = resolve_config(&[
3120 tier(
3121 "user",
3122 r#"{
3123 "sandbox": {
3124 "enabled": true,
3125 "write_allow": ["/user/write"],
3126 "read_deny": ["/user/secret"]
3127 }
3128 }"#,
3129 ),
3130 tier(
3131 "project",
3132 r#"{
3133 "sandbox": {
3134 "enabled": false,
3135 "write_allow": ["/project/write"],
3136 "read_deny": ["/project/secret", "/user/secret"]
3137 }
3138 }"#,
3139 ),
3140 ]);
3141
3142 assert!(result.config.sandbox.enabled);
3143 assert_eq!(
3144 result.config.sandbox.write_allow,
3145 vec![PathBuf::from("/user/write")]
3146 );
3147 assert_eq!(
3148 result.config.sandbox.read_deny,
3149 vec![
3150 PathBuf::from("/user/secret"),
3151 PathBuf::from("/project/secret")
3152 ]
3153 );
3154 assert_eq!(
3155 drop_keys(&result),
3156 vec![
3157 "sandbox.enabled".to_string(),
3158 "sandbox.write_allow".to_string()
3159 ]
3160 );
3161 }
3162
3163 #[test]
3164 fn project_can_enable_sandbox_but_never_disable_it() {
3165 let opt_in = resolve_config(&[
3167 tier("user", r#"{}"#),
3168 tier("project", r#"{ "sandbox": { "enabled": true } }"#),
3169 ]);
3170 assert!(opt_in.config.sandbox.enabled);
3171 assert!(
3172 !drop_keys(&opt_in).contains(&"sandbox.enabled".to_string()),
3173 "project enabled:true is an accepted opt-in, not a dropped key"
3174 );
3175
3176 let opt_out = resolve_config(&[
3178 tier("user", r#"{ "sandbox": { "enabled": true } }"#),
3179 tier("project", r#"{ "sandbox": { "enabled": false } }"#),
3180 ]);
3181 assert!(opt_out.config.sandbox.enabled);
3182 assert!(drop_keys(&opt_out).contains(&"sandbox.enabled".to_string()));
3183 }
3184
3185 #[test]
3186 fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
3187 let result = resolve_config(&[
3188 tier(
3189 "user",
3190 r#"{
3191 "restrict_to_project_root": true,
3192 "url_fetch_allow_private": true,
3193 "formatter_timeout_secs": 11,
3194 "type_checker_timeout_secs": 33,
3195 "auto_update": true,
3196 "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
3197 "semantic": {
3198 "backend": "openai_compatible",
3199 "base_url": "https://user.example.test",
3200 "api_key_env": "USER_KEY",
3201 "model": "user-model",
3202 "query_timeout_ms": 900,
3203 "max_input_tokens": 512
3204 },
3205 "lsp": {
3206 "servers": {
3207 "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
3208 },
3209 "disabled": ["user-disabled"],
3210 "versions": { "typescript-language-server": "1.0.0" },
3211 "auto_install": true,
3212 "grace_days": 7
3213 }
3214 }"#,
3215 ),
3216 tier(
3217 "project",
3218 r#"{
3219 "restrict_to_project_root": false,
3220 "url_fetch_allow_private": false,
3221 "formatter_timeout_secs": 22,
3222 "type_checker_timeout_secs": 44,
3223 "auto_update": false,
3224 "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
3225 "semantic": {
3226 "backend": "ollama",
3227 "base_url": "https://project.example.test",
3228 "api_key_env": "PROJECT_KEY",
3229 "model": "project-model",
3230 "timeout_ms": 2222,
3231 "query_timeout_ms": 2222,
3232 "max_input_tokens": 960
3233 },
3234 "lsp": {
3235 "servers": {
3236 "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
3237 },
3238 "disabled": ["project-disabled"],
3239 "versions": { "evil-lsp": "9.9.9" },
3240 "auto_install": false,
3241 "grace_days": 1,
3242 "python": "ty"
3243 }
3244 }"#,
3245 ),
3246 ]);
3247
3248 assert!(result.config.restrict_to_project_root);
3249 assert!(result.config.url_fetch_allow_private);
3250 assert_eq!(result.config.formatter_timeout_secs, 11);
3251 assert_eq!(result.config.type_checker_timeout_secs, 33);
3252 assert_eq!(
3253 result.config.semantic.backend,
3254 SemanticBackend::OpenAiCompatible
3255 );
3256 assert_eq!(
3257 result.config.semantic.base_url.as_deref(),
3258 Some("https://user.example.test")
3259 );
3260 assert_eq!(
3261 result.config.semantic.api_key_env.as_deref(),
3262 Some("USER_KEY")
3263 );
3264 assert_eq!(result.config.semantic.model, "project-model");
3265 assert_eq!(result.config.semantic.timeout_ms, 2222);
3266 assert_eq!(result.config.semantic.query_timeout_ms, 900);
3267 assert_eq!(result.config.semantic.max_input_tokens, Some(960));
3268 assert_eq!(result.config.lsp_servers.len(), 1);
3269 assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
3270 assert!(result.config.disabled_lsp.contains("user-disabled"));
3271 assert!(!result.config.disabled_lsp.contains("project-disabled"));
3272 assert!(result.config.disabled_lsp.contains("python"));
3273 assert!(result.config.experimental_lsp_ty);
3274
3275 let keys = drop_keys(&result);
3276 let expected = [
3277 "restrict_to_project_root",
3278 "url_fetch_allow_private",
3279 "formatter_timeout_secs",
3280 "type_checker_timeout_secs",
3281 "auto_update",
3282 "bridge",
3283 "semantic.backend",
3284 "semantic.base_url",
3285 "semantic.api_key_env",
3286 "semantic.query_timeout_ms",
3287 "lsp.servers",
3288 "lsp.versions",
3289 "lsp.auto_install",
3290 "lsp.grace_days",
3291 "lsp.disabled",
3292 ];
3293 for key in expected {
3294 assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
3295 }
3296 assert_eq!(keys.len(), expected.len());
3297 assert!(result
3298 .dropped
3299 .iter()
3300 .all(|dropped| dropped.tier == "project"));
3301 }
3302
3303 #[test]
3304 fn disabled_tools_resolve_into_runtime_config() {
3305 let result = resolve_config(&[tier(
3306 "user",
3307 r#"{ "disabled_tools": ["aft_zoom", "aft_search"] }"#,
3308 )]);
3309 assert!(result
3310 .config
3311 .disabled_tools
3312 .iter()
3313 .any(|tool| tool == "aft_zoom"));
3314 assert!(result
3315 .config
3316 .disabled_tools
3317 .iter()
3318 .any(|tool| tool == "aft_search"));
3319 }
3320
3321 #[test]
3322 fn bash_linux_scope_is_user_only_and_defaults_off() {
3323 assert!(!resolve_config(&[]).config.bash.linux_scope);
3324
3325 let result = resolve_config(&[
3326 tier("user", r#"{ "bash": { "linux_scope": true } }"#),
3327 tier("project", r#"{ "bash": { "linux_scope": false } }"#),
3328 ]);
3329 assert!(result.config.bash.linux_scope);
3330 assert!(drop_keys(&result).contains(&"bash.linux_scope".to_string()));
3331 }
3332
3333 #[test]
3334 fn config_resolve_bash_ladder_and_merge_parity() {
3335 let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
3336 assert!(true_result.config.experimental_bash_rewrite);
3337 assert!(true_result.config.experimental_bash_compress);
3338 assert!(true_result.config.experimental_bash_background);
3339
3340 let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
3341 assert!(!false_result.config.experimental_bash_rewrite);
3342 assert!(!false_result.config.experimental_bash_compress);
3343 assert!(!false_result.config.experimental_bash_background);
3344
3345 let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
3346 assert!(object_default_result.config.experimental_bash_rewrite);
3347 assert!(object_default_result.config.experimental_bash_compress);
3348 assert!(object_default_result.config.experimental_bash_background);
3349
3350 let object_partial_result =
3351 resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
3352 assert!(object_partial_result.config.experimental_bash_rewrite);
3353 assert!(!object_partial_result.config.experimental_bash_compress);
3354 assert!(object_partial_result.config.experimental_bash_background);
3355
3356 let legacy_result = resolve_config(&[tier(
3357 "user",
3358 r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
3359 )]);
3360 assert!(legacy_result.config.experimental_bash_rewrite);
3361 assert!(!legacy_result.config.experimental_bash_compress);
3362 assert!(!legacy_result.config.experimental_bash_background);
3363
3364 let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
3365 assert!(surface_default_result.config.experimental_bash_rewrite);
3366 assert!(surface_default_result.config.experimental_bash_compress);
3367 assert!(surface_default_result.config.experimental_bash_background);
3368
3369 let minimal_surface_result =
3370 resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
3371 assert!(!minimal_surface_result.config.experimental_bash_rewrite);
3372 assert!(!minimal_surface_result.config.experimental_bash_compress);
3373 assert!(!minimal_surface_result.config.experimental_bash_background);
3374
3375 let merged_result = resolve_config(&[
3376 tier("user", r#"{ "bash": true }"#),
3377 tier("project", r#"{ "bash": { "compress": false } }"#),
3378 ]);
3379 assert!(merged_result.config.experimental_bash_rewrite);
3380 assert!(!merged_result.config.experimental_bash_compress);
3381 assert!(merged_result.config.experimental_bash_background);
3382
3383 let false_then_object_result = resolve_config(&[
3384 tier("user", r#"{ "bash": false }"#),
3385 tier("project", r#"{ "bash": { "compress": true } }"#),
3386 ]);
3387 assert!(!false_then_object_result.config.experimental_bash_rewrite);
3388 assert!(false_then_object_result.config.experimental_bash_compress);
3389 assert!(!false_then_object_result.config.experimental_bash_background);
3390 }
3391
3392 #[test]
3393 fn config_resolve_bash_foreground_wait_clamps_to_floor() {
3394 let Some(raw) = parse_tier(&tier(
3395 "user",
3396 r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
3397 )) else {
3398 panic!("test tier should parse");
3399 };
3400 let mut warnings = Vec::new();
3401 let bash = resolve_bash_config(&raw, &mut warnings);
3402
3403 assert_eq!(
3404 bash.foreground_wait_window_ms,
3405 FOREGROUND_WAIT_WINDOW_MIN_MS
3406 );
3407 assert!(bash.subagent_background);
3408
3409 let result = resolve_config(&[tier(
3410 "user",
3411 r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
3412 )]);
3413 assert_eq!(
3414 result.config.foreground_wait_window_ms,
3415 FOREGROUND_WAIT_WINDOW_MIN_MS
3416 );
3417
3418 let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
3423 assert_eq!(
3424 defaulted.config.foreground_wait_window_ms,
3425 FOREGROUND_WAIT_WINDOW_DEFAULT_MS
3426 );
3427 assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
3428 }
3429
3430 #[test]
3431 fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
3432 let result = resolve_config(&[tier(
3433 "user",
3434 r#"{
3435 "semantic": { "timeout_ms": 0 },
3436 "search_index": true,
3437 "format_on_edit": false
3438 }"#,
3439 )]);
3440
3441 assert!(result.config.search_index);
3442 assert!(!result.config.format_on_edit);
3443 assert_eq!(result.config.semantic, SemanticBackendConfig::default());
3444 assert!(result.dropped.is_empty());
3445 }
3446
3447 #[test]
3448 fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
3449 let result = resolve_config(&[tier(
3450 "user",
3451 r#"{ "not_a_real_key": true, "search_index": true }"#,
3452 )]);
3453
3454 assert!(result.config.search_index);
3455 assert!(result.dropped.is_empty());
3456 }
3457
3458 #[test]
3459 fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
3460 let mut config = Config::default();
3466
3467 let dropped1 = resolve_config_onto(
3470 &[tier(
3471 "user",
3472 r#"{
3473 "url_fetch_allow_private": true,
3474 "restrict_to_project_root": true,
3475 "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
3476 }"#,
3477 )],
3478 &mut config,
3479 );
3480 assert!(dropped1.is_empty());
3481 assert!(config.url_fetch_allow_private);
3482 assert!(config.restrict_to_project_root);
3483 assert_eq!(config.lsp_servers.len(), 1);
3484
3485 let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
3488 assert!(
3489 !config.url_fetch_allow_private,
3490 "url_fetch_allow_private must reset to default, not inherit prior bind"
3491 );
3492 assert!(
3493 !config.restrict_to_project_root,
3494 "restrict_to_project_root must reset to default"
3495 );
3496 assert!(
3497 config.lsp_servers.is_empty(),
3498 "lsp_servers must reset to default, not inherit prior bind's custom server"
3499 );
3500 assert!(config.search_index, "this bind's own field still applies");
3501 }
3502
3503 #[test]
3504 fn resolve_config_onto_empty_tiers_resets_to_default() {
3505 let mut config = Config::default();
3509 let _ = resolve_config_onto(
3510 &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
3511 &mut config,
3512 );
3513 assert!(config.url_fetch_allow_private);
3514
3515 let _ = resolve_config_onto(&[], &mut config);
3516 assert!(
3517 !config.url_fetch_allow_private,
3518 "empty-tier bind must reset core config to default"
3519 );
3520 }
3521
3522 #[test]
3523 fn resolve_config_onto_preserves_process_state_fields() {
3524 let mut config = Config {
3528 storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
3529 lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
3530 bash_permissions: true,
3531 project_root: Some(std::path::PathBuf::from("/tmp/proj")),
3532 ..Default::default()
3533 };
3534
3535 let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
3536
3537 assert_eq!(
3538 config.storage_dir,
3539 Some(std::path::PathBuf::from("/tmp/aft-store"))
3540 );
3541 assert_eq!(
3542 config.lsp_paths_extra,
3543 vec![std::path::PathBuf::from("/tmp/lsp-bin")]
3544 );
3545 assert!(config.bash_permissions);
3546 assert_eq!(
3547 config.project_root,
3548 Some(std::path::PathBuf::from("/tmp/proj"))
3549 );
3550 assert!(config.search_index);
3551 }
3552
3553 #[test]
3554 fn index_roots_are_user_only_normalized_and_validate_before_resolution() {
3555 let result = resolve_config(&[tier(
3556 "user",
3557 r#"{
3558 "index": {
3559 "roots": [{
3560 "path": "~/.aft-standing-root",
3561 "indexes": ["semantic", "callgraph"],
3562 "future_field": true
3563 }]
3564 }
3565 }"#,
3566 )]);
3567 assert_eq!(result.config.index.roots.len(), 1);
3568 assert_eq!(result.config.index.roots[0].path, "~/.aft-standing-root");
3569 assert_eq!(
3570 result.config.index.roots[0].indexes,
3571 vec![IndexKind::Search, IndexKind::Semantic, IndexKind::Callgraph]
3572 );
3573 assert!(result
3574 .warnings
3575 .iter()
3576 .any(|warning| warning.code == "index_dependency_closure"));
3577 assert!(result
3578 .warnings
3579 .iter()
3580 .any(|warning| warning.code == "unknown_index_root_field"));
3581
3582 for invalid in [
3583 r#"{ "index": { "roots": [{ "path": "relative", "indexes": ["search"] }] } }"#,
3584 r#"{ "index": { "roots": [{ "path": "~/a", "indexes": [] }] } }"#,
3585 r#"{ "index": { "roots": [{ "path": "~/a", "indexes": ["search", "search"] }] } }"#,
3586 r#"{ "index": { "roots": [{ "path": "~/a", "indexes": ["unknown"] }] } }"#,
3587 ] {
3588 let invalid = resolve_config(&[tier("user", invalid)]);
3589 assert!(invalid.config.index.roots.is_empty());
3590 let warning = invalid
3591 .warnings
3592 .iter()
3593 .find(|warning| warning.code == "invalid_index_roots")
3594 .expect("invalid standing roots must be named");
3595 assert!(warning.message.contains("index.roots"));
3596 }
3597 }
3598
3599 #[test]
3600 fn index_roots_project_and_mcp_tiers_are_rejected_at_the_trust_boundary() {
3601 let result = resolve_config(&[
3602 tier(
3603 "user",
3604 r#"{ "index": { "roots": [{ "path": "~/user", "indexes": ["search"] }] } }"#,
3605 ),
3606 tier(
3607 "project",
3608 r#"{ "index": { "roots": [{ "path": "~/project", "indexes": ["semantic"] }] } }"#,
3609 ),
3610 tier(
3611 "mcp:untrusted",
3612 r#"{ "index": { "roots": [{ "path": "~/mcp", "indexes": ["callgraph"] }] } }"#,
3613 ),
3614 ]);
3615 assert_eq!(result.config.index.roots[0].path, "~/user");
3616 assert_eq!(
3617 result
3618 .dropped
3619 .iter()
3620 .filter(|dropped| dropped.key == "index.roots")
3621 .map(|dropped| dropped.tier.as_str())
3622 .collect::<Vec<_>>(),
3623 vec!["project", "mcp:untrusted"]
3624 );
3625 }
3626
3627 #[test]
3628 fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
3629 let result = resolve_config(&[tier(
3630 "user",
3631 r#"{
3632 // line comment
3633 "search_index": true,
3634 "formatter": {
3635 "rust": "rustfmt", /* block comment */
3636 },
3637 }"#,
3638 )]);
3639
3640 assert!(result.config.search_index);
3641 assert_eq!(
3642 result.config.formatter.get("rust").map(String::as_str),
3643 Some("rustfmt")
3644 );
3645 }
3646}