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