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 IndexConfig, IndexKind, IndexRootConfig, InspectConfig, SandboxConfig, SemanticBackend,
18 SemanticBackendConfig, UserServerDef, WorktreeConfig, DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
19 MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS,
20 MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
21};
22use crate::harness::Harness;
23use crate::jsonc::strip_jsonc;
24
25const FOREGROUND_WAIT_WINDOW_DEFAULT_MS: u64 = 15_000;
26const FOREGROUND_WAIT_WINDOW_MIN_MS: u64 = 5_000;
27
28const MAX_SEMANTIC_TIMEOUT_MS: u64 = 120_000;
32const MAX_SEMANTIC_BATCH_SIZE: usize = 1_024;
33
34const USER_ONLY_REASON: &str =
35 "security: this setting only honors user-level config and project values are ignored";
36const SEMANTIC_SECRET_REASON: &str =
37 "security: semantic backend credentials and endpoints must come from user-level config";
38const LSP_USER_ONLY_REASON: &str =
39 "security: LSP executable-origin and diagnostic-suppression settings must come from user-level config";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ConfigTier {
47 pub tier: String,
48 pub source: String,
49 pub doc: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DroppedKey {
55 pub key: String,
56 pub tier: String,
57 pub reason: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ConfigWarning {
63 pub code: &'static str,
64 pub key: &'static str,
65 pub tier: String,
66 pub value: String,
67 pub message: String,
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct ResolveDiagnostics {
73 pub dropped: Vec<DroppedKey>,
74 pub warnings: Vec<ConfigWarning>,
75}
76
77#[derive(Debug, Clone)]
79pub struct ResolveResult {
80 pub config: Config,
81 pub dropped: Vec<DroppedKey>,
82 pub warnings: Vec<ConfigWarning>,
83}
84
85#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
89#[serde(default, deny_unknown_fields)]
90pub struct RawAftConfig {
91 #[serde(rename = "$schema")]
92 pub schema: Option<String>,
93 pub enabled: Option<bool>,
98 pub edit_mode: Option<RawEditMode>,
99 pub format_on_edit: Option<bool>,
100 #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
101 pub formatter_timeout_secs: Option<u32>,
102 #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
103 pub type_checker_timeout_secs: Option<u32>,
104 pub validate_on_edit: Option<RawValidateOnEdit>,
105 pub formatter: Option<HashMap<String, RawFormatter>>,
106 pub checker: Option<HashMap<String, RawChecker>>,
107 pub configure_warnings_delivery: Option<RawConfigureWarningsDelivery>,
108 pub hoist_builtin_tools: Option<bool>,
109 pub tool_surface: Option<RawToolSurface>,
110 pub disabled_tools: Option<Vec<String>>,
111 pub restrict_to_project_root: Option<bool>,
112 pub search_index: Option<bool>,
113 pub index: Option<RawIndex>,
114 pub semantic_search: Option<bool>,
115 pub callgraph_store: Option<bool>,
116 #[serde(deserialize_with = "deserialize_opt_usize")]
117 pub callgraph_chunk_size: Option<usize>,
118 pub inspect: Option<RawInspect>,
119 pub backup: Option<RawBackup>,
120 pub worktree: Option<RawWorktree>,
121 pub gh_shim: Option<RawGhShim>,
122 pub gh_read: Option<RawGhRead>,
123 pub git: Option<RawGit>,
124 pub sandbox: Option<RawSandbox>,
125 pub bash: Option<RawBash>,
126 pub experimental: Option<RawExperimental>,
127 pub lsp: Option<RawLsp>,
128 pub url_fetch_allow_private: Option<bool>,
129 pub semantic: Option<RawSemantic>,
130 pub auto_update: Option<bool>,
131 pub bridge: Option<RawBridge>,
132 pub subc: Option<RawSubc>,
133 pub harnesses: Option<BTreeMap<String, Value>>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum RawEditMode {
140 Default,
141 Hashline,
142 Unknown(String),
143}
144
145impl<'de> Deserialize<'de> for RawEditMode {
146 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
147 where
148 D: Deserializer<'de>,
149 {
150 let value = String::deserialize(deserializer)?;
151 Ok(match value.as_str() {
152 "default" => Self::Default,
153 "hashline" => Self::Hashline,
154 _ => Self::Unknown(value),
155 })
156 }
157}
158
159#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
160#[serde(rename_all = "snake_case")]
161pub enum RawValidateOnEdit {
162 Syntax,
163 Full,
164}
165
166impl RawValidateOnEdit {
167 const fn as_str(self) -> &'static str {
168 match self {
169 Self::Syntax => "syntax",
170 Self::Full => "full",
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
176#[serde(rename_all = "snake_case")]
177pub enum RawFormatter {
178 Biome,
179 Oxfmt,
180 Prettier,
181 Deno,
182 Ruff,
183 Black,
184 Rustfmt,
185 Goimports,
186 Gofmt,
187 None,
188}
189
190impl RawFormatter {
191 const fn as_str(self) -> &'static str {
192 match self {
193 Self::Biome => "biome",
194 Self::Oxfmt => "oxfmt",
195 Self::Prettier => "prettier",
196 Self::Deno => "deno",
197 Self::Ruff => "ruff",
198 Self::Black => "black",
199 Self::Rustfmt => "rustfmt",
200 Self::Goimports => "goimports",
201 Self::Gofmt => "gofmt",
202 Self::None => "none",
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
208#[serde(rename_all = "snake_case")]
209pub enum RawChecker {
210 Tsc,
211 Tsgo,
212 Biome,
213 Pyright,
214 Ruff,
215 Cargo,
216 Go,
217 Staticcheck,
218 None,
219}
220
221impl RawChecker {
222 const fn as_str(self) -> &'static str {
223 match self {
224 Self::Tsc => "tsc",
225 Self::Tsgo => "tsgo",
226 Self::Biome => "biome",
227 Self::Pyright => "pyright",
228 Self::Ruff => "ruff",
229 Self::Cargo => "cargo",
230 Self::Go => "go",
231 Self::Staticcheck => "staticcheck",
232 Self::None => "none",
233 }
234 }
235}
236
237#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
238#[serde(rename_all = "snake_case")]
239pub enum RawConfigureWarningsDelivery {
240 Toast,
241 Log,
242 Chat,
243}
244
245#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
246#[serde(rename_all = "snake_case")]
247pub enum RawToolSurface {
248 Minimal,
249 Recommended,
250 All,
251}
252
253#[derive(Debug, Clone, Deserialize, PartialEq)]
254pub struct RawSemantic {
259 pub backend: Option<SemanticBackend>,
260 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
261 pub model: Option<String>,
262 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
263 pub base_url: Option<String>,
264 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
265 pub api_key_env: Option<String>,
266 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
267 pub timeout_ms: Option<u64>,
268 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
269 pub query_timeout_ms: Option<u64>,
270 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
271 pub max_batch_size: Option<usize>,
272 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
273 pub max_files: Option<usize>,
274}
275
276impl RawSemantic {
277 fn is_empty(&self) -> bool {
278 self.backend.is_none()
279 && self.model.is_none()
280 && self.base_url.is_none()
281 && self.api_key_env.is_none()
282 && self.timeout_ms.is_none()
283 && self.query_timeout_ms.is_none()
284 && self.max_batch_size.is_none()
285 && self.max_files.is_none()
286 }
287}
288
289#[derive(Debug, Clone, Deserialize, PartialEq)]
290pub struct RawLsp {
291 #[serde(default, deserialize_with = "deserialize_opt_lsp_servers")]
292 pub servers: Option<BTreeMap<String, RawLspServerEntry>>,
293 #[serde(
294 default,
295 deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec"
296 )]
297 pub disabled: Option<Vec<String>>,
298 pub python: Option<RawPythonLsp>,
299 pub diagnostics_on_edit: Option<bool>,
300 pub auto_install: Option<bool>,
301 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
302 pub grace_days: Option<u64>,
303 #[serde(default, deserialize_with = "deserialize_opt_versions_map")]
304 pub versions: Option<HashMap<String, String>>,
305}
306
307impl RawLsp {
308 fn is_empty(&self) -> bool {
309 self.servers.is_none()
310 && self.disabled.is_none()
311 && self.python.is_none()
312 && self.diagnostics_on_edit.is_none()
313 && self.auto_install.is_none()
314 && self.grace_days.is_none()
315 && self.versions.is_none()
316 }
317}
318
319#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
320#[serde(rename_all = "snake_case")]
321pub enum RawPythonLsp {
322 Pyright,
323 Ty,
324 Auto,
325}
326
327#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
328#[serde(default)]
329pub struct RawLspServerEntry {
330 #[serde(deserialize_with = "deserialize_opt_lsp_extensions")]
331 pub extensions: Option<Vec<String>>,
332 #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
333 pub binary: Option<String>,
334 pub args: Option<Vec<String>>,
335 #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec")]
336 pub root_markers: Option<Vec<String>>,
337 pub disabled: Option<bool>,
338 pub env: Option<HashMap<String, String>>,
339 pub initialization_options: Option<Value>,
340}
341
342#[derive(Debug, Clone, Deserialize, PartialEq)]
343#[serde(untagged)]
344pub enum RawBash {
345 Bool(bool),
346 Features(RawBashFeatures),
347}
348
349#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
350#[serde(default)]
351pub struct RawBashFeatures {
352 pub rewrite: Option<bool>,
353 pub compress: Option<bool>,
354 pub background: Option<bool>,
355 pub host_fallback: Option<bool>,
356 pub subagent_background: Option<bool>,
357 pub detach_on_user_message: Option<bool>,
358 pub long_running_reminder_enabled: Option<bool>,
359 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
360 pub long_running_reminder_interval_ms: Option<u64>,
361 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
362 pub foreground_wait_window_ms: Option<u64>,
363 pub powershell_tool: Option<bool>,
364}
365
366#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
367#[serde(default)]
368pub struct RawExperimental {
369 pub bash: Option<RawExperimentalBash>,
370 pub lsp_ty: Option<bool>,
371}
372
373impl RawExperimental {
374 fn is_empty(&self) -> bool {
375 self.bash.is_none() && self.lsp_ty.is_none()
376 }
377}
378
379#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
380#[serde(default)]
381pub struct RawExperimentalBash {
382 pub rewrite: Option<bool>,
383 pub compress: Option<bool>,
384 pub background: Option<bool>,
385 pub long_running_reminder_enabled: Option<bool>,
386 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
387 pub long_running_reminder_interval_ms: Option<u64>,
388}
389
390impl RawExperimentalBash {
391 fn has_any_value(&self) -> bool {
392 self.rewrite.is_some()
393 || self.compress.is_some()
394 || self.background.is_some()
395 || self.long_running_reminder_enabled.is_some()
396 || self.long_running_reminder_interval_ms.is_some()
397 }
398
399 fn has_legacy_feature_flag(&self) -> bool {
400 self.rewrite.is_some() || self.compress.is_some() || self.background.is_some()
401 }
402}
403
404#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
405#[serde(default)]
406pub struct RawInspect {
407 pub enabled: Option<bool>,
408 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
409 pub diagnostics_timeout_ms: Option<u64>,
410 #[serde(deserialize_with = "deserialize_opt_nonnegative_f64")]
411 pub tier2_idle_minutes: Option<f64>,
412 pub categories: Option<HashMap<String, bool>>,
413 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
414 pub tier2_soft_deadline_ms: Option<u64>,
415 #[serde(deserialize_with = "deserialize_opt_drill_down_items")]
416 pub max_drill_down_items: Option<usize>,
417 pub duplicates: Option<RawInspectDuplicates>,
418}
419
420impl RawInspect {
421 fn is_empty(&self) -> bool {
422 self.enabled.is_none()
423 && self.diagnostics_timeout_ms.is_none()
424 && self.tier2_idle_minutes.is_none()
425 && self.categories.is_none()
426 && self.tier2_soft_deadline_ms.is_none()
427 && self.max_drill_down_items.is_none()
428 && self.duplicates.is_none()
429 }
430}
431
432#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
437#[serde(default)]
438pub struct RawInspectDuplicates {
439 pub expected_mirrors: Option<Vec<[String; 2]>>,
440}
441
442impl RawInspectDuplicates {
443 fn is_empty(&self) -> bool {
444 self.expected_mirrors.is_none()
445 }
446}
447
448#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
449#[serde(default)]
450pub struct RawBridge {
451 #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
452 pub request_timeout_ms: Option<u64>,
453 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
454 pub hang_threshold: Option<u64>,
455}
456
457#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
458#[serde(default)]
459pub struct RawSubc {
460 pub connection_file: Option<String>,
461 pub client_reaper: Option<bool>,
462}
463
464#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
465#[serde(default)]
466pub struct RawWorktree {
467 pub ram_overlay: Option<bool>,
468}
469
470impl RawWorktree {
471 fn is_empty(&self) -> bool {
472 self.ram_overlay.is_none()
473 }
474}
475
476#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
477#[serde(default)]
478pub struct RawGhShim {
479 pub enabled: Option<bool>,
480 #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
481 pub binary_path: Option<String>,
482}
483
484#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
485#[serde(default)]
486pub struct RawGhRead {
487 pub enabled: Option<bool>,
488}
489
490#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
491#[serde(default)]
492pub struct RawGit {
493 #[serde(deserialize_with = "deserialize_opt_git_co_author")]
494 pub co_author: Option<String>,
495}
496
497#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
498#[serde(default)]
499pub struct RawBackup {
500 pub enabled: Option<bool>,
501 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
502 pub max_depth: Option<usize>,
503 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
504 pub max_file_size: Option<u64>,
505}
506
507#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
508#[serde(default)]
509pub struct RawSandbox {
510 pub enabled: Option<bool>,
511 pub write_allow: Option<Vec<PathBuf>>,
512 pub read_deny: Option<Vec<PathBuf>>,
513}
514
515#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
519#[serde(default)]
520pub struct RawIndex {
521 pub roots: Option<Vec<RawIndexRoot>>,
522}
523
524#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
525#[serde(default)]
526pub struct RawIndexRoot {
527 pub path: Option<String>,
528 pub indexes: Option<Vec<String>>,
529 #[serde(flatten)]
530 pub unknown: BTreeMap<String, Value>,
531}
532
533pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
540 resolve_config_for_harness(tiers, None)
541}
542
543pub fn resolve_config_for_harness(
546 tiers: &[ConfigTier],
547 harness: Option<&Harness>,
548) -> ResolveResult {
549 let mut merged = RawAftConfig::default();
550 let mut dropped = Vec::new();
551 let mut warnings = Vec::new();
552
553 for tier in tiers {
554 let Some(mut raw) = parse_tier(tier) else {
555 continue;
556 };
557 apply_harness_override(&mut raw, harness, tier, &mut warnings);
558 if let Some(RawEditMode::Unknown(value)) = raw.edit_mode.as_ref() {
559 warnings.push(ConfigWarning {
560 code: "invalid_edit_mode",
561 key: "edit_mode",
562 tier: tier.tier.clone(),
563 value: value.clone(),
564 message: format!("Unknown edit_mode value {value:?}; falling back to \"default\""),
565 });
566 }
567
568 if tier.tier == "user" {
569 merge_trusted_config(&mut merged, raw);
570 } else {
571 record_project_drops(&raw, &tier.tier, &mut dropped);
572 merge_project_config(&mut merged, raw);
573 }
574 }
575
576 let mut config = Config::default();
577 apply_resolved_config(&merged, &mut config);
578 config.index = resolve_index_config(merged.index.as_ref(), &mut warnings);
579 ResolveResult {
580 config,
581 dropped,
582 warnings,
583 }
584}
585
586pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
615 resolve_config_onto_with_diagnostics(tiers, base).dropped
616}
617
618pub fn resolve_config_onto_for_harness(
621 tiers: &[ConfigTier],
622 harness: &Harness,
623 base: &mut Config,
624) -> Vec<DroppedKey> {
625 resolve_config_onto_with_diagnostics_for_harness(tiers, Some(harness), base).dropped
626}
627
628pub fn resolve_config_onto_with_diagnostics(
631 tiers: &[ConfigTier],
632 base: &mut Config,
633) -> ResolveDiagnostics {
634 resolve_config_onto_with_diagnostics_for_harness(tiers, None, base)
635}
636
637pub fn resolve_config_onto_with_diagnostics_for_harness(
639 tiers: &[ConfigTier],
640 harness: Option<&Harness>,
641 base: &mut Config,
642) -> ResolveDiagnostics {
643 let ResolveResult {
644 mut config,
645 dropped,
646 warnings,
647 } = resolve_config_for_harness(tiers, harness);
648 carry_process_state(base, &mut config);
649 *base = config;
650 ResolveDiagnostics { dropped, warnings }
651}
652
653fn carry_process_state(base: &Config, resolved: &mut Config) {
659 resolved.project_root = base.project_root.clone();
660 resolved.harness = base.harness.clone();
661 resolved.validation_depth = base.validation_depth;
662 resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
663 resolved.max_symbol_depth = base.max_symbol_depth;
664 resolved.diagnostic_cache_size = base.diagnostic_cache_size;
665 resolved.aft_search_registered = base.aft_search_registered;
666 resolved.max_background_bash_tasks = base.max_background_bash_tasks;
667 resolved.bash_permissions = base.bash_permissions;
668 resolved.search_index_max_file_size = base.search_index_max_file_size;
669 resolved.storage_dir = base.storage_dir.clone();
670 resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
671 resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
672 resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
673}
674
675fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
676 let stripped = strip_jsonc(&tier.doc);
677 let value = serde_json::from_str::<Value>(&stripped).ok()?;
678 let Value::Object(map) = value else {
679 return None;
680 };
681
682 match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
683 Ok(config) => Some(config),
684 Err(_) => Some(parse_config_partially(map)),
685 }
686}
687
688fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
689 let mut partial = RawAftConfig::default();
690
691 for (key, value) in raw_config {
692 let mut one_field = Map::new();
693 one_field.insert(key, value);
694 if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
695 merge_trusted_config(&mut partial, section);
696 }
697 }
698
699 partial
700}
701
702fn apply_harness_override(
703 raw: &mut RawAftConfig,
704 harness: Option<&Harness>,
705 tier: &ConfigTier,
706 warnings: &mut Vec<ConfigWarning>,
707) {
708 let Some(overrides) = raw.harnesses.take() else {
709 return;
710 };
711 let Some(harness) = harness else {
712 return;
713 };
714 let key = harness.wire_label();
715 let Some(value) = overrides.get(&key) else {
716 return;
717 };
718 let Value::Object(mut override_map) = value.clone() else {
719 warnings.push(ConfigWarning {
720 code: "invalid_harness_override",
721 key: "harnesses",
722 tier: tier.tier.clone(),
723 value: key,
724 message: "Ignoring non-object harness override; overrides must be config objects"
725 .to_string(),
726 });
727 return;
728 };
729
730 if override_map.remove("harnesses").is_some() {
731 warnings.push(ConfigWarning {
732 code: "nested_harnesses_ignored",
733 key: "harnesses",
734 tier: tier.tier.clone(),
735 value: key.clone(),
736 message: format!(
737 "Ignoring nested harnesses in harnesses.{key}; harness overrides cannot recurse"
738 ),
739 });
740 }
741
742 match serde_json::from_value::<RawAftConfig>(Value::Object(override_map)) {
743 Ok(override_config) => merge_trusted_config(raw, override_config),
744 Err(_) => warnings.push(ConfigWarning {
745 code: "invalid_harness_override",
746 key: "harnesses",
747 tier: tier.tier.clone(),
748 value: key,
749 message: "Ignoring invalid harness override; it must use the root config shape"
750 .to_string(),
751 }),
752 }
753}
754
755fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
756 if override_config.harnesses.is_some() {
757 base.harnesses = override_config.harnesses.clone();
758 }
759 if override_config.schema.is_some() {
760 base.schema = override_config.schema;
761 }
762 if override_config.enabled.is_some() {
763 base.enabled = override_config.enabled;
764 }
765 if override_config.edit_mode.is_some() {
766 base.edit_mode = override_config.edit_mode;
767 }
768 if override_config.format_on_edit.is_some() {
769 base.format_on_edit = override_config.format_on_edit;
770 }
771 if override_config.formatter_timeout_secs.is_some() {
772 base.formatter_timeout_secs = override_config.formatter_timeout_secs;
773 }
774 if override_config.type_checker_timeout_secs.is_some() {
775 base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
776 }
777 if override_config.validate_on_edit.is_some() {
778 base.validate_on_edit = override_config.validate_on_edit;
779 }
780 if override_config.formatter.is_some() {
781 base.formatter = override_config.formatter;
782 }
783 if override_config.checker.is_some() {
784 base.checker = override_config.checker;
785 }
786 if override_config.configure_warnings_delivery.is_some() {
787 base.configure_warnings_delivery = override_config.configure_warnings_delivery;
788 }
789 if override_config.hoist_builtin_tools.is_some() {
790 base.hoist_builtin_tools = override_config.hoist_builtin_tools;
791 }
792 if override_config.tool_surface.is_some() {
793 base.tool_surface = override_config.tool_surface;
794 }
795 if override_config.disabled_tools.is_some() {
796 base.disabled_tools = override_config.disabled_tools;
797 }
798 if override_config.restrict_to_project_root.is_some() {
799 base.restrict_to_project_root = override_config.restrict_to_project_root;
800 }
801 if override_config.search_index.is_some() {
802 base.search_index = override_config.search_index;
803 }
804 if override_config.index.is_some() {
805 base.index = override_config.index;
806 }
807 if override_config.semantic_search.is_some() {
808 base.semantic_search = override_config.semantic_search;
809 }
810 if override_config.callgraph_store.is_some() {
811 base.callgraph_store = override_config.callgraph_store;
812 }
813 if override_config.callgraph_chunk_size.is_some() {
814 base.callgraph_chunk_size = override_config.callgraph_chunk_size;
815 }
816 if override_config.inspect.is_some() {
817 base.inspect = override_config.inspect;
818 }
819 if override_config.backup.is_some() {
820 base.backup = override_config.backup;
821 }
822 if override_config.worktree.is_some() {
823 base.worktree = override_config.worktree;
824 }
825 if override_config.gh_shim.is_some() {
826 base.gh_shim = override_config.gh_shim;
827 }
828 if override_config.gh_read.is_some() {
829 base.gh_read = override_config.gh_read;
830 }
831 if override_config.git.is_some() {
832 base.git = override_config.git;
833 }
834 if override_config.sandbox.is_some() {
835 base.sandbox = override_config.sandbox;
836 }
837 if override_config.bash.is_some() {
838 base.bash = override_config.bash;
839 }
840 if override_config.experimental.is_some() {
841 base.experimental = override_config.experimental;
842 }
843 if override_config.lsp.is_some() {
844 base.lsp = override_config.lsp;
845 }
846 if override_config.url_fetch_allow_private.is_some() {
847 base.url_fetch_allow_private = override_config.url_fetch_allow_private;
848 }
849 if override_config.semantic.is_some() {
850 base.semantic = override_config.semantic;
851 }
852 if override_config.auto_update.is_some() {
853 base.auto_update = override_config.auto_update;
854 }
855 if override_config.bridge.is_some() {
856 base.bridge = override_config.bridge;
857 }
858 if override_config.subc.is_some() {
859 base.subc = override_config.subc;
860 }
861}
862
863fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
864 if project.enabled.is_some() {
866 base.enabled = project.enabled;
867 }
868 if project.edit_mode.is_some() {
869 base.edit_mode = project.edit_mode;
870 }
871 if project.format_on_edit.is_some() {
872 base.format_on_edit = project.format_on_edit;
873 }
874 if project.validate_on_edit.is_some() {
875 base.validate_on_edit = project.validate_on_edit;
876 }
877 if project.configure_warnings_delivery.is_some() {
878 base.configure_warnings_delivery = project.configure_warnings_delivery;
879 }
880 if project.hoist_builtin_tools.is_some() {
881 base.hoist_builtin_tools = project.hoist_builtin_tools;
882 }
883 if project.tool_surface.is_some() {
884 base.tool_surface = project.tool_surface;
885 }
886 if project.search_index.is_some() {
887 base.search_index = project.search_index;
888 }
889 if project.semantic_search.is_some() {
890 base.semantic_search = project.semantic_search;
891 }
892 if project.callgraph_store.is_some() {
893 base.callgraph_store = project.callgraph_store;
894 }
895 if project.callgraph_chunk_size.is_some() {
896 base.callgraph_chunk_size = project.callgraph_chunk_size;
897 }
898
899 merge_formatter_map(&mut base.formatter, project.formatter);
900 merge_checker_map(&mut base.checker, project.checker);
901 merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
902 base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
903 base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
904 base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
905 base.bash = merge_bash_config(base.bash.clone(), project.bash);
906 base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
907 base.worktree = merge_worktree_config(base.worktree.clone(), project.worktree);
908 if project.git.is_some() {
909 base.git = project.git;
910 }
911 base.sandbox = merge_project_sandbox(base.sandbox.clone(), project.sandbox);
912}
913
914fn merge_project_sandbox(
915 base: Option<RawSandbox>,
916 project: Option<RawSandbox>,
917) -> Option<RawSandbox> {
918 let Some(project) = project else {
919 return base;
920 };
921 let mut sandbox = base.unwrap_or_default();
922 if let Some(project_denies) = project.read_deny {
923 let denies = sandbox.read_deny.get_or_insert_with(Vec::new);
924 for path in project_denies {
925 if !denies.contains(&path) {
926 denies.push(path);
927 }
928 }
929 }
930 if project.enabled == Some(true) {
934 sandbox.enabled = Some(true);
935 }
936 (sandbox.enabled.is_some() || sandbox.write_allow.is_some() || sandbox.read_deny.is_some())
937 .then_some(sandbox)
938}
939
940fn merge_formatter_map(
941 base: &mut Option<HashMap<String, RawFormatter>>,
942 override_map: Option<HashMap<String, RawFormatter>>,
943) {
944 let Some(override_map) = override_map else {
945 return;
946 };
947 if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
948 return;
949 }
950 let target = base.get_or_insert_with(HashMap::new);
951 target.extend(override_map);
952}
953
954fn merge_checker_map(
955 base: &mut Option<HashMap<String, RawChecker>>,
956 override_map: Option<HashMap<String, RawChecker>>,
957) {
958 let Some(override_map) = override_map else {
959 return;
960 };
961 if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
962 return;
963 }
964 let target = base.get_or_insert_with(HashMap::new);
965 target.extend(override_map);
966}
967
968fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
969 let Some(override_tools) = override_tools else {
970 return;
971 };
972 let mut merged = Vec::new();
973 let mut seen = HashSet::new();
974 for tool in base.iter().flatten() {
975 if seen.insert(tool.clone()) {
976 merged.push(tool.clone());
977 }
978 }
979 for tool in override_tools
980 .iter()
981 .filter(|tool| tool.as_str() != "aft_safety")
982 {
983 if seen.insert(tool.clone()) {
984 merged.push(tool.clone());
985 }
986 }
987 if !merged.is_empty() {
988 *base = Some(merged);
989 }
990}
991
992fn merge_semantic_config(
993 base: Option<RawSemantic>,
994 override_semantic: Option<RawSemantic>,
995) -> Option<RawSemantic> {
996 let mut semantic = base.unwrap_or(RawSemantic {
997 backend: None,
998 model: None,
999 base_url: None,
1000 api_key_env: None,
1001 timeout_ms: None,
1002 query_timeout_ms: None,
1003 max_batch_size: None,
1004 max_files: None,
1005 });
1006
1007 if let Some(project) = override_semantic {
1008 if project.model.is_some() {
1009 semantic.model = project.model;
1010 }
1011 if project.timeout_ms.is_some() {
1012 semantic.timeout_ms = project.timeout_ms;
1013 }
1014 if project.max_batch_size.is_some() {
1015 semantic.max_batch_size = project.max_batch_size;
1016 }
1017 if project.max_files.is_some() {
1018 semantic.max_files = project.max_files;
1019 }
1020 }
1021
1022 (!semantic.is_empty()).then_some(semantic)
1023}
1024
1025fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
1026 let mut lsp = base.unwrap_or(RawLsp {
1027 servers: None,
1028 disabled: None,
1029 python: None,
1030 diagnostics_on_edit: None,
1031 auto_install: None,
1032 grace_days: None,
1033 versions: None,
1034 });
1035
1036 if let Some(project) = override_lsp {
1037 if project.python.is_some() {
1038 lsp.python = project.python;
1039 }
1040 if project.diagnostics_on_edit.is_some() {
1041 lsp.diagnostics_on_edit = project.diagnostics_on_edit;
1042 }
1043 }
1044
1045 (!lsp.is_empty()).then_some(lsp)
1046}
1047
1048fn merge_experimental_config(
1049 base: Option<RawExperimental>,
1050 override_experimental: Option<RawExperimental>,
1051) -> Option<RawExperimental> {
1052 let Some(override_experimental) = override_experimental else {
1053 return base;
1054 };
1055
1056 let mut experimental = base.unwrap_or_default();
1057 experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
1058 experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
1059
1060 (!experimental.is_empty()).then_some(experimental)
1061}
1062
1063fn merge_experimental_bash(
1064 base: Option<RawExperimentalBash>,
1065 override_bash: Option<RawExperimentalBash>,
1066) -> Option<RawExperimentalBash> {
1067 let Some(override_bash) = override_bash else {
1068 return base;
1069 };
1070 let mut bash = base.unwrap_or_default();
1071 bash.rewrite = override_bash.rewrite.or(bash.rewrite);
1072 bash.compress = override_bash.compress.or(bash.compress);
1073 bash.background = override_bash.background.or(bash.background);
1074 bash.long_running_reminder_enabled = override_bash
1075 .long_running_reminder_enabled
1076 .or(bash.long_running_reminder_enabled);
1077 bash.long_running_reminder_interval_ms = override_bash
1078 .long_running_reminder_interval_ms
1079 .or(bash.long_running_reminder_interval_ms);
1080
1081 bash.has_any_value().then_some(bash)
1082}
1083
1084fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
1085 match (base, override_bash) {
1086 (None, None) => None,
1087 (None, Some(override_bash)) => Some(override_bash),
1088 (Some(base), None) => Some(base),
1089 (Some(base), Some(override_bash)) => {
1090 let base = expand_bash_for_merge(&base);
1091 let override_features = expand_bash_for_merge(&override_bash);
1092 Some(RawBash::Features(RawBashFeatures {
1093 rewrite: override_features.rewrite.or(base.rewrite),
1094 compress: override_features.compress.or(base.compress),
1095 background: override_features.background.or(base.background),
1096 host_fallback: override_features.host_fallback.or(base.host_fallback),
1097 subagent_background: override_features
1098 .subagent_background
1099 .or(base.subagent_background),
1100 detach_on_user_message: override_features
1101 .detach_on_user_message
1102 .or(base.detach_on_user_message),
1103 long_running_reminder_enabled: override_features
1104 .long_running_reminder_enabled
1105 .or(base.long_running_reminder_enabled),
1106 long_running_reminder_interval_ms: override_features
1107 .long_running_reminder_interval_ms
1108 .or(base.long_running_reminder_interval_ms),
1109 foreground_wait_window_ms: override_features
1110 .foreground_wait_window_ms
1111 .or(base.foreground_wait_window_ms),
1112 powershell_tool: override_features.powershell_tool.or(base.powershell_tool),
1113 }))
1114 }
1115 }
1116}
1117
1118fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
1119 match value {
1120 RawBash::Bool(enabled) => RawBashFeatures {
1121 rewrite: Some(*enabled),
1122 compress: Some(*enabled),
1123 background: Some(*enabled),
1124 host_fallback: None,
1125 subagent_background: None,
1126 detach_on_user_message: None,
1127 long_running_reminder_enabled: None,
1128 long_running_reminder_interval_ms: None,
1129 foreground_wait_window_ms: None,
1130 powershell_tool: None,
1131 },
1132 RawBash::Features(features) => features.clone(),
1133 }
1134}
1135
1136fn merge_inspect_config(
1137 base: Option<RawInspect>,
1138 override_inspect: Option<RawInspect>,
1139) -> Option<RawInspect> {
1140 let Some(override_inspect) = override_inspect else {
1141 return base;
1142 };
1143
1144 let mut inspect = base.unwrap_or_default();
1145 inspect.enabled = override_inspect.enabled.or(inspect.enabled);
1146 if let Some(project_timeout) = override_inspect.diagnostics_timeout_ms {
1147 inspect.diagnostics_timeout_ms = Some(
1150 project_timeout.max(
1151 inspect
1152 .diagnostics_timeout_ms
1153 .unwrap_or(DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS),
1154 ),
1155 );
1156 }
1157 inspect.tier2_idle_minutes = override_inspect
1158 .tier2_idle_minutes
1159 .or(inspect.tier2_idle_minutes);
1160 inspect.categories = override_inspect.categories.or(inspect.categories);
1161 inspect.tier2_soft_deadline_ms = override_inspect
1162 .tier2_soft_deadline_ms
1163 .or(inspect.tier2_soft_deadline_ms);
1164 inspect.max_drill_down_items = override_inspect
1165 .max_drill_down_items
1166 .or(inspect.max_drill_down_items);
1167 inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
1168
1169 (!inspect.is_empty()).then_some(inspect)
1170}
1171
1172fn merge_worktree_config(
1173 base: Option<RawWorktree>,
1174 override_worktree: Option<RawWorktree>,
1175) -> Option<RawWorktree> {
1176 let Some(override_worktree) = override_worktree else {
1177 return base;
1178 };
1179
1180 let mut worktree = base.unwrap_or_default();
1181 worktree.ram_overlay = override_worktree.ram_overlay.or(worktree.ram_overlay);
1182 (!worktree.is_empty()).then_some(worktree)
1183}
1184
1185fn merge_inspect_duplicates(
1186 base: Option<RawInspectDuplicates>,
1187 override_duplicates: Option<RawInspectDuplicates>,
1188) -> Option<RawInspectDuplicates> {
1189 let Some(override_duplicates) = override_duplicates else {
1190 return base;
1191 };
1192
1193 let mut duplicates = base.unwrap_or_default();
1194 duplicates.expected_mirrors = override_duplicates
1195 .expected_mirrors
1196 .or(duplicates.expected_mirrors);
1197
1198 (!duplicates.is_empty()).then_some(duplicates)
1199}
1200
1201fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
1202 if raw.restrict_to_project_root.is_some() {
1203 push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
1204 }
1205 if raw.url_fetch_allow_private.is_some() {
1206 push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
1207 }
1208 if raw.formatter_timeout_secs.is_some() {
1209 push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
1210 }
1211 if raw.type_checker_timeout_secs.is_some() {
1212 push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
1213 }
1214 if raw.auto_update.is_some() {
1215 push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
1216 }
1217 if raw.bridge.is_some() {
1218 push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
1219 }
1220 if raw.subc.is_some() {
1221 push_drop(dropped, "subc", tier, USER_ONLY_REASON);
1222 }
1223 if raw.backup.is_some() {
1224 push_drop(dropped, "backup", tier, USER_ONLY_REASON);
1225 }
1226 if raw.gh_shim.is_some() {
1227 push_drop(dropped, "gh_shim", tier, USER_ONLY_REASON);
1228 }
1229 if raw.gh_read.is_some() {
1230 push_drop(dropped, "gh_read", tier, USER_ONLY_REASON);
1231 }
1232 if raw
1233 .index
1234 .as_ref()
1235 .and_then(|index| index.roots.as_ref())
1236 .is_some()
1237 {
1238 push_drop(dropped, "index.roots", tier, USER_ONLY_REASON);
1239 }
1240 if let Some(sandbox) = &raw.sandbox {
1241 if sandbox.enabled == Some(false) {
1244 push_drop(dropped, "sandbox.enabled", tier, USER_ONLY_REASON);
1245 }
1246 if sandbox.write_allow.is_some() {
1247 push_drop(dropped, "sandbox.write_allow", tier, USER_ONLY_REASON);
1248 }
1249 }
1250 if raw
1251 .disabled_tools
1252 .as_ref()
1253 .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
1254 {
1255 push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
1256 }
1257
1258 if let Some(semantic) = &raw.semantic {
1259 if semantic.backend.is_some() {
1260 push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
1261 }
1262 if semantic.base_url.is_some() {
1263 push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
1264 }
1265 if semantic.api_key_env.is_some() {
1266 push_drop(
1267 dropped,
1268 "semantic.api_key_env",
1269 tier,
1270 SEMANTIC_SECRET_REASON,
1271 );
1272 }
1273 if semantic.query_timeout_ms.is_some() {
1274 push_drop(dropped, "semantic.query_timeout_ms", tier, USER_ONLY_REASON);
1275 }
1276 }
1277
1278 if let Some(lsp) = &raw.lsp {
1279 if lsp.servers.is_some() {
1280 push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
1281 }
1282 if lsp.versions.is_some() {
1283 push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
1284 }
1285 if lsp.auto_install.is_some() {
1286 push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
1287 }
1288 if lsp.grace_days.is_some() {
1289 push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
1290 }
1291 if lsp.disabled.is_some() {
1292 push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
1293 }
1294 }
1295}
1296
1297fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
1298 dropped.push(DroppedKey {
1299 key: key.to_string(),
1300 tier: tier.to_string(),
1301 reason: reason.to_string(),
1302 });
1303}
1304
1305fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
1310 config.hashline_enabled = matches!(raw.edit_mode, Some(RawEditMode::Hashline));
1311 if let Some(value) = raw.hoist_builtin_tools {
1312 config.hoist_builtin_tools = value;
1313 }
1314 if let Some(value) = raw.format_on_edit {
1315 config.format_on_edit = value;
1316 }
1317 if let Some(value) = raw.formatter_timeout_secs {
1318 config.formatter_timeout_secs = value;
1319 }
1320 if let Some(value) = raw.type_checker_timeout_secs {
1321 config.type_checker_timeout_secs = value;
1322 }
1323 if let Some(value) = raw.validate_on_edit {
1324 config.validate_on_edit = Some(value.as_str().to_string());
1325 }
1326 if let Some(formatter) = &raw.formatter {
1327 config.formatter = formatter
1328 .iter()
1329 .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
1330 .collect();
1331 }
1332 if let Some(checker) = &raw.checker {
1333 config.checker = checker
1334 .iter()
1335 .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
1336 .collect();
1337 }
1338 if let Some(value) = raw.restrict_to_project_root {
1339 config.restrict_to_project_root = value;
1340 }
1341 if let Some(value) = raw.search_index {
1342 config.search_index = value;
1343 }
1344 if let Some(value) = raw.semantic_search {
1345 config.semantic_search = value;
1346 }
1347 if let Some(value) = raw.callgraph_store {
1348 config.callgraph_store = value;
1349 }
1350 if let Some(value) = raw.callgraph_chunk_size {
1351 config.callgraph_chunk_size = value;
1352 }
1353 if let Some(value) = raw.url_fetch_allow_private {
1354 config.url_fetch_allow_private = value;
1355 }
1356 config.semantic = resolve_semantic_config(raw.semantic.as_ref(), raw.subc.as_ref());
1357 config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1358 config.backup = resolve_backup_config(raw.backup.as_ref());
1359 config.worktree = resolve_worktree_config(raw.worktree.as_ref());
1360 config.gh_shim = resolve_gh_shim_config(raw.gh_shim.as_ref());
1361 config.gh_read = resolve_gh_read_config(raw.gh_read.as_ref());
1362 config.git = resolve_git_config(raw.git.as_ref());
1363 config.sandbox = resolve_sandbox_config(raw.sandbox.as_ref());
1364 resolve_lsp_config(raw, config);
1365 resolve_bash_fields(raw, config);
1366}
1367
1368fn resolve_index_config(raw: Option<&RawIndex>, warnings: &mut Vec<ConfigWarning>) -> IndexConfig {
1369 let Some(raw) = raw else {
1370 return IndexConfig::default();
1371 };
1372 let Some(roots) = raw.roots.as_ref() else {
1373 return IndexConfig::default();
1374 };
1375
1376 let home = std::env::var_os("HOME")
1377 .or_else(|| std::env::var_os("USERPROFILE"))
1378 .map(PathBuf::from);
1379 let mut normalized_roots = Vec::with_capacity(roots.len());
1380
1381 for (position, root) in roots.iter().enumerate() {
1382 for field in root.unknown.keys() {
1383 warnings.push(ConfigWarning {
1384 code: "unknown_index_root_field",
1385 key: "index.roots",
1386 tier: "user".to_string(),
1387 value: field.clone(),
1388 message: format!(
1389 "Ignoring unknown field index.roots[{position}].{field}; only path and indexes are defined"
1390 ),
1391 });
1392 }
1393
1394 let result = (|| -> Result<IndexRootConfig, String> {
1395 let path = root.path.as_deref().ok_or_else(|| {
1396 "index.roots entry is missing required string field path".to_string()
1397 })?;
1398 expand_index_root_path(path, home.as_deref())?;
1399
1400 let indexes = root.indexes.as_ref().ok_or_else(|| {
1401 "index.roots entry is missing required non-empty indexes array".to_string()
1402 })?;
1403 if indexes.is_empty() {
1404 return Err("index.roots indexes must be a non-empty array".to_string());
1405 }
1406
1407 let mut normalized = Vec::with_capacity(indexes.len() + 1);
1408 for name in indexes {
1409 let kind = IndexKind::from_name(name).ok_or_else(|| {
1410 format!(
1411 "index.roots indexes contains unknown name {name:?}; valid names: search, semantic, callgraph"
1412 )
1413 })?;
1414 if normalized.contains(&kind) {
1415 return Err(format!(
1416 "index.roots indexes contains duplicate name {name:?}"
1417 ));
1418 }
1419 normalized.push(kind);
1420 }
1421 if normalized.contains(&IndexKind::Semantic) && !normalized.contains(&IndexKind::Search)
1422 {
1423 normalized.push(IndexKind::Search);
1424 warnings.push(ConfigWarning {
1425 code: "index_dependency_closure",
1426 key: "index.roots",
1427 tier: "user".to_string(),
1428 value: path.to_string(),
1429 message: format!(
1430 "Added search to index.roots[{position}].indexes because semantic depends on search"
1431 ),
1432 });
1433 }
1434 normalized.sort_unstable();
1435 Ok(IndexRootConfig {
1436 path: path.to_string(),
1437 indexes: normalized,
1438 })
1439 })();
1440
1441 match result {
1442 Ok(root) => normalized_roots.push(root),
1443 Err(message) => {
1444 warnings.push(ConfigWarning {
1445 code: "invalid_index_roots",
1446 key: "index.roots",
1447 tier: "user".to_string(),
1448 value: position.to_string(),
1449 message,
1450 });
1451 return IndexConfig::default();
1452 }
1453 }
1454 }
1455
1456 IndexConfig {
1457 roots: normalized_roots,
1458 }
1459}
1460
1461fn resolve_semantic_config(
1462 raw: Option<&RawSemantic>,
1463 subc: Option<&RawSubc>,
1464) -> SemanticBackendConfig {
1465 let mut semantic = SemanticBackendConfig::default();
1466 semantic.subc_connection_file = subc
1467 .and_then(|subc| subc.connection_file.as_deref())
1468 .map(str::trim)
1469 .filter(|path| !path.is_empty())
1470 .map(PathBuf::from);
1471 let Some(raw) = raw else {
1472 return semantic;
1473 };
1474
1475 if let Some(value) = raw.backend {
1476 semantic.backend = value;
1477 if value == SemanticBackend::Synapse && raw.model.is_none() {
1478 semantic.model.clear();
1481 }
1482 }
1483 if let Some(value) = &raw.model {
1484 semantic.model = value.clone();
1485 }
1486 if let Some(value) = &raw.base_url {
1487 semantic.base_url = Some(value.clone());
1488 }
1489 if let Some(value) = &raw.api_key_env {
1490 semantic.api_key_env = Some(value.clone());
1491 }
1492 if let Some(value) = raw.timeout_ms {
1493 semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1494 }
1495 if let Some(value) = raw.query_timeout_ms {
1496 semantic.query_timeout_ms =
1497 value.clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS);
1498 }
1499 if let Some(value) = raw.max_batch_size {
1500 semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1501 }
1502 if let Some(value) = raw.max_files {
1503 semantic.max_files = value;
1504 }
1505
1506 semantic
1507}
1508
1509fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1510 let mut inspect = InspectConfig::default();
1511 let Some(raw) = raw else {
1512 return inspect;
1513 };
1514 if let Some(enabled) = raw.enabled {
1515 inspect.enabled = enabled;
1516 }
1517 if let Some(value) = raw.diagnostics_timeout_ms {
1518 inspect.diagnostics_timeout_ms = value.clamp(
1519 MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1520 MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1521 );
1522 }
1523 if let Some(expected_mirrors) = raw
1524 .duplicates
1525 .as_ref()
1526 .and_then(|duplicates| duplicates.expected_mirrors.clone())
1527 {
1528 inspect.duplicates.expected_mirrors = expected_mirrors;
1529 }
1530 inspect
1531}
1532
1533fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1534 let mut backup = BackupConfig::default();
1535 if let Some(raw) = raw {
1536 if raw.enabled.is_some() {
1537 backup.enabled = raw.enabled;
1538 }
1539 if raw.max_depth.is_some() {
1540 backup.max_depth = raw.max_depth;
1541 }
1542 if raw.max_file_size.is_some() {
1543 backup.max_file_size = raw.max_file_size;
1544 }
1545 }
1546 backup
1547}
1548
1549fn resolve_worktree_config(raw: Option<&RawWorktree>) -> WorktreeConfig {
1550 let mut worktree = WorktreeConfig::default();
1551 if let Some(value) = raw.and_then(|raw| raw.ram_overlay) {
1552 worktree.ram_overlay = value;
1553 }
1554 worktree
1555}
1556
1557fn resolve_gh_shim_config(raw: Option<&RawGhShim>) -> GhShimConfig {
1558 let mut gh_shim = GhShimConfig::default();
1559 if let Some(value) = raw.and_then(|raw| raw.enabled) {
1560 gh_shim.enabled = value;
1561 }
1562 gh_shim.binary_path = raw
1563 .and_then(|raw| raw.binary_path.as_ref())
1564 .map(PathBuf::from);
1565 gh_shim
1566}
1567
1568fn resolve_gh_read_config(raw: Option<&RawGhRead>) -> crate::config::GhReadConfig {
1569 let mut gh_read = crate::config::GhReadConfig::default();
1570 if let Some(value) = raw.and_then(|raw| raw.enabled) {
1571 gh_read.enabled = value;
1572 }
1573 gh_read
1574}
1575
1576fn resolve_git_config(raw: Option<&RawGit>) -> GitConfig {
1577 GitConfig {
1578 co_author: raw
1579 .and_then(|raw| raw.co_author.clone())
1580 .unwrap_or_else(|| "off".to_string()),
1581 }
1582}
1583
1584fn resolve_sandbox_config(raw: Option<&RawSandbox>) -> SandboxConfig {
1585 let Some(raw) = raw else {
1586 return SandboxConfig::default();
1587 };
1588 SandboxConfig {
1589 enabled: raw.enabled.unwrap_or(false),
1590 write_allow: raw.write_allow.clone().unwrap_or_default(),
1591 read_deny: raw.read_deny.clone().unwrap_or_default(),
1592 }
1593}
1594
1595fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1596 let lsp = raw.lsp.as_ref();
1597 let mut disabled: HashSet<String> = lsp
1598 .and_then(|lsp| lsp.disabled.as_ref())
1599 .into_iter()
1600 .flatten()
1601 .map(|value| value.to_ascii_lowercase())
1602 .collect();
1603 let mut experimental_ty = raw
1604 .experimental
1605 .as_ref()
1606 .and_then(|experimental| experimental.lsp_ty);
1607
1608 match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1609 RawPythonLsp::Ty => {
1610 experimental_ty = Some(true);
1611 disabled.insert("python".to_string());
1612 }
1613 RawPythonLsp::Pyright => {
1614 experimental_ty = Some(false);
1615 disabled.insert("ty".to_string());
1616 }
1617 RawPythonLsp::Auto => {}
1618 }
1619
1620 if let Some(value) = experimental_ty {
1621 config.experimental_lsp_ty = value;
1622 }
1623
1624 if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1625 config.diagnostics_on_edit = value;
1626 }
1627
1628 if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1629 config.lsp_servers = servers
1630 .iter()
1631 .map(|(id, server)| UserServerDef {
1632 id: id.clone(),
1633 extensions: server
1634 .extensions
1635 .clone()
1636 .unwrap_or_default()
1637 .into_iter()
1638 .map(|extension| extension.trim_start_matches('.').to_string())
1639 .collect(),
1640 binary: server.binary.clone().unwrap_or_default(),
1641 args: server.args.clone().unwrap_or_default(),
1642 root_markers: server
1643 .root_markers
1644 .clone()
1645 .unwrap_or_else(|| vec![".git".to_string()]),
1646 env: server.env.clone().unwrap_or_default(),
1647 initialization_options: server.initialization_options.clone(),
1648 disabled: server.disabled.unwrap_or(false),
1649 })
1650 .collect();
1651 }
1652
1653 if !disabled.is_empty() {
1654 config.disabled_lsp = disabled;
1655 }
1656}
1657
1658#[derive(Debug, Clone, PartialEq, Eq)]
1659struct ResolvedBashConfig {
1660 enabled: bool,
1661 rewrite: bool,
1662 compress: bool,
1663 background: bool,
1664 host_fallback: bool,
1665 subagent_background: bool,
1666 detach_on_user_message: bool,
1667 long_running_reminder_enabled: Option<bool>,
1668 long_running_reminder_interval_ms: Option<u64>,
1669 foreground_wait_window_ms: u64,
1670 powershell_tool: bool,
1671}
1672
1673fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config) {
1674 let bash = resolve_bash_config(raw);
1675 let _registration_only = (bash.enabled, bash.subagent_background);
1679 config.bash.host_fallback = bash.host_fallback;
1680 config.bash.detach_on_user_message = bash.detach_on_user_message;
1681 config.bash.powershell_tool = bash.powershell_tool;
1682 config.experimental_bash_rewrite = bash.rewrite;
1683 config.experimental_bash_compress = bash.compress;
1684 config.experimental_bash_background = bash.background;
1685 config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
1686 if let Some(value) = bash.long_running_reminder_enabled {
1687 config.bash_long_running_reminder_enabled = value;
1688 }
1689 if let Some(value) = bash.long_running_reminder_interval_ms {
1690 config.bash_long_running_reminder_interval_ms = value;
1691 }
1692}
1693
1694fn resolve_bash_config(raw: &RawAftConfig) -> ResolvedBashConfig {
1695 let top = raw.bash.as_ref();
1696 let legacy = raw
1697 .experimental
1698 .as_ref()
1699 .and_then(|experimental| experimental.bash.as_ref());
1700 let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
1701 let surface_default_enabled = surface != RawToolSurface::Minimal;
1702
1703 let top_features = match top {
1704 Some(RawBash::Features(features)) => Some(features),
1705 _ => None,
1706 };
1707 let reminder_enabled = top_features
1708 .and_then(|features| features.long_running_reminder_enabled)
1709 .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
1710 let reminder_interval = top_features
1711 .and_then(|features| features.long_running_reminder_interval_ms)
1712 .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
1713 let top_host_fallback = top_features
1714 .and_then(|features| features.host_fallback)
1715 .unwrap_or(false);
1716 let top_subagent_background = top_features
1717 .and_then(|features| features.subagent_background)
1718 .unwrap_or(false);
1719 let top_detach_on_user_message = top_features
1720 .and_then(|features| features.detach_on_user_message)
1721 .unwrap_or(true);
1722 let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
1723 let top_powershell_tool = top_features
1724 .and_then(|features| features.powershell_tool)
1725 .unwrap_or(false);
1726 let foreground_wait_window_ms = raw_foreground_wait
1727 .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
1728 .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
1729
1730 let base = ResolvedBashConfig {
1731 enabled: false,
1732 rewrite: false,
1733 compress: false,
1734 background: false,
1735 host_fallback: false,
1736 subagent_background: false,
1737 detach_on_user_message: true,
1738 long_running_reminder_enabled: reminder_enabled,
1739 long_running_reminder_interval_ms: reminder_interval,
1740 foreground_wait_window_ms,
1741 powershell_tool: false,
1742 };
1743
1744 match top {
1745 Some(RawBash::Bool(false)) => base,
1746 Some(RawBash::Bool(true)) => ResolvedBashConfig {
1747 enabled: true,
1748 rewrite: true,
1749 compress: true,
1750 background: true,
1751 ..base
1752 },
1753 Some(RawBash::Features(features)) => ResolvedBashConfig {
1754 enabled: true,
1755 rewrite: features.rewrite.unwrap_or(true),
1756 compress: features.compress.unwrap_or(true),
1757 background: features.background.unwrap_or(true),
1758 host_fallback: top_host_fallback,
1759 subagent_background: top_subagent_background,
1760 detach_on_user_message: top_detach_on_user_message,
1761 powershell_tool: top_powershell_tool,
1762 ..base
1763 },
1764 None => {
1765 if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
1766 let legacy = legacy.cloned().unwrap_or_default();
1767 let rewrite = legacy.rewrite == Some(true);
1768 let compress = legacy.compress == Some(true);
1769 let background = legacy.background == Some(true);
1770 return ResolvedBashConfig {
1771 enabled: rewrite || compress || background,
1772 rewrite,
1773 compress,
1774 background,
1775 ..base
1776 };
1777 }
1778
1779 ResolvedBashConfig {
1780 enabled: surface_default_enabled,
1781 rewrite: surface_default_enabled,
1782 compress: surface_default_enabled,
1783 background: surface_default_enabled,
1784 ..base
1785 }
1786 }
1787 }
1788}
1789
1790fn deserialize_opt_git_co_author<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
1791where
1792 D: Deserializer<'de>,
1793{
1794 let value = Option::<String>::deserialize(deserializer)?;
1795 value
1796 .map(|value| {
1797 normalize_git_co_author(&value).ok_or_else(|| {
1798 de::Error::custom(
1799 "git.co_author must be 'off', 'auto', or an explicit 'Name <email>' identity",
1800 )
1801 })
1802 })
1803 .transpose()
1804}
1805
1806fn deserialize_opt_trimmed_non_empty_string<'de, D>(
1807 deserializer: D,
1808) -> Result<Option<String>, D::Error>
1809where
1810 D: Deserializer<'de>,
1811{
1812 let value = Option::<String>::deserialize(deserializer)?;
1813 value
1814 .map(|value| {
1815 let trimmed = value.trim().to_string();
1816 if trimmed.is_empty() {
1817 Err(de::Error::custom("must be a non-empty string"))
1818 } else {
1819 Ok(trimmed)
1820 }
1821 })
1822 .transpose()
1823}
1824
1825fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
1826 deserializer: D,
1827) -> Result<Option<Vec<String>>, D::Error>
1828where
1829 D: Deserializer<'de>,
1830{
1831 let value = Option::<Vec<String>>::deserialize(deserializer)?;
1832 value
1833 .map(|values| {
1834 values
1835 .into_iter()
1836 .map(|value| {
1837 let trimmed = value.trim().to_string();
1838 if trimmed.is_empty() {
1839 Err(de::Error::custom("array entries must be non-empty strings"))
1840 } else {
1841 Ok(trimmed)
1842 }
1843 })
1844 .collect()
1845 })
1846 .transpose()
1847}
1848
1849fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1850where
1851 D: Deserializer<'de>,
1852{
1853 let value = Option::<Vec<String>>::deserialize(deserializer)?;
1854 value
1855 .map(|values| {
1856 if values.is_empty() {
1857 return Err(de::Error::custom(
1858 "extensions must contain at least one entry",
1859 ));
1860 }
1861 values
1862 .into_iter()
1863 .map(|value| {
1864 let trimmed = value.trim().to_string();
1865 if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
1866 Err(de::Error::custom(
1867 "extension must include characters other than leading dots",
1868 ))
1869 } else {
1870 Ok(trimmed)
1871 }
1872 })
1873 .collect()
1874 })
1875 .transpose()
1876}
1877
1878fn deserialize_opt_lsp_servers<'de, D>(
1879 deserializer: D,
1880) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
1881where
1882 D: Deserializer<'de>,
1883{
1884 let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
1885 value
1886 .map(|entries| {
1887 entries
1888 .into_iter()
1889 .map(|(key, value)| {
1890 let trimmed = key.trim().to_string();
1891 if trimmed.is_empty() {
1892 Err(de::Error::custom(
1893 "lsp.servers keys must be non-empty strings",
1894 ))
1895 } else {
1896 Ok((trimmed, value))
1897 }
1898 })
1899 .collect()
1900 })
1901 .transpose()
1902}
1903
1904fn deserialize_opt_versions_map<'de, D>(
1905 deserializer: D,
1906) -> Result<Option<HashMap<String, String>>, D::Error>
1907where
1908 D: Deserializer<'de>,
1909{
1910 let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
1911 value
1912 .map(|entries| {
1913 entries
1914 .into_iter()
1915 .map(|(key, value)| {
1916 let trimmed_key = key.trim().to_string();
1917 let trimmed_value = value.trim().to_string();
1918 if trimmed_key.is_empty() || trimmed_value.is_empty() {
1919 Err(de::Error::custom(
1920 "lsp.versions keys and values must be non-empty strings",
1921 ))
1922 } else {
1923 Ok((trimmed_key, trimmed_value))
1924 }
1925 })
1926 .collect()
1927 })
1928 .transpose()
1929}
1930
1931fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1932where
1933 D: Deserializer<'de>,
1934{
1935 let value = Option::<u64>::deserialize(deserializer)?;
1936 value
1937 .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1938 .transpose()
1939}
1940
1941fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1942where
1943 D: Deserializer<'de>,
1944{
1945 let value = Option::<u64>::deserialize(deserializer)?;
1946 match value {
1947 Some(0) => Err(de::Error::custom("must be a positive integer")),
1948 other => Ok(other),
1949 }
1950}
1951
1952fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1953where
1954 D: Deserializer<'de>,
1955{
1956 let value = deserialize_opt_positive_u64(deserializer)?;
1957 value
1958 .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1959 .transpose()
1960}
1961
1962fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1963where
1964 D: Deserializer<'de>,
1965{
1966 let value = Option::<u64>::deserialize(deserializer)?;
1967 match value {
1968 Some(value) if !(1..=600).contains(&value) => {
1969 Err(de::Error::custom("timeout must be in 1..=600 seconds"))
1970 }
1971 Some(value) => u32::try_from(value)
1972 .map(Some)
1973 .map_err(|_| de::Error::custom("timeout is too large")),
1974 None => Ok(None),
1975 }
1976}
1977
1978fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
1979 deserializer: D,
1980) -> Result<Option<u64>, D::Error>
1981where
1982 D: Deserializer<'de>,
1983{
1984 let value = Option::<u64>::deserialize(deserializer)?;
1985 match value {
1986 Some(value) if value < 1_000 => Err(de::Error::custom(
1987 "bridge.request_timeout_ms must be at least 1000",
1988 )),
1989 other => Ok(other),
1990 }
1991}
1992
1993fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1994where
1995 D: Deserializer<'de>,
1996{
1997 let value = Option::<f64>::deserialize(deserializer)?;
1998 match value {
1999 Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
2000 other => Ok(other),
2001 }
2002}
2003
2004fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
2005where
2006 D: Deserializer<'de>,
2007{
2008 let value = Option::<u64>::deserialize(deserializer)?;
2009 match value {
2010 Some(value) if value == 0 || value > 100 => {
2011 Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
2012 }
2013 Some(value) => usize::try_from(value)
2014 .map(Some)
2015 .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
2016 None => Ok(None),
2017 }
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022 use super::*;
2023
2024 fn tier(tier: &str, doc: &str) -> ConfigTier {
2025 ConfigTier {
2026 tier: tier.to_string(),
2027 source: format!("/tmp/{tier}/aft.jsonc"),
2028 doc: doc.to_string(),
2029 }
2030 }
2031
2032 fn drop_keys(result: &ResolveResult) -> Vec<String> {
2033 result
2034 .dropped
2035 .iter()
2036 .map(|dropped| dropped.key.clone())
2037 .collect()
2038 }
2039
2040 #[test]
2047 fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
2048 let nested = resolve_config(&[tier(
2054 "user",
2055 r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
2056 )]);
2057 assert!(nested.config.experimental_bash_rewrite);
2058 assert!(nested.config.experimental_bash_compress);
2059 assert!(nested.config.experimental_bash_background);
2060
2061 let smuggle = resolve_config(&[
2065 tier("user", r#"{ "search_index": true }"#),
2066 tier(
2067 "project",
2068 r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
2069 ),
2070 ]);
2071 assert!(!smuggle.config.search_index);
2073 assert!(smuggle.config.storage_dir.is_none());
2075 assert!(!smuggle.config.bash_permissions);
2076 }
2077
2078 #[test]
2079 fn config_resolve_empty_tiers_applies_bash_surface_default() {
2080 let result = resolve_config(&[]);
2085 let default_config = Config::default();
2086
2087 assert!(result.dropped.is_empty());
2088 assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
2090 assert_eq!(result.config.search_index, default_config.search_index);
2091 assert_eq!(
2092 result.config.semantic_search,
2093 default_config.semantic_search
2094 );
2095 assert_eq!(result.config.semantic, default_config.semantic);
2096 assert_eq!(
2097 result.config.inspect.enabled,
2098 default_config.inspect.enabled
2099 );
2100 assert_eq!(result.config.lsp_servers.len(), 0);
2101 assert!(result.config.experimental_bash_rewrite);
2103 assert!(result.config.experimental_bash_compress);
2104 assert!(result.config.experimental_bash_background);
2105 }
2106
2107 #[test]
2108 fn config_resolve_user_only_config_applies_fields() {
2109 let result = resolve_config(&[tier(
2110 "user",
2111 r#"{
2112 "$schema": "https://example.test/aft.schema.json",
2113 "format_on_edit": false,
2114 "formatter_timeout_secs": 42,
2115 "type_checker_timeout_secs": 43,
2116 "validate_on_edit": "full",
2117 "formatter": { "rust": "rustfmt", "typescript": "prettier" },
2118 "checker": { "rust": "cargo", "typescript": "tsc" },
2119 "restrict_to_project_root": true,
2120 "search_index": true,
2121 "semantic_search": true,
2122 "callgraph_store": false,
2123 "callgraph_chunk_size": 17,
2124 "url_fetch_allow_private": true,
2125 "semantic": {
2126 "backend": "openai_compatible",
2127 "model": " user-model ",
2128 "base_url": "https://semantic.example.test",
2129 "api_key_env": "AFT_API_KEY",
2130 "timeout_ms": 12345,
2131 "query_timeout_ms": 2345,
2132 "max_batch_size": 12,
2133 "max_files": 3456
2134 },
2135 "inspect": { "enabled": false, "diagnostics_timeout_ms": 15000 },
2136 "experimental": { "lsp_ty": true },
2137 "lsp": {
2138 "servers": {
2139 "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
2140 },
2141 "disabled": ["Python"],
2142 "python": "pyright"
2143 },
2144 "bash": { "rewrite": false, "compress": true, "background": false,
2145 "long_running_reminder_enabled": false,
2146 "long_running_reminder_interval_ms": 123000 }
2147 }"#,
2148 )]);
2149
2150 assert!(result.dropped.is_empty());
2151 assert!(!result.config.format_on_edit);
2152 assert_eq!(result.config.formatter_timeout_secs, 42);
2153 assert_eq!(result.config.type_checker_timeout_secs, 43);
2154 assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
2155 assert_eq!(
2156 result.config.formatter.get("rust").map(String::as_str),
2157 Some("rustfmt")
2158 );
2159 assert_eq!(
2160 result.config.checker.get("typescript").map(String::as_str),
2161 Some("tsc")
2162 );
2163 assert!(result.config.restrict_to_project_root);
2164 assert!(result.config.search_index);
2165 assert!(result.config.semantic_search);
2166 assert!(!result.config.callgraph_store);
2167 assert_eq!(result.config.callgraph_chunk_size, 17);
2168 assert!(result.config.url_fetch_allow_private);
2169 assert_eq!(
2170 result.config.semantic.backend,
2171 SemanticBackend::OpenAiCompatible
2172 );
2173 assert_eq!(result.config.semantic.model, "user-model");
2174 assert_eq!(
2175 result.config.semantic.base_url.as_deref(),
2176 Some("https://semantic.example.test")
2177 );
2178 assert_eq!(
2179 result.config.semantic.api_key_env.as_deref(),
2180 Some("AFT_API_KEY")
2181 );
2182 assert_eq!(result.config.semantic.timeout_ms, 12345);
2183 assert_eq!(result.config.semantic.query_timeout_ms, 2345);
2184 assert_eq!(result.config.semantic.max_batch_size, 12);
2185 assert_eq!(result.config.semantic.max_files, 3456);
2186 assert!(!result.config.inspect.enabled);
2187 assert_eq!(result.config.inspect.diagnostics_timeout_ms, 15_000);
2188 assert!(!result.config.experimental_lsp_ty);
2189 assert!(result.config.disabled_lsp.contains("ty"));
2190 assert_eq!(result.config.lsp_servers.len(), 1);
2191 assert_eq!(result.config.lsp_servers[0].id, "rust");
2192 assert_eq!(
2193 result.config.lsp_servers[0].extensions,
2194 vec!["rs".to_string()]
2195 );
2196 assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
2197 assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
2198 assert_eq!(
2199 result.config.lsp_servers[0].root_markers,
2200 vec![".git".to_string()]
2201 );
2202 assert!(!result.config.experimental_bash_rewrite);
2203 assert!(result.config.experimental_bash_compress);
2204 assert!(!result.config.experimental_bash_background);
2205 assert!(!result.config.bash_long_running_reminder_enabled);
2206 assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
2207 }
2208
2209 #[test]
2210 fn edit_mode_resolves_at_user_and_project_tiers_with_project_precedence() {
2211 let hashline = resolve_config(&[
2212 tier("user", r#"{"edit_mode":"default"}"#),
2213 tier("project", r#"{"edit_mode":"hashline"}"#),
2214 ]);
2215 assert!(hashline.config.hashline_enabled);
2216 assert!(hashline.dropped.is_empty());
2217
2218 let default = resolve_config(&[
2219 tier("user", r#"{"edit_mode":"hashline"}"#),
2220 tier("project", r#"{"edit_mode":"default"}"#),
2221 ]);
2222 assert!(!default.config.hashline_enabled);
2223 assert!(default.dropped.is_empty());
2224 }
2225
2226 #[test]
2227 fn harness_overrides_select_the_active_harness_and_preserve_tier_order() {
2228 let tiers = [
2229 tier(
2230 "user",
2231 r#"{
2232 "hoist_builtin_tools": false,
2233 "harnesses": {
2234 "opencode": { "hoist_builtin_tools": true },
2235 "pi": { "hoist_builtin_tools": false }
2236 }
2237 }"#,
2238 ),
2239 tier(
2240 "project",
2241 r#"{
2242 "hoist_builtin_tools": false,
2243 "harnesses": { "opencode": { "hoist_builtin_tools": true } }
2244 }"#,
2245 ),
2246 ];
2247
2248 let opencode = resolve_config_for_harness(&tiers, Some(&Harness::Opencode));
2249 let pi = resolve_config_for_harness(&tiers, Some(&Harness::Pi));
2250
2251 assert!(opencode.config.hoist_builtin_tools);
2252 assert!(!pi.config.hoist_builtin_tools);
2253 }
2254
2255 #[test]
2256 fn project_harness_overrides_are_filtered_at_the_existing_trust_boundary() {
2257 let result = resolve_config_for_harness(
2258 &[
2259 tier(
2260 "user",
2261 r#"{
2262 "restrict_to_project_root": true,
2263 "semantic": {
2264 "backend": "ollama",
2265 "base_url": "http://localhost:11434",
2266 "api_key_env": "USER_KEY"
2267 },
2268 "sandbox": { "enabled": true, "write_allow": ["/user/write"] }
2269 }"#,
2270 ),
2271 tier(
2272 "project",
2273 r#"{
2274 "harnesses": {
2275 "opencode": {
2276 "edit_mode": "hashline",
2277 "restrict_to_project_root": false,
2278 "semantic": {
2279 "backend": "openai_compatible",
2280 "base_url": "https://evil.example.test",
2281 "api_key_env": "EVIL_KEY"
2282 },
2283 "subc": { "connection_file": "/tmp/evil-subc.json" },
2284 "sandbox": { "enabled": false, "write_allow": ["/project/write"] }
2285 }
2286 }
2287 }"#,
2288 ),
2289 ],
2290 Some(&Harness::Opencode),
2291 );
2292
2293 assert!(result.config.hashline_enabled);
2294 assert!(result.config.restrict_to_project_root);
2295 assert_eq!(result.config.semantic.backend, SemanticBackend::Ollama);
2296 assert_eq!(
2297 result.config.semantic.api_key_env.as_deref(),
2298 Some("USER_KEY")
2299 );
2300 assert!(result.config.sandbox.enabled);
2301 assert_eq!(
2302 result.config.sandbox.write_allow,
2303 vec![PathBuf::from("/user/write")]
2304 );
2305 let keys = drop_keys(&result);
2306 for key in [
2307 "restrict_to_project_root",
2308 "semantic.backend",
2309 "semantic.base_url",
2310 "semantic.api_key_env",
2311 "subc",
2312 "sandbox.enabled",
2313 "sandbox.write_allow",
2314 ] {
2315 assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
2316 }
2317 }
2318
2319 #[test]
2320 fn gh_read_is_user_only_and_records_project_drops() {
2321 let remains_disabled = resolve_config(&[
2322 tier("user", r#"{"gh_read":{"enabled":false}}"#),
2323 tier("project", r#"{"gh_read":{"enabled":true}}"#),
2324 ]);
2325 assert!(!remains_disabled.config.gh_read.enabled);
2326 assert_eq!(drop_keys(&remains_disabled), vec!["gh_read"]);
2327 assert_eq!(remains_disabled.dropped[0].tier, "project");
2328 assert_eq!(remains_disabled.dropped[0].reason, USER_ONLY_REASON);
2329
2330 let remains_enabled = resolve_config(&[
2331 tier("user", r#"{"gh_read":{"enabled":true}}"#),
2332 tier("project", r#"{"gh_read":{"enabled":false}}"#),
2333 ]);
2334 assert!(remains_enabled.config.gh_read.enabled);
2335 assert_eq!(drop_keys(&remains_enabled), vec!["gh_read"]);
2336 }
2337
2338 #[test]
2339 fn git_co_author_accepts_project_precedence_and_rejects_invalid_identities() {
2340 let resolved = resolve_config(&[
2341 tier("user", r#"{"git":{"co_author":"auto"}}"#),
2342 tier(
2343 "project",
2344 r#"{"git":{"co_author":"Pair Agent <pair@example.test>"}}"#,
2345 ),
2346 ]);
2347 assert_eq!(
2348 resolved.config.git.co_author,
2349 "Pair Agent <pair@example.test>"
2350 );
2351 assert!(resolved.dropped.is_empty());
2352
2353 let invalid = resolve_config(&[tier(
2354 "user",
2355 r#"{"git":{"co_author":"not-an-identity"},"search_index":true}"#,
2356 )]);
2357 assert_eq!(invalid.config.git.co_author, "off");
2358 assert!(invalid.config.search_index);
2359 }
2360
2361 #[test]
2362 fn unknown_edit_mode_warns_and_falls_back_without_dropping_valid_keys() {
2363 let result = resolve_config(&[tier(
2364 "project",
2365 r#"{"edit_mode":"future","format_on_edit":true}"#,
2366 )]);
2367
2368 assert!(!result.config.hashline_enabled);
2369 assert!(result.config.format_on_edit);
2370 assert_eq!(result.warnings.len(), 1);
2371 assert_eq!(result.warnings[0].code, "invalid_edit_mode");
2372 assert_eq!(result.warnings[0].key, "edit_mode");
2373 assert_eq!(result.warnings[0].tier, "project");
2374 assert_eq!(result.warnings[0].value, "future");
2375 }
2376
2377 #[test]
2378 fn synapse_requires_explicit_model_and_receives_user_subc_connection_file() {
2379 let without_model = resolve_config(&[tier(
2380 "user",
2381 r#"{
2382 "semantic": { "backend": "synapse" },
2383 "subc": { "connection_file": "/tmp/subc-connection.json" }
2384 }"#,
2385 )]);
2386 assert_eq!(
2387 without_model.config.semantic.backend,
2388 SemanticBackend::Synapse
2389 );
2390 assert!(without_model.config.semantic.model.is_empty());
2391 assert_eq!(
2392 without_model.config.semantic.subc_connection_file,
2393 Some(PathBuf::from("/tmp/subc-connection.json"))
2394 );
2395
2396 let with_model = resolve_config(&[tier(
2397 "user",
2398 r#"{
2399 "semantic": { "backend": "synapse", "model": "configured-model" },
2400 "subc": { "connection_file": "/tmp/subc-connection.json" }
2401 }"#,
2402 )]);
2403 assert_eq!(with_model.config.semantic.model, "configured-model");
2404 }
2405
2406 #[test]
2407 fn semantic_query_timeout_clamps_to_interactive_budget_range() {
2408 let below_min =
2409 resolve_config(&[tier("user", r#"{ "semantic": { "query_timeout_ms": 1 } }"#)]);
2410 assert_eq!(
2411 below_min.config.semantic.query_timeout_ms,
2412 MIN_SEMANTIC_QUERY_TIMEOUT_MS
2413 );
2414
2415 let above_max = resolve_config(&[tier(
2416 "user",
2417 r#"{ "semantic": { "query_timeout_ms": 50000 } }"#,
2418 )]);
2419 assert_eq!(
2420 above_max.config.semantic.query_timeout_ms,
2421 MAX_SEMANTIC_QUERY_TIMEOUT_MS
2422 );
2423 }
2424
2425 #[test]
2426 fn inspect_diagnostics_timeout_clamps_to_blocking_phase_range() {
2427 let below_min = resolve_config(&[tier(
2428 "user",
2429 r#"{ "inspect": { "diagnostics_timeout_ms": 1 } }"#,
2430 )]);
2431 assert_eq!(
2432 below_min.config.inspect.diagnostics_timeout_ms,
2433 MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS
2434 );
2435
2436 let above_max = resolve_config(&[tier(
2437 "user",
2438 r#"{ "inspect": { "diagnostics_timeout_ms": 700000 } }"#,
2439 )]);
2440 assert_eq!(
2441 above_max.config.inspect.diagnostics_timeout_ms,
2442 MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS
2443 );
2444 }
2445
2446 #[test]
2447 fn project_inspect_diagnostics_timeout_can_raise_but_never_lower_user_value() {
2448 let lower = resolve_config(&[
2449 tier(
2450 "user",
2451 r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
2452 ),
2453 tier(
2454 "project",
2455 r#"{ "inspect": { "diagnostics_timeout_ms": 90000 } }"#,
2456 ),
2457 ]);
2458 assert_eq!(lower.config.inspect.diagnostics_timeout_ms, 180_000);
2459
2460 let higher = resolve_config(&[
2461 tier(
2462 "user",
2463 r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
2464 ),
2465 tier(
2466 "project",
2467 r#"{ "inspect": { "diagnostics_timeout_ms": 240000 } }"#,
2468 ),
2469 ]);
2470 assert_eq!(higher.config.inspect.diagnostics_timeout_ms, 240_000);
2471 }
2472
2473 #[test]
2474 fn config_resolve_project_allowed_search_index_wins() {
2475 let result = resolve_config(&[
2476 tier("user", r#"{ "search_index": false }"#),
2477 tier("project", r#"{ "search_index": true }"#),
2478 ]);
2479
2480 assert!(result.config.search_index);
2481 assert!(result.dropped.is_empty());
2482 }
2483
2484 #[test]
2485 fn worktree_ram_overlay_resolves_at_user_and_project_tiers() {
2486 assert!(!resolve_config(&[]).config.worktree.ram_overlay);
2487
2488 let user = resolve_config(&[tier("user", r#"{ "worktree": { "ram_overlay": true } }"#)]);
2489 assert!(user.config.worktree.ram_overlay);
2490 assert!(user.dropped.is_empty());
2491
2492 let project = resolve_config(&[
2493 tier("user", r#"{ "worktree": { "ram_overlay": false } }"#),
2494 tier("project", r#"{ "worktree": { "ram_overlay": true } }"#),
2495 ]);
2496 assert!(project.config.worktree.ram_overlay);
2497 assert!(project.dropped.is_empty());
2498 }
2499
2500 #[test]
2501 fn project_sandbox_can_add_read_denies_but_not_enable_or_add_writes() {
2502 let result = resolve_config(&[
2503 tier(
2504 "user",
2505 r#"{
2506 "sandbox": {
2507 "enabled": true,
2508 "write_allow": ["/user/write"],
2509 "read_deny": ["/user/secret"]
2510 }
2511 }"#,
2512 ),
2513 tier(
2514 "project",
2515 r#"{
2516 "sandbox": {
2517 "enabled": false,
2518 "write_allow": ["/project/write"],
2519 "read_deny": ["/project/secret", "/user/secret"]
2520 }
2521 }"#,
2522 ),
2523 ]);
2524
2525 assert!(result.config.sandbox.enabled);
2526 assert_eq!(
2527 result.config.sandbox.write_allow,
2528 vec![PathBuf::from("/user/write")]
2529 );
2530 assert_eq!(
2531 result.config.sandbox.read_deny,
2532 vec![
2533 PathBuf::from("/user/secret"),
2534 PathBuf::from("/project/secret")
2535 ]
2536 );
2537 assert_eq!(
2538 drop_keys(&result),
2539 vec![
2540 "sandbox.enabled".to_string(),
2541 "sandbox.write_allow".to_string()
2542 ]
2543 );
2544 }
2545
2546 #[test]
2547 fn project_can_enable_sandbox_but_never_disable_it() {
2548 let opt_in = resolve_config(&[
2550 tier("user", r#"{}"#),
2551 tier("project", r#"{ "sandbox": { "enabled": true } }"#),
2552 ]);
2553 assert!(opt_in.config.sandbox.enabled);
2554 assert!(
2555 !drop_keys(&opt_in).contains(&"sandbox.enabled".to_string()),
2556 "project enabled:true is an accepted opt-in, not a dropped key"
2557 );
2558
2559 let opt_out = resolve_config(&[
2561 tier("user", r#"{ "sandbox": { "enabled": true } }"#),
2562 tier("project", r#"{ "sandbox": { "enabled": false } }"#),
2563 ]);
2564 assert!(opt_out.config.sandbox.enabled);
2565 assert!(drop_keys(&opt_out).contains(&"sandbox.enabled".to_string()));
2566 }
2567
2568 #[test]
2569 fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
2570 let result = resolve_config(&[
2571 tier(
2572 "user",
2573 r#"{
2574 "restrict_to_project_root": true,
2575 "url_fetch_allow_private": true,
2576 "formatter_timeout_secs": 11,
2577 "type_checker_timeout_secs": 33,
2578 "auto_update": true,
2579 "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
2580 "semantic": {
2581 "backend": "openai_compatible",
2582 "base_url": "https://user.example.test",
2583 "api_key_env": "USER_KEY",
2584 "model": "user-model",
2585 "query_timeout_ms": 900
2586 },
2587 "lsp": {
2588 "servers": {
2589 "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
2590 },
2591 "disabled": ["user-disabled"],
2592 "versions": { "typescript-language-server": "1.0.0" },
2593 "auto_install": true,
2594 "grace_days": 7
2595 }
2596 }"#,
2597 ),
2598 tier(
2599 "project",
2600 r#"{
2601 "restrict_to_project_root": false,
2602 "url_fetch_allow_private": false,
2603 "formatter_timeout_secs": 22,
2604 "type_checker_timeout_secs": 44,
2605 "auto_update": false,
2606 "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
2607 "semantic": {
2608 "backend": "ollama",
2609 "base_url": "https://project.example.test",
2610 "api_key_env": "PROJECT_KEY",
2611 "model": "project-model",
2612 "timeout_ms": 2222,
2613 "query_timeout_ms": 2222
2614 },
2615 "lsp": {
2616 "servers": {
2617 "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
2618 },
2619 "disabled": ["project-disabled"],
2620 "versions": { "evil-lsp": "9.9.9" },
2621 "auto_install": false,
2622 "grace_days": 1,
2623 "python": "ty"
2624 }
2625 }"#,
2626 ),
2627 ]);
2628
2629 assert!(result.config.restrict_to_project_root);
2630 assert!(result.config.url_fetch_allow_private);
2631 assert_eq!(result.config.formatter_timeout_secs, 11);
2632 assert_eq!(result.config.type_checker_timeout_secs, 33);
2633 assert_eq!(
2634 result.config.semantic.backend,
2635 SemanticBackend::OpenAiCompatible
2636 );
2637 assert_eq!(
2638 result.config.semantic.base_url.as_deref(),
2639 Some("https://user.example.test")
2640 );
2641 assert_eq!(
2642 result.config.semantic.api_key_env.as_deref(),
2643 Some("USER_KEY")
2644 );
2645 assert_eq!(result.config.semantic.model, "project-model");
2646 assert_eq!(result.config.semantic.timeout_ms, 2222);
2647 assert_eq!(result.config.semantic.query_timeout_ms, 900);
2648 assert_eq!(result.config.lsp_servers.len(), 1);
2649 assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
2650 assert!(result.config.disabled_lsp.contains("user-disabled"));
2651 assert!(!result.config.disabled_lsp.contains("project-disabled"));
2652 assert!(result.config.disabled_lsp.contains("python"));
2653 assert!(result.config.experimental_lsp_ty);
2654
2655 let keys = drop_keys(&result);
2656 let expected = [
2657 "restrict_to_project_root",
2658 "url_fetch_allow_private",
2659 "formatter_timeout_secs",
2660 "type_checker_timeout_secs",
2661 "auto_update",
2662 "bridge",
2663 "semantic.backend",
2664 "semantic.base_url",
2665 "semantic.api_key_env",
2666 "semantic.query_timeout_ms",
2667 "lsp.servers",
2668 "lsp.versions",
2669 "lsp.auto_install",
2670 "lsp.grace_days",
2671 "lsp.disabled",
2672 ];
2673 for key in expected {
2674 assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
2675 }
2676 assert_eq!(keys.len(), expected.len());
2677 assert!(result
2678 .dropped
2679 .iter()
2680 .all(|dropped| dropped.tier == "project"));
2681 }
2682
2683 #[test]
2684 fn config_resolve_bash_ladder_and_merge_parity() {
2685 let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2686 assert!(true_result.config.experimental_bash_rewrite);
2687 assert!(true_result.config.experimental_bash_compress);
2688 assert!(true_result.config.experimental_bash_background);
2689
2690 let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
2691 assert!(!false_result.config.experimental_bash_rewrite);
2692 assert!(!false_result.config.experimental_bash_compress);
2693 assert!(!false_result.config.experimental_bash_background);
2694
2695 let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
2696 assert!(object_default_result.config.experimental_bash_rewrite);
2697 assert!(object_default_result.config.experimental_bash_compress);
2698 assert!(object_default_result.config.experimental_bash_background);
2699
2700 let object_partial_result =
2701 resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
2702 assert!(object_partial_result.config.experimental_bash_rewrite);
2703 assert!(!object_partial_result.config.experimental_bash_compress);
2704 assert!(object_partial_result.config.experimental_bash_background);
2705
2706 let legacy_result = resolve_config(&[tier(
2707 "user",
2708 r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
2709 )]);
2710 assert!(legacy_result.config.experimental_bash_rewrite);
2711 assert!(!legacy_result.config.experimental_bash_compress);
2712 assert!(!legacy_result.config.experimental_bash_background);
2713
2714 let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
2715 assert!(surface_default_result.config.experimental_bash_rewrite);
2716 assert!(surface_default_result.config.experimental_bash_compress);
2717 assert!(surface_default_result.config.experimental_bash_background);
2718
2719 let minimal_surface_result =
2720 resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
2721 assert!(!minimal_surface_result.config.experimental_bash_rewrite);
2722 assert!(!minimal_surface_result.config.experimental_bash_compress);
2723 assert!(!minimal_surface_result.config.experimental_bash_background);
2724
2725 let merged_result = resolve_config(&[
2726 tier("user", r#"{ "bash": true }"#),
2727 tier("project", r#"{ "bash": { "compress": false } }"#),
2728 ]);
2729 assert!(merged_result.config.experimental_bash_rewrite);
2730 assert!(!merged_result.config.experimental_bash_compress);
2731 assert!(merged_result.config.experimental_bash_background);
2732
2733 let false_then_object_result = resolve_config(&[
2734 tier("user", r#"{ "bash": false }"#),
2735 tier("project", r#"{ "bash": { "compress": true } }"#),
2736 ]);
2737 assert!(!false_then_object_result.config.experimental_bash_rewrite);
2738 assert!(false_then_object_result.config.experimental_bash_compress);
2739 assert!(!false_then_object_result.config.experimental_bash_background);
2740 }
2741
2742 #[test]
2743 fn config_resolve_bash_foreground_wait_clamps_to_floor() {
2744 let Some(raw) = parse_tier(&tier(
2745 "user",
2746 r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
2747 )) else {
2748 panic!("test tier should parse");
2749 };
2750 let bash = resolve_bash_config(&raw);
2751
2752 assert_eq!(
2753 bash.foreground_wait_window_ms,
2754 FOREGROUND_WAIT_WINDOW_MIN_MS
2755 );
2756 assert!(bash.subagent_background);
2757
2758 let result = resolve_config(&[tier(
2759 "user",
2760 r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
2761 )]);
2762 assert_eq!(
2763 result.config.foreground_wait_window_ms,
2764 FOREGROUND_WAIT_WINDOW_MIN_MS
2765 );
2766
2767 let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2772 assert_eq!(
2773 defaulted.config.foreground_wait_window_ms,
2774 FOREGROUND_WAIT_WINDOW_DEFAULT_MS
2775 );
2776 assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
2777 }
2778
2779 #[test]
2780 fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
2781 let result = resolve_config(&[tier(
2782 "user",
2783 r#"{
2784 "semantic": { "timeout_ms": 0 },
2785 "search_index": true,
2786 "format_on_edit": false
2787 }"#,
2788 )]);
2789
2790 assert!(result.config.search_index);
2791 assert!(!result.config.format_on_edit);
2792 assert_eq!(result.config.semantic, SemanticBackendConfig::default());
2793 assert!(result.dropped.is_empty());
2794 }
2795
2796 #[test]
2797 fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
2798 let result = resolve_config(&[tier(
2799 "user",
2800 r#"{ "not_a_real_key": true, "search_index": true }"#,
2801 )]);
2802
2803 assert!(result.config.search_index);
2804 assert!(result.dropped.is_empty());
2805 }
2806
2807 #[test]
2808 fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
2809 let mut config = Config::default();
2815
2816 let dropped1 = resolve_config_onto(
2819 &[tier(
2820 "user",
2821 r#"{
2822 "url_fetch_allow_private": true,
2823 "restrict_to_project_root": true,
2824 "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
2825 }"#,
2826 )],
2827 &mut config,
2828 );
2829 assert!(dropped1.is_empty());
2830 assert!(config.url_fetch_allow_private);
2831 assert!(config.restrict_to_project_root);
2832 assert_eq!(config.lsp_servers.len(), 1);
2833
2834 let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2837 assert!(
2838 !config.url_fetch_allow_private,
2839 "url_fetch_allow_private must reset to default, not inherit prior bind"
2840 );
2841 assert!(
2842 !config.restrict_to_project_root,
2843 "restrict_to_project_root must reset to default"
2844 );
2845 assert!(
2846 config.lsp_servers.is_empty(),
2847 "lsp_servers must reset to default, not inherit prior bind's custom server"
2848 );
2849 assert!(config.search_index, "this bind's own field still applies");
2850 }
2851
2852 #[test]
2853 fn resolve_config_onto_empty_tiers_resets_to_default() {
2854 let mut config = Config::default();
2858 let _ = resolve_config_onto(
2859 &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
2860 &mut config,
2861 );
2862 assert!(config.url_fetch_allow_private);
2863
2864 let _ = resolve_config_onto(&[], &mut config);
2865 assert!(
2866 !config.url_fetch_allow_private,
2867 "empty-tier bind must reset core config to default"
2868 );
2869 }
2870
2871 #[test]
2872 fn resolve_config_onto_preserves_process_state_fields() {
2873 let mut config = Config {
2877 storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
2878 lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
2879 bash_permissions: true,
2880 project_root: Some(std::path::PathBuf::from("/tmp/proj")),
2881 ..Default::default()
2882 };
2883
2884 let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2885
2886 assert_eq!(
2887 config.storage_dir,
2888 Some(std::path::PathBuf::from("/tmp/aft-store"))
2889 );
2890 assert_eq!(
2891 config.lsp_paths_extra,
2892 vec![std::path::PathBuf::from("/tmp/lsp-bin")]
2893 );
2894 assert!(config.bash_permissions);
2895 assert_eq!(
2896 config.project_root,
2897 Some(std::path::PathBuf::from("/tmp/proj"))
2898 );
2899 assert!(config.search_index);
2900 }
2901
2902 #[test]
2903 fn index_roots_are_user_only_normalized_and_validate_before_resolution() {
2904 let result = resolve_config(&[tier(
2905 "user",
2906 r#"{
2907 "index": {
2908 "roots": [{
2909 "path": "~/.aft-standing-root",
2910 "indexes": ["semantic", "callgraph"],
2911 "future_field": true
2912 }]
2913 }
2914 }"#,
2915 )]);
2916 assert_eq!(result.config.index.roots.len(), 1);
2917 assert_eq!(result.config.index.roots[0].path, "~/.aft-standing-root");
2918 assert_eq!(
2919 result.config.index.roots[0].indexes,
2920 vec![IndexKind::Search, IndexKind::Semantic, IndexKind::Callgraph]
2921 );
2922 assert!(result
2923 .warnings
2924 .iter()
2925 .any(|warning| warning.code == "index_dependency_closure"));
2926 assert!(result
2927 .warnings
2928 .iter()
2929 .any(|warning| warning.code == "unknown_index_root_field"));
2930
2931 for invalid in [
2932 r#"{ "index": { "roots": [{ "path": "relative", "indexes": ["search"] }] } }"#,
2933 r#"{ "index": { "roots": [{ "path": "~/a", "indexes": [] }] } }"#,
2934 r#"{ "index": { "roots": [{ "path": "~/a", "indexes": ["search", "search"] }] } }"#,
2935 r#"{ "index": { "roots": [{ "path": "~/a", "indexes": ["unknown"] }] } }"#,
2936 ] {
2937 let invalid = resolve_config(&[tier("user", invalid)]);
2938 assert!(invalid.config.index.roots.is_empty());
2939 let warning = invalid
2940 .warnings
2941 .iter()
2942 .find(|warning| warning.code == "invalid_index_roots")
2943 .expect("invalid standing roots must be named");
2944 assert!(warning.message.contains("index.roots"));
2945 }
2946 }
2947
2948 #[test]
2949 fn index_roots_project_and_mcp_tiers_are_rejected_at_the_trust_boundary() {
2950 let result = resolve_config(&[
2951 tier(
2952 "user",
2953 r#"{ "index": { "roots": [{ "path": "~/user", "indexes": ["search"] }] } }"#,
2954 ),
2955 tier(
2956 "project",
2957 r#"{ "index": { "roots": [{ "path": "~/project", "indexes": ["semantic"] }] } }"#,
2958 ),
2959 tier(
2960 "mcp:untrusted",
2961 r#"{ "index": { "roots": [{ "path": "~/mcp", "indexes": ["callgraph"] }] } }"#,
2962 ),
2963 ]);
2964 assert_eq!(result.config.index.roots[0].path, "~/user");
2965 assert_eq!(
2966 result
2967 .dropped
2968 .iter()
2969 .filter(|dropped| dropped.key == "index.roots")
2970 .map(|dropped| dropped.tier.as_str())
2971 .collect::<Vec<_>>(),
2972 vec!["project", "mcp:untrusted"]
2973 );
2974 }
2975
2976 #[test]
2977 fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
2978 let result = resolve_config(&[tier(
2979 "user",
2980 r#"{
2981 // line comment
2982 "search_index": true,
2983 "formatter": {
2984 "rust": "rustfmt", /* block comment */
2985 },
2986 }"#,
2987 )]);
2988
2989 assert!(result.config.search_index);
2990 assert_eq!(
2991 result.config.formatter.get("rust").map(String::as_str),
2992 Some("rustfmt")
2993 );
2994 }
2995}