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