1use std::collections::{BTreeMap, HashMap, HashSet};
9
10use serde::de;
11use serde::{Deserialize, Deserializer};
12use serde_json::{Map, Value};
13
14use crate::config::{
15 BackupConfig, Config, InspectConfig, SemanticBackend, SemanticBackendConfig, UserServerDef,
16};
17use crate::jsonc::strip_jsonc;
18
19const FOREGROUND_WAIT_WINDOW_DEFAULT_MS: u64 = 15_000;
20const FOREGROUND_WAIT_WINDOW_MIN_MS: u64 = 5_000;
21
22const MAX_SEMANTIC_TIMEOUT_MS: u64 = 120_000;
26const MAX_SEMANTIC_BATCH_SIZE: usize = 1_024;
27
28const USER_ONLY_REASON: &str =
29 "security: this setting only honors user-level config and project values are ignored";
30const SEMANTIC_SECRET_REASON: &str =
31 "security: semantic backend credentials and endpoints must come from user-level config";
32const LSP_USER_ONLY_REASON: &str =
33 "security: LSP executable-origin and diagnostic-suppression settings must come from user-level config";
34
35#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ConfigTier {
41 pub tier: String,
42 pub source: String,
43 pub doc: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct DroppedKey {
49 pub key: String,
50 pub tier: String,
51 pub reason: String,
52}
53
54#[derive(Debug, Clone)]
56pub struct ResolveResult {
57 pub config: Config,
58 pub dropped: Vec<DroppedKey>,
59}
60
61#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
65#[serde(default, deny_unknown_fields)]
66pub struct RawAftConfig {
67 #[serde(rename = "$schema")]
68 pub schema: Option<String>,
69 pub enabled: Option<bool>,
74 pub format_on_edit: Option<bool>,
75 #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
76 pub formatter_timeout_secs: Option<u32>,
77 #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
78 pub type_checker_timeout_secs: Option<u32>,
79 pub validate_on_edit: Option<RawValidateOnEdit>,
80 pub formatter: Option<HashMap<String, RawFormatter>>,
81 pub checker: Option<HashMap<String, RawChecker>>,
82 pub configure_warnings_delivery: Option<RawConfigureWarningsDelivery>,
83 pub hoist_builtin_tools: Option<bool>,
84 pub tool_surface: Option<RawToolSurface>,
85 pub disabled_tools: Option<Vec<String>>,
86 pub restrict_to_project_root: Option<bool>,
87 pub search_index: Option<bool>,
88 pub semantic_search: Option<bool>,
89 pub callgraph_store: Option<bool>,
90 #[serde(deserialize_with = "deserialize_opt_usize")]
91 pub callgraph_chunk_size: Option<usize>,
92 pub inspect: Option<RawInspect>,
93 pub backup: Option<RawBackup>,
94 pub bash: Option<RawBash>,
95 pub experimental: Option<RawExperimental>,
96 pub lsp: Option<RawLsp>,
97 pub url_fetch_allow_private: Option<bool>,
98 pub semantic: Option<RawSemantic>,
99 pub auto_update: Option<bool>,
100 pub bridge: Option<RawBridge>,
101}
102
103#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
104#[serde(rename_all = "snake_case")]
105pub enum RawValidateOnEdit {
106 Syntax,
107 Full,
108}
109
110impl RawValidateOnEdit {
111 const fn as_str(self) -> &'static str {
112 match self {
113 Self::Syntax => "syntax",
114 Self::Full => "full",
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "snake_case")]
121pub enum RawFormatter {
122 Biome,
123 Oxfmt,
124 Prettier,
125 Deno,
126 Ruff,
127 Black,
128 Rustfmt,
129 Goimports,
130 Gofmt,
131 None,
132}
133
134impl RawFormatter {
135 const fn as_str(self) -> &'static str {
136 match self {
137 Self::Biome => "biome",
138 Self::Oxfmt => "oxfmt",
139 Self::Prettier => "prettier",
140 Self::Deno => "deno",
141 Self::Ruff => "ruff",
142 Self::Black => "black",
143 Self::Rustfmt => "rustfmt",
144 Self::Goimports => "goimports",
145 Self::Gofmt => "gofmt",
146 Self::None => "none",
147 }
148 }
149}
150
151#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "snake_case")]
153pub enum RawChecker {
154 Tsc,
155 Tsgo,
156 Biome,
157 Pyright,
158 Ruff,
159 Cargo,
160 Go,
161 Staticcheck,
162 None,
163}
164
165impl RawChecker {
166 const fn as_str(self) -> &'static str {
167 match self {
168 Self::Tsc => "tsc",
169 Self::Tsgo => "tsgo",
170 Self::Biome => "biome",
171 Self::Pyright => "pyright",
172 Self::Ruff => "ruff",
173 Self::Cargo => "cargo",
174 Self::Go => "go",
175 Self::Staticcheck => "staticcheck",
176 Self::None => "none",
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
182#[serde(rename_all = "snake_case")]
183pub enum RawConfigureWarningsDelivery {
184 Toast,
185 Log,
186 Chat,
187}
188
189#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
190#[serde(rename_all = "snake_case")]
191pub enum RawToolSurface {
192 Minimal,
193 Recommended,
194 All,
195}
196
197#[derive(Debug, Clone, Deserialize, PartialEq)]
198pub struct RawSemantic {
203 pub backend: Option<SemanticBackend>,
204 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
205 pub model: Option<String>,
206 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
207 pub base_url: Option<String>,
208 #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
209 pub api_key_env: Option<String>,
210 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
211 pub timeout_ms: Option<u64>,
212 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
213 pub max_batch_size: Option<usize>,
214 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
215 pub max_files: Option<usize>,
216}
217
218impl RawSemantic {
219 fn is_empty(&self) -> bool {
220 self.backend.is_none()
221 && self.model.is_none()
222 && self.base_url.is_none()
223 && self.api_key_env.is_none()
224 && self.timeout_ms.is_none()
225 && self.max_batch_size.is_none()
226 && self.max_files.is_none()
227 }
228}
229
230#[derive(Debug, Clone, Deserialize, PartialEq)]
231pub struct RawLsp {
232 #[serde(default, deserialize_with = "deserialize_opt_lsp_servers")]
233 pub servers: Option<BTreeMap<String, RawLspServerEntry>>,
234 #[serde(
235 default,
236 deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec"
237 )]
238 pub disabled: Option<Vec<String>>,
239 pub python: Option<RawPythonLsp>,
240 pub diagnostics_on_edit: Option<bool>,
241 pub auto_install: Option<bool>,
242 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
243 pub grace_days: Option<u64>,
244 #[serde(default, deserialize_with = "deserialize_opt_versions_map")]
245 pub versions: Option<HashMap<String, String>>,
246}
247
248impl RawLsp {
249 fn is_empty(&self) -> bool {
250 self.servers.is_none()
251 && self.disabled.is_none()
252 && self.python.is_none()
253 && self.diagnostics_on_edit.is_none()
254 && self.auto_install.is_none()
255 && self.grace_days.is_none()
256 && self.versions.is_none()
257 }
258}
259
260#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
261#[serde(rename_all = "snake_case")]
262pub enum RawPythonLsp {
263 Pyright,
264 Ty,
265 Auto,
266}
267
268#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
269#[serde(default)]
270pub struct RawLspServerEntry {
271 #[serde(deserialize_with = "deserialize_opt_lsp_extensions")]
272 pub extensions: Option<Vec<String>>,
273 #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
274 pub binary: Option<String>,
275 pub args: Option<Vec<String>>,
276 #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec")]
277 pub root_markers: Option<Vec<String>>,
278 pub disabled: Option<bool>,
279 pub env: Option<HashMap<String, String>>,
280 pub initialization_options: Option<Value>,
281}
282
283#[derive(Debug, Clone, Deserialize, PartialEq)]
284#[serde(untagged)]
285pub enum RawBash {
286 Bool(bool),
287 Features(RawBashFeatures),
288}
289
290#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
291#[serde(default)]
292pub struct RawBashFeatures {
293 pub rewrite: Option<bool>,
294 pub compress: Option<bool>,
295 pub background: Option<bool>,
296 pub subagent_background: Option<bool>,
297 pub long_running_reminder_enabled: Option<bool>,
298 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
299 pub long_running_reminder_interval_ms: Option<u64>,
300 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
301 pub foreground_wait_window_ms: Option<u64>,
302}
303
304#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
305#[serde(default)]
306pub struct RawExperimental {
307 pub bash: Option<RawExperimentalBash>,
308 pub lsp_ty: Option<bool>,
309}
310
311impl RawExperimental {
312 fn is_empty(&self) -> bool {
313 self.bash.is_none() && self.lsp_ty.is_none()
314 }
315}
316
317#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
318#[serde(default)]
319pub struct RawExperimentalBash {
320 pub rewrite: Option<bool>,
321 pub compress: Option<bool>,
322 pub background: Option<bool>,
323 pub long_running_reminder_enabled: Option<bool>,
324 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
325 pub long_running_reminder_interval_ms: Option<u64>,
326}
327
328impl RawExperimentalBash {
329 fn has_any_value(&self) -> bool {
330 self.rewrite.is_some()
331 || self.compress.is_some()
332 || self.background.is_some()
333 || self.long_running_reminder_enabled.is_some()
334 || self.long_running_reminder_interval_ms.is_some()
335 }
336
337 fn has_legacy_feature_flag(&self) -> bool {
338 self.rewrite.is_some() || self.compress.is_some() || self.background.is_some()
339 }
340}
341
342#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
343#[serde(default)]
344pub struct RawInspect {
345 pub enabled: Option<bool>,
346 #[serde(deserialize_with = "deserialize_opt_nonnegative_f64")]
347 pub tier2_idle_minutes: Option<f64>,
348 pub categories: Option<HashMap<String, bool>>,
349 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
350 pub tier2_soft_deadline_ms: Option<u64>,
351 #[serde(deserialize_with = "deserialize_opt_drill_down_items")]
352 pub max_drill_down_items: Option<usize>,
353 pub duplicates: Option<RawInspectDuplicates>,
354}
355
356impl RawInspect {
357 fn is_empty(&self) -> bool {
358 self.enabled.is_none()
359 && self.tier2_idle_minutes.is_none()
360 && self.categories.is_none()
361 && self.tier2_soft_deadline_ms.is_none()
362 && self.max_drill_down_items.is_none()
363 && self.duplicates.is_none()
364 }
365}
366
367#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
372#[serde(default)]
373pub struct RawInspectDuplicates {
374 pub expected_mirrors: Option<Vec<[String; 2]>>,
375}
376
377impl RawInspectDuplicates {
378 fn is_empty(&self) -> bool {
379 self.expected_mirrors.is_none()
380 }
381}
382
383#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
384#[serde(default)]
385pub struct RawBridge {
386 #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
387 pub request_timeout_ms: Option<u64>,
388 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
389 pub hang_threshold: Option<u64>,
390}
391
392#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
393#[serde(default)]
394pub struct RawBackup {
395 pub enabled: Option<bool>,
396 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
397 pub max_depth: Option<usize>,
398 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
399 pub max_file_size: Option<u64>,
400}
401
402pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
409 let mut merged = RawAftConfig::default();
410 let mut dropped = Vec::new();
411
412 for tier in tiers {
413 let Some(raw) = parse_tier(tier) else {
414 continue;
415 };
416
417 if tier.tier == "user" {
418 merge_trusted_config(&mut merged, raw);
419 } else {
420 record_project_drops(&raw, &tier.tier, &mut dropped);
421 merge_project_config(&mut merged, raw);
422 }
423 }
424
425 let mut config = Config::default();
426 apply_resolved_config(&merged, &mut config);
427 ResolveResult { config, dropped }
428}
429
430pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
459 let ResolveResult {
460 mut config,
461 dropped,
462 } = resolve_config(tiers);
463 carry_process_state(base, &mut config);
464 *base = config;
465 dropped
466}
467
468fn carry_process_state(base: &Config, resolved: &mut Config) {
474 resolved.project_root = base.project_root.clone();
475 resolved.harness = base.harness.clone();
476 resolved.validation_depth = base.validation_depth;
477 resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
478 resolved.max_symbol_depth = base.max_symbol_depth;
479 resolved.diagnostic_cache_size = base.diagnostic_cache_size;
480 resolved.aft_search_registered = base.aft_search_registered;
481 resolved.max_background_bash_tasks = base.max_background_bash_tasks;
482 resolved.bash_permissions = base.bash_permissions;
483 resolved.search_index_max_file_size = base.search_index_max_file_size;
484 resolved.storage_dir = base.storage_dir.clone();
485 resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
486 resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
487 resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
488}
489
490fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
491 let stripped = strip_jsonc(&tier.doc);
492 let value = serde_json::from_str::<Value>(&stripped).ok()?;
493 let Value::Object(map) = value else {
494 return None;
495 };
496
497 match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
498 Ok(config) => Some(config),
499 Err(_) => Some(parse_config_partially(map)),
500 }
501}
502
503fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
504 let mut partial = RawAftConfig::default();
505
506 for (key, value) in raw_config {
507 let mut one_field = Map::new();
508 one_field.insert(key, value);
509 if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
510 merge_trusted_config(&mut partial, section);
511 }
512 }
513
514 partial
515}
516
517fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
518 if override_config.schema.is_some() {
519 base.schema = override_config.schema;
520 }
521 if override_config.enabled.is_some() {
522 base.enabled = override_config.enabled;
523 }
524 if override_config.format_on_edit.is_some() {
525 base.format_on_edit = override_config.format_on_edit;
526 }
527 if override_config.formatter_timeout_secs.is_some() {
528 base.formatter_timeout_secs = override_config.formatter_timeout_secs;
529 }
530 if override_config.type_checker_timeout_secs.is_some() {
531 base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
532 }
533 if override_config.validate_on_edit.is_some() {
534 base.validate_on_edit = override_config.validate_on_edit;
535 }
536 if override_config.formatter.is_some() {
537 base.formatter = override_config.formatter;
538 }
539 if override_config.checker.is_some() {
540 base.checker = override_config.checker;
541 }
542 if override_config.configure_warnings_delivery.is_some() {
543 base.configure_warnings_delivery = override_config.configure_warnings_delivery;
544 }
545 if override_config.hoist_builtin_tools.is_some() {
546 base.hoist_builtin_tools = override_config.hoist_builtin_tools;
547 }
548 if override_config.tool_surface.is_some() {
549 base.tool_surface = override_config.tool_surface;
550 }
551 if override_config.disabled_tools.is_some() {
552 base.disabled_tools = override_config.disabled_tools;
553 }
554 if override_config.restrict_to_project_root.is_some() {
555 base.restrict_to_project_root = override_config.restrict_to_project_root;
556 }
557 if override_config.search_index.is_some() {
558 base.search_index = override_config.search_index;
559 }
560 if override_config.semantic_search.is_some() {
561 base.semantic_search = override_config.semantic_search;
562 }
563 if override_config.callgraph_store.is_some() {
564 base.callgraph_store = override_config.callgraph_store;
565 }
566 if override_config.callgraph_chunk_size.is_some() {
567 base.callgraph_chunk_size = override_config.callgraph_chunk_size;
568 }
569 if override_config.inspect.is_some() {
570 base.inspect = override_config.inspect;
571 }
572 if override_config.backup.is_some() {
573 base.backup = override_config.backup;
574 }
575 if override_config.bash.is_some() {
576 base.bash = override_config.bash;
577 }
578 if override_config.experimental.is_some() {
579 base.experimental = override_config.experimental;
580 }
581 if override_config.lsp.is_some() {
582 base.lsp = override_config.lsp;
583 }
584 if override_config.url_fetch_allow_private.is_some() {
585 base.url_fetch_allow_private = override_config.url_fetch_allow_private;
586 }
587 if override_config.semantic.is_some() {
588 base.semantic = override_config.semantic;
589 }
590 if override_config.auto_update.is_some() {
591 base.auto_update = override_config.auto_update;
592 }
593 if override_config.bridge.is_some() {
594 base.bridge = override_config.bridge;
595 }
596}
597
598fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
599 if project.enabled.is_some() {
601 base.enabled = project.enabled;
602 }
603 if project.format_on_edit.is_some() {
604 base.format_on_edit = project.format_on_edit;
605 }
606 if project.validate_on_edit.is_some() {
607 base.validate_on_edit = project.validate_on_edit;
608 }
609 if project.configure_warnings_delivery.is_some() {
610 base.configure_warnings_delivery = project.configure_warnings_delivery;
611 }
612 if project.hoist_builtin_tools.is_some() {
613 base.hoist_builtin_tools = project.hoist_builtin_tools;
614 }
615 if project.tool_surface.is_some() {
616 base.tool_surface = project.tool_surface;
617 }
618 if project.search_index.is_some() {
619 base.search_index = project.search_index;
620 }
621 if project.semantic_search.is_some() {
622 base.semantic_search = project.semantic_search;
623 }
624 if project.callgraph_store.is_some() {
625 base.callgraph_store = project.callgraph_store;
626 }
627 if project.callgraph_chunk_size.is_some() {
628 base.callgraph_chunk_size = project.callgraph_chunk_size;
629 }
630
631 merge_formatter_map(&mut base.formatter, project.formatter);
632 merge_checker_map(&mut base.checker, project.checker);
633 merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
634 base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
635 base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
636 base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
637 base.bash = merge_bash_config(base.bash.clone(), project.bash);
638 base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
639}
640
641fn merge_formatter_map(
642 base: &mut Option<HashMap<String, RawFormatter>>,
643 override_map: Option<HashMap<String, RawFormatter>>,
644) {
645 let Some(override_map) = override_map else {
646 return;
647 };
648 if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
649 return;
650 }
651 let target = base.get_or_insert_with(HashMap::new);
652 target.extend(override_map);
653}
654
655fn merge_checker_map(
656 base: &mut Option<HashMap<String, RawChecker>>,
657 override_map: Option<HashMap<String, RawChecker>>,
658) {
659 let Some(override_map) = override_map else {
660 return;
661 };
662 if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
663 return;
664 }
665 let target = base.get_or_insert_with(HashMap::new);
666 target.extend(override_map);
667}
668
669fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
670 let Some(override_tools) = override_tools else {
671 return;
672 };
673 let mut merged = Vec::new();
674 let mut seen = HashSet::new();
675 for tool in base.iter().flatten() {
676 if seen.insert(tool.clone()) {
677 merged.push(tool.clone());
678 }
679 }
680 for tool in override_tools
681 .iter()
682 .filter(|tool| tool.as_str() != "aft_safety")
683 {
684 if seen.insert(tool.clone()) {
685 merged.push(tool.clone());
686 }
687 }
688 if !merged.is_empty() {
689 *base = Some(merged);
690 }
691}
692
693fn merge_semantic_config(
694 base: Option<RawSemantic>,
695 override_semantic: Option<RawSemantic>,
696) -> Option<RawSemantic> {
697 let mut semantic = base.unwrap_or(RawSemantic {
698 backend: None,
699 model: None,
700 base_url: None,
701 api_key_env: None,
702 timeout_ms: None,
703 max_batch_size: None,
704 max_files: None,
705 });
706
707 if let Some(project) = override_semantic {
708 if project.model.is_some() {
709 semantic.model = project.model;
710 }
711 if project.timeout_ms.is_some() {
712 semantic.timeout_ms = project.timeout_ms;
713 }
714 if project.max_batch_size.is_some() {
715 semantic.max_batch_size = project.max_batch_size;
716 }
717 if project.max_files.is_some() {
718 semantic.max_files = project.max_files;
719 }
720 }
721
722 (!semantic.is_empty()).then_some(semantic)
723}
724
725fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
726 let mut lsp = base.unwrap_or(RawLsp {
727 servers: None,
728 disabled: None,
729 python: None,
730 diagnostics_on_edit: None,
731 auto_install: None,
732 grace_days: None,
733 versions: None,
734 });
735
736 if let Some(project) = override_lsp {
737 if project.python.is_some() {
738 lsp.python = project.python;
739 }
740 if project.diagnostics_on_edit.is_some() {
741 lsp.diagnostics_on_edit = project.diagnostics_on_edit;
742 }
743 }
744
745 (!lsp.is_empty()).then_some(lsp)
746}
747
748fn merge_experimental_config(
749 base: Option<RawExperimental>,
750 override_experimental: Option<RawExperimental>,
751) -> Option<RawExperimental> {
752 let Some(override_experimental) = override_experimental else {
753 return base;
754 };
755
756 let mut experimental = base.unwrap_or_default();
757 experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
758 experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
759
760 (!experimental.is_empty()).then_some(experimental)
761}
762
763fn merge_experimental_bash(
764 base: Option<RawExperimentalBash>,
765 override_bash: Option<RawExperimentalBash>,
766) -> Option<RawExperimentalBash> {
767 let Some(override_bash) = override_bash else {
768 return base;
769 };
770 let mut bash = base.unwrap_or_default();
771 bash.rewrite = override_bash.rewrite.or(bash.rewrite);
772 bash.compress = override_bash.compress.or(bash.compress);
773 bash.background = override_bash.background.or(bash.background);
774 bash.long_running_reminder_enabled = override_bash
775 .long_running_reminder_enabled
776 .or(bash.long_running_reminder_enabled);
777 bash.long_running_reminder_interval_ms = override_bash
778 .long_running_reminder_interval_ms
779 .or(bash.long_running_reminder_interval_ms);
780
781 bash.has_any_value().then_some(bash)
782}
783
784fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
785 match (base, override_bash) {
786 (None, None) => None,
787 (None, Some(override_bash)) => Some(override_bash),
788 (Some(base), None) => Some(base),
789 (Some(base), Some(override_bash)) => {
790 let base = expand_bash_for_merge(&base);
791 let override_features = expand_bash_for_merge(&override_bash);
792 Some(RawBash::Features(RawBashFeatures {
793 rewrite: override_features.rewrite.or(base.rewrite),
794 compress: override_features.compress.or(base.compress),
795 background: override_features.background.or(base.background),
796 subagent_background: override_features
797 .subagent_background
798 .or(base.subagent_background),
799 long_running_reminder_enabled: override_features
800 .long_running_reminder_enabled
801 .or(base.long_running_reminder_enabled),
802 long_running_reminder_interval_ms: override_features
803 .long_running_reminder_interval_ms
804 .or(base.long_running_reminder_interval_ms),
805 foreground_wait_window_ms: override_features
806 .foreground_wait_window_ms
807 .or(base.foreground_wait_window_ms),
808 }))
809 }
810 }
811}
812
813fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
814 match value {
815 RawBash::Bool(enabled) => RawBashFeatures {
816 rewrite: Some(*enabled),
817 compress: Some(*enabled),
818 background: Some(*enabled),
819 subagent_background: None,
820 long_running_reminder_enabled: None,
821 long_running_reminder_interval_ms: None,
822 foreground_wait_window_ms: None,
823 },
824 RawBash::Features(features) => features.clone(),
825 }
826}
827
828fn merge_inspect_config(
829 base: Option<RawInspect>,
830 override_inspect: Option<RawInspect>,
831) -> Option<RawInspect> {
832 let Some(override_inspect) = override_inspect else {
833 return base;
834 };
835
836 let mut inspect = base.unwrap_or_default();
837 inspect.enabled = override_inspect.enabled.or(inspect.enabled);
838 inspect.tier2_idle_minutes = override_inspect
839 .tier2_idle_minutes
840 .or(inspect.tier2_idle_minutes);
841 inspect.categories = override_inspect.categories.or(inspect.categories);
842 inspect.tier2_soft_deadline_ms = override_inspect
843 .tier2_soft_deadline_ms
844 .or(inspect.tier2_soft_deadline_ms);
845 inspect.max_drill_down_items = override_inspect
846 .max_drill_down_items
847 .or(inspect.max_drill_down_items);
848 inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
849
850 (!inspect.is_empty()).then_some(inspect)
851}
852
853fn merge_inspect_duplicates(
854 base: Option<RawInspectDuplicates>,
855 override_duplicates: Option<RawInspectDuplicates>,
856) -> Option<RawInspectDuplicates> {
857 let Some(override_duplicates) = override_duplicates else {
858 return base;
859 };
860
861 let mut duplicates = base.unwrap_or_default();
862 duplicates.expected_mirrors = override_duplicates
863 .expected_mirrors
864 .or(duplicates.expected_mirrors);
865
866 (!duplicates.is_empty()).then_some(duplicates)
867}
868
869fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
870 if raw.restrict_to_project_root.is_some() {
871 push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
872 }
873 if raw.url_fetch_allow_private.is_some() {
874 push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
875 }
876 if raw.formatter_timeout_secs.is_some() {
877 push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
878 }
879 if raw.type_checker_timeout_secs.is_some() {
880 push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
881 }
882 if raw.auto_update.is_some() {
883 push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
884 }
885 if raw.bridge.is_some() {
886 push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
887 }
888 if raw.backup.is_some() {
889 push_drop(dropped, "backup", tier, USER_ONLY_REASON);
890 }
891 if raw
892 .disabled_tools
893 .as_ref()
894 .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
895 {
896 push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
897 }
898
899 if let Some(semantic) = &raw.semantic {
900 if semantic.backend.is_some() {
901 push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
902 }
903 if semantic.base_url.is_some() {
904 push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
905 }
906 if semantic.api_key_env.is_some() {
907 push_drop(
908 dropped,
909 "semantic.api_key_env",
910 tier,
911 SEMANTIC_SECRET_REASON,
912 );
913 }
914 }
915
916 if let Some(lsp) = &raw.lsp {
917 if lsp.servers.is_some() {
918 push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
919 }
920 if lsp.versions.is_some() {
921 push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
922 }
923 if lsp.auto_install.is_some() {
924 push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
925 }
926 if lsp.grace_days.is_some() {
927 push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
928 }
929 if lsp.disabled.is_some() {
930 push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
931 }
932 }
933}
934
935fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
936 dropped.push(DroppedKey {
937 key: key.to_string(),
938 tier: tier.to_string(),
939 reason: reason.to_string(),
940 });
941}
942
943fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
949 if let Some(value) = raw.format_on_edit {
950 config.format_on_edit = value;
951 }
952 if let Some(value) = raw.formatter_timeout_secs {
953 config.formatter_timeout_secs = value;
954 }
955 if let Some(value) = raw.type_checker_timeout_secs {
956 config.type_checker_timeout_secs = value;
957 }
958 if let Some(value) = raw.validate_on_edit {
959 config.validate_on_edit = Some(value.as_str().to_string());
960 }
961 if let Some(formatter) = &raw.formatter {
962 config.formatter = formatter
963 .iter()
964 .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
965 .collect();
966 }
967 if let Some(checker) = &raw.checker {
968 config.checker = checker
969 .iter()
970 .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
971 .collect();
972 }
973 if let Some(value) = raw.restrict_to_project_root {
974 config.restrict_to_project_root = value;
975 }
976 if let Some(value) = raw.search_index {
977 config.search_index = value;
978 }
979 if let Some(value) = raw.semantic_search {
980 config.semantic_search = value;
981 }
982 if let Some(value) = raw.callgraph_store {
983 config.callgraph_store = value;
984 }
985 if let Some(value) = raw.callgraph_chunk_size {
986 config.callgraph_chunk_size = value;
987 }
988 if let Some(value) = raw.url_fetch_allow_private {
989 config.url_fetch_allow_private = value;
990 }
991 config.semantic = resolve_semantic_config(raw.semantic.as_ref());
992 config.inspect = resolve_inspect_config(raw.inspect.as_ref());
993 config.backup = resolve_backup_config(raw.backup.as_ref());
994 resolve_lsp_config(raw, config);
995 resolve_bash_fields(raw, config);
996}
997
998fn resolve_semantic_config(raw: Option<&RawSemantic>) -> SemanticBackendConfig {
999 let mut semantic = SemanticBackendConfig::default();
1000 let Some(raw) = raw else {
1001 return semantic;
1002 };
1003
1004 if let Some(value) = raw.backend {
1005 semantic.backend = value;
1006 }
1007 if let Some(value) = &raw.model {
1008 semantic.model = value.clone();
1009 }
1010 if let Some(value) = &raw.base_url {
1011 semantic.base_url = Some(value.clone());
1012 }
1013 if let Some(value) = &raw.api_key_env {
1014 semantic.api_key_env = Some(value.clone());
1015 }
1016 if let Some(value) = raw.timeout_ms {
1017 semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1018 }
1019 if let Some(value) = raw.max_batch_size {
1020 semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1021 }
1022 if let Some(value) = raw.max_files {
1023 semantic.max_files = value;
1024 }
1025
1026 semantic
1027}
1028
1029fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1030 let mut inspect = InspectConfig::default();
1031 let Some(raw) = raw else {
1032 return inspect;
1033 };
1034 if let Some(enabled) = raw.enabled {
1035 inspect.enabled = enabled;
1036 }
1037 if let Some(expected_mirrors) = raw
1038 .duplicates
1039 .as_ref()
1040 .and_then(|duplicates| duplicates.expected_mirrors.clone())
1041 {
1042 inspect.duplicates.expected_mirrors = expected_mirrors;
1043 }
1044 inspect
1045}
1046
1047fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1048 let mut backup = BackupConfig::default();
1049 if let Some(raw) = raw {
1050 if raw.enabled.is_some() {
1051 backup.enabled = raw.enabled;
1052 }
1053 if raw.max_depth.is_some() {
1054 backup.max_depth = raw.max_depth;
1055 }
1056 if raw.max_file_size.is_some() {
1057 backup.max_file_size = raw.max_file_size;
1058 }
1059 }
1060 backup
1061}
1062
1063fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1064 let lsp = raw.lsp.as_ref();
1065 let mut disabled: HashSet<String> = lsp
1066 .and_then(|lsp| lsp.disabled.as_ref())
1067 .into_iter()
1068 .flatten()
1069 .map(|value| value.to_ascii_lowercase())
1070 .collect();
1071 let mut experimental_ty = raw
1072 .experimental
1073 .as_ref()
1074 .and_then(|experimental| experimental.lsp_ty);
1075
1076 match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1077 RawPythonLsp::Ty => {
1078 experimental_ty = Some(true);
1079 disabled.insert("python".to_string());
1080 }
1081 RawPythonLsp::Pyright => {
1082 experimental_ty = Some(false);
1083 disabled.insert("ty".to_string());
1084 }
1085 RawPythonLsp::Auto => {}
1086 }
1087
1088 if let Some(value) = experimental_ty {
1089 config.experimental_lsp_ty = value;
1090 }
1091
1092 if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1093 config.diagnostics_on_edit = value;
1094 }
1095
1096 if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1097 config.lsp_servers = servers
1098 .iter()
1099 .map(|(id, server)| UserServerDef {
1100 id: id.clone(),
1101 extensions: server
1102 .extensions
1103 .clone()
1104 .unwrap_or_default()
1105 .into_iter()
1106 .map(|extension| extension.trim_start_matches('.').to_string())
1107 .collect(),
1108 binary: server.binary.clone().unwrap_or_default(),
1109 args: server.args.clone().unwrap_or_default(),
1110 root_markers: server
1111 .root_markers
1112 .clone()
1113 .unwrap_or_else(|| vec![".git".to_string()]),
1114 env: server.env.clone().unwrap_or_default(),
1115 initialization_options: server.initialization_options.clone(),
1116 disabled: server.disabled.unwrap_or(false),
1117 })
1118 .collect();
1119 }
1120
1121 if !disabled.is_empty() {
1122 config.disabled_lsp = disabled;
1123 }
1124}
1125
1126#[derive(Debug, Clone, PartialEq, Eq)]
1127struct ResolvedBashConfig {
1128 enabled: bool,
1129 rewrite: bool,
1130 compress: bool,
1131 background: bool,
1132 subagent_background: bool,
1133 long_running_reminder_enabled: Option<bool>,
1134 long_running_reminder_interval_ms: Option<u64>,
1135 foreground_wait_window_ms: u64,
1136}
1137
1138fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config) {
1139 let bash = resolve_bash_config(raw);
1140 let _registration_only = (bash.enabled, bash.subagent_background);
1146 config.experimental_bash_rewrite = bash.rewrite;
1147 config.experimental_bash_compress = bash.compress;
1148 config.experimental_bash_background = bash.background;
1149 config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
1150 if let Some(value) = bash.long_running_reminder_enabled {
1151 config.bash_long_running_reminder_enabled = value;
1152 }
1153 if let Some(value) = bash.long_running_reminder_interval_ms {
1154 config.bash_long_running_reminder_interval_ms = value;
1155 }
1156}
1157
1158fn resolve_bash_config(raw: &RawAftConfig) -> ResolvedBashConfig {
1159 let top = raw.bash.as_ref();
1160 let legacy = raw
1161 .experimental
1162 .as_ref()
1163 .and_then(|experimental| experimental.bash.as_ref());
1164 let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
1165 let surface_default_enabled = surface != RawToolSurface::Minimal;
1166
1167 let top_features = match top {
1168 Some(RawBash::Features(features)) => Some(features),
1169 _ => None,
1170 };
1171 let reminder_enabled = top_features
1172 .and_then(|features| features.long_running_reminder_enabled)
1173 .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
1174 let reminder_interval = top_features
1175 .and_then(|features| features.long_running_reminder_interval_ms)
1176 .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
1177 let top_subagent_background = top_features
1178 .and_then(|features| features.subagent_background)
1179 .unwrap_or(false);
1180 let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
1181 let foreground_wait_window_ms = raw_foreground_wait
1182 .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
1183 .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
1184
1185 let base = ResolvedBashConfig {
1186 enabled: false,
1187 rewrite: false,
1188 compress: false,
1189 background: false,
1190 subagent_background: false,
1191 long_running_reminder_enabled: reminder_enabled,
1192 long_running_reminder_interval_ms: reminder_interval,
1193 foreground_wait_window_ms,
1194 };
1195
1196 match top {
1197 Some(RawBash::Bool(false)) => base,
1198 Some(RawBash::Bool(true)) => ResolvedBashConfig {
1199 enabled: true,
1200 rewrite: true,
1201 compress: true,
1202 background: true,
1203 ..base
1204 },
1205 Some(RawBash::Features(features)) => ResolvedBashConfig {
1206 enabled: true,
1207 rewrite: features.rewrite.unwrap_or(true),
1208 compress: features.compress.unwrap_or(true),
1209 background: features.background.unwrap_or(true),
1210 subagent_background: top_subagent_background,
1211 ..base
1212 },
1213 None => {
1214 if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
1215 let legacy = legacy.cloned().unwrap_or_default();
1216 let rewrite = legacy.rewrite == Some(true);
1217 let compress = legacy.compress == Some(true);
1218 let background = legacy.background == Some(true);
1219 return ResolvedBashConfig {
1220 enabled: rewrite || compress || background,
1221 rewrite,
1222 compress,
1223 background,
1224 ..base
1225 };
1226 }
1227
1228 ResolvedBashConfig {
1229 enabled: surface_default_enabled,
1230 rewrite: surface_default_enabled,
1231 compress: surface_default_enabled,
1232 background: surface_default_enabled,
1233 ..base
1234 }
1235 }
1236 }
1237}
1238
1239fn deserialize_opt_trimmed_non_empty_string<'de, D>(
1240 deserializer: D,
1241) -> Result<Option<String>, D::Error>
1242where
1243 D: Deserializer<'de>,
1244{
1245 let value = Option::<String>::deserialize(deserializer)?;
1246 value
1247 .map(|value| {
1248 let trimmed = value.trim().to_string();
1249 if trimmed.is_empty() {
1250 Err(de::Error::custom("must be a non-empty string"))
1251 } else {
1252 Ok(trimmed)
1253 }
1254 })
1255 .transpose()
1256}
1257
1258fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
1259 deserializer: D,
1260) -> Result<Option<Vec<String>>, D::Error>
1261where
1262 D: Deserializer<'de>,
1263{
1264 let value = Option::<Vec<String>>::deserialize(deserializer)?;
1265 value
1266 .map(|values| {
1267 values
1268 .into_iter()
1269 .map(|value| {
1270 let trimmed = value.trim().to_string();
1271 if trimmed.is_empty() {
1272 Err(de::Error::custom("array entries must be non-empty strings"))
1273 } else {
1274 Ok(trimmed)
1275 }
1276 })
1277 .collect()
1278 })
1279 .transpose()
1280}
1281
1282fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1283where
1284 D: Deserializer<'de>,
1285{
1286 let value = Option::<Vec<String>>::deserialize(deserializer)?;
1287 value
1288 .map(|values| {
1289 if values.is_empty() {
1290 return Err(de::Error::custom(
1291 "extensions must contain at least one entry",
1292 ));
1293 }
1294 values
1295 .into_iter()
1296 .map(|value| {
1297 let trimmed = value.trim().to_string();
1298 if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
1299 Err(de::Error::custom(
1300 "extension must include characters other than leading dots",
1301 ))
1302 } else {
1303 Ok(trimmed)
1304 }
1305 })
1306 .collect()
1307 })
1308 .transpose()
1309}
1310
1311fn deserialize_opt_lsp_servers<'de, D>(
1312 deserializer: D,
1313) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
1314where
1315 D: Deserializer<'de>,
1316{
1317 let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
1318 value
1319 .map(|entries| {
1320 entries
1321 .into_iter()
1322 .map(|(key, value)| {
1323 let trimmed = key.trim().to_string();
1324 if trimmed.is_empty() {
1325 Err(de::Error::custom(
1326 "lsp.servers keys must be non-empty strings",
1327 ))
1328 } else {
1329 Ok((trimmed, value))
1330 }
1331 })
1332 .collect()
1333 })
1334 .transpose()
1335}
1336
1337fn deserialize_opt_versions_map<'de, D>(
1338 deserializer: D,
1339) -> Result<Option<HashMap<String, String>>, D::Error>
1340where
1341 D: Deserializer<'de>,
1342{
1343 let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
1344 value
1345 .map(|entries| {
1346 entries
1347 .into_iter()
1348 .map(|(key, value)| {
1349 let trimmed_key = key.trim().to_string();
1350 let trimmed_value = value.trim().to_string();
1351 if trimmed_key.is_empty() || trimmed_value.is_empty() {
1352 Err(de::Error::custom(
1353 "lsp.versions keys and values must be non-empty strings",
1354 ))
1355 } else {
1356 Ok((trimmed_key, trimmed_value))
1357 }
1358 })
1359 .collect()
1360 })
1361 .transpose()
1362}
1363
1364fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1365where
1366 D: Deserializer<'de>,
1367{
1368 let value = Option::<u64>::deserialize(deserializer)?;
1369 value
1370 .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1371 .transpose()
1372}
1373
1374fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1375where
1376 D: Deserializer<'de>,
1377{
1378 let value = Option::<u64>::deserialize(deserializer)?;
1379 match value {
1380 Some(0) => Err(de::Error::custom("must be a positive integer")),
1381 other => Ok(other),
1382 }
1383}
1384
1385fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1386where
1387 D: Deserializer<'de>,
1388{
1389 let value = deserialize_opt_positive_u64(deserializer)?;
1390 value
1391 .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1392 .transpose()
1393}
1394
1395fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1396where
1397 D: Deserializer<'de>,
1398{
1399 let value = Option::<u64>::deserialize(deserializer)?;
1400 match value {
1401 Some(value) if !(1..=600).contains(&value) => {
1402 Err(de::Error::custom("timeout must be in 1..=600 seconds"))
1403 }
1404 Some(value) => u32::try_from(value)
1405 .map(Some)
1406 .map_err(|_| de::Error::custom("timeout is too large")),
1407 None => Ok(None),
1408 }
1409}
1410
1411fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
1412 deserializer: D,
1413) -> Result<Option<u64>, D::Error>
1414where
1415 D: Deserializer<'de>,
1416{
1417 let value = Option::<u64>::deserialize(deserializer)?;
1418 match value {
1419 Some(value) if value < 1_000 => Err(de::Error::custom(
1420 "bridge.request_timeout_ms must be at least 1000",
1421 )),
1422 other => Ok(other),
1423 }
1424}
1425
1426fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1427where
1428 D: Deserializer<'de>,
1429{
1430 let value = Option::<f64>::deserialize(deserializer)?;
1431 match value {
1432 Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
1433 other => Ok(other),
1434 }
1435}
1436
1437fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1438where
1439 D: Deserializer<'de>,
1440{
1441 let value = Option::<u64>::deserialize(deserializer)?;
1442 match value {
1443 Some(value) if value == 0 || value > 100 => {
1444 Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
1445 }
1446 Some(value) => usize::try_from(value)
1447 .map(Some)
1448 .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
1449 None => Ok(None),
1450 }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456
1457 fn tier(tier: &str, doc: &str) -> ConfigTier {
1458 ConfigTier {
1459 tier: tier.to_string(),
1460 source: format!("/tmp/{tier}/aft.jsonc"),
1461 doc: doc.to_string(),
1462 }
1463 }
1464
1465 fn drop_keys(result: &ResolveResult) -> Vec<String> {
1466 result
1467 .dropped
1468 .iter()
1469 .map(|dropped| dropped.key.clone())
1470 .collect()
1471 }
1472
1473 #[test]
1480 fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
1481 let nested = resolve_config(&[tier(
1487 "user",
1488 r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
1489 )]);
1490 assert!(nested.config.experimental_bash_rewrite);
1491 assert!(nested.config.experimental_bash_compress);
1492 assert!(nested.config.experimental_bash_background);
1493
1494 let smuggle = resolve_config(&[
1498 tier("user", r#"{ "search_index": true }"#),
1499 tier(
1500 "project",
1501 r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
1502 ),
1503 ]);
1504 assert!(!smuggle.config.search_index);
1506 assert!(smuggle.config.storage_dir.is_none());
1508 assert!(!smuggle.config.bash_permissions);
1509 }
1510
1511 #[test]
1512 fn config_resolve_empty_tiers_applies_bash_surface_default() {
1513 let result = resolve_config(&[]);
1518 let default_config = Config::default();
1519
1520 assert!(result.dropped.is_empty());
1521 assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
1523 assert_eq!(result.config.search_index, default_config.search_index);
1524 assert_eq!(
1525 result.config.semantic_search,
1526 default_config.semantic_search
1527 );
1528 assert_eq!(result.config.semantic, default_config.semantic);
1529 assert_eq!(
1530 result.config.inspect.enabled,
1531 default_config.inspect.enabled
1532 );
1533 assert_eq!(result.config.lsp_servers.len(), 0);
1534 assert!(result.config.experimental_bash_rewrite);
1536 assert!(result.config.experimental_bash_compress);
1537 assert!(result.config.experimental_bash_background);
1538 }
1539
1540 #[test]
1541 fn config_resolve_user_only_config_applies_fields() {
1542 let result = resolve_config(&[tier(
1543 "user",
1544 r#"{
1545 "$schema": "https://example.test/aft.schema.json",
1546 "format_on_edit": false,
1547 "formatter_timeout_secs": 42,
1548 "type_checker_timeout_secs": 43,
1549 "validate_on_edit": "full",
1550 "formatter": { "rust": "rustfmt", "typescript": "prettier" },
1551 "checker": { "rust": "cargo", "typescript": "tsc" },
1552 "restrict_to_project_root": true,
1553 "search_index": true,
1554 "semantic_search": true,
1555 "callgraph_store": false,
1556 "callgraph_chunk_size": 17,
1557 "url_fetch_allow_private": true,
1558 "semantic": {
1559 "backend": "openai_compatible",
1560 "model": " user-model ",
1561 "base_url": "https://semantic.example.test",
1562 "api_key_env": "AFT_API_KEY",
1563 "timeout_ms": 12345,
1564 "max_batch_size": 12,
1565 "max_files": 3456
1566 },
1567 "inspect": { "enabled": false },
1568 "experimental": { "lsp_ty": true },
1569 "lsp": {
1570 "servers": {
1571 "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1572 },
1573 "disabled": ["Python"],
1574 "python": "pyright"
1575 },
1576 "bash": { "rewrite": false, "compress": true, "background": false,
1577 "long_running_reminder_enabled": false,
1578 "long_running_reminder_interval_ms": 123000 }
1579 }"#,
1580 )]);
1581
1582 assert!(result.dropped.is_empty());
1583 assert!(!result.config.format_on_edit);
1584 assert_eq!(result.config.formatter_timeout_secs, 42);
1585 assert_eq!(result.config.type_checker_timeout_secs, 43);
1586 assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
1587 assert_eq!(
1588 result.config.formatter.get("rust").map(String::as_str),
1589 Some("rustfmt")
1590 );
1591 assert_eq!(
1592 result.config.checker.get("typescript").map(String::as_str),
1593 Some("tsc")
1594 );
1595 assert!(result.config.restrict_to_project_root);
1596 assert!(result.config.search_index);
1597 assert!(result.config.semantic_search);
1598 assert!(!result.config.callgraph_store);
1599 assert_eq!(result.config.callgraph_chunk_size, 17);
1600 assert!(result.config.url_fetch_allow_private);
1601 assert_eq!(
1602 result.config.semantic.backend,
1603 SemanticBackend::OpenAiCompatible
1604 );
1605 assert_eq!(result.config.semantic.model, "user-model");
1606 assert_eq!(
1607 result.config.semantic.base_url.as_deref(),
1608 Some("https://semantic.example.test")
1609 );
1610 assert_eq!(
1611 result.config.semantic.api_key_env.as_deref(),
1612 Some("AFT_API_KEY")
1613 );
1614 assert_eq!(result.config.semantic.timeout_ms, 12345);
1615 assert_eq!(result.config.semantic.max_batch_size, 12);
1616 assert_eq!(result.config.semantic.max_files, 3456);
1617 assert!(!result.config.inspect.enabled);
1618 assert!(!result.config.experimental_lsp_ty);
1619 assert!(result.config.disabled_lsp.contains("ty"));
1620 assert_eq!(result.config.lsp_servers.len(), 1);
1621 assert_eq!(result.config.lsp_servers[0].id, "rust");
1622 assert_eq!(
1623 result.config.lsp_servers[0].extensions,
1624 vec!["rs".to_string()]
1625 );
1626 assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1627 assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
1628 assert_eq!(
1629 result.config.lsp_servers[0].root_markers,
1630 vec![".git".to_string()]
1631 );
1632 assert!(!result.config.experimental_bash_rewrite);
1633 assert!(result.config.experimental_bash_compress);
1634 assert!(!result.config.experimental_bash_background);
1635 assert!(!result.config.bash_long_running_reminder_enabled);
1636 assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
1637 }
1638
1639 #[test]
1640 fn config_resolve_project_allowed_search_index_wins() {
1641 let result = resolve_config(&[
1642 tier("user", r#"{ "search_index": false }"#),
1643 tier("project", r#"{ "search_index": true }"#),
1644 ]);
1645
1646 assert!(result.config.search_index);
1647 assert!(result.dropped.is_empty());
1648 }
1649
1650 #[test]
1651 fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
1652 let result = resolve_config(&[
1653 tier(
1654 "user",
1655 r#"{
1656 "restrict_to_project_root": true,
1657 "url_fetch_allow_private": true,
1658 "formatter_timeout_secs": 11,
1659 "type_checker_timeout_secs": 33,
1660 "auto_update": true,
1661 "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
1662 "semantic": {
1663 "backend": "openai_compatible",
1664 "base_url": "https://user.example.test",
1665 "api_key_env": "USER_KEY",
1666 "model": "user-model"
1667 },
1668 "lsp": {
1669 "servers": {
1670 "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1671 },
1672 "disabled": ["user-disabled"],
1673 "versions": { "typescript-language-server": "1.0.0" },
1674 "auto_install": true,
1675 "grace_days": 7
1676 }
1677 }"#,
1678 ),
1679 tier(
1680 "project",
1681 r#"{
1682 "restrict_to_project_root": false,
1683 "url_fetch_allow_private": false,
1684 "formatter_timeout_secs": 22,
1685 "type_checker_timeout_secs": 44,
1686 "auto_update": false,
1687 "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
1688 "semantic": {
1689 "backend": "ollama",
1690 "base_url": "https://project.example.test",
1691 "api_key_env": "PROJECT_KEY",
1692 "model": "project-model",
1693 "timeout_ms": 2222
1694 },
1695 "lsp": {
1696 "servers": {
1697 "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
1698 },
1699 "disabled": ["project-disabled"],
1700 "versions": { "evil-lsp": "9.9.9" },
1701 "auto_install": false,
1702 "grace_days": 1,
1703 "python": "ty"
1704 }
1705 }"#,
1706 ),
1707 ]);
1708
1709 assert!(result.config.restrict_to_project_root);
1710 assert!(result.config.url_fetch_allow_private);
1711 assert_eq!(result.config.formatter_timeout_secs, 11);
1712 assert_eq!(result.config.type_checker_timeout_secs, 33);
1713 assert_eq!(
1714 result.config.semantic.backend,
1715 SemanticBackend::OpenAiCompatible
1716 );
1717 assert_eq!(
1718 result.config.semantic.base_url.as_deref(),
1719 Some("https://user.example.test")
1720 );
1721 assert_eq!(
1722 result.config.semantic.api_key_env.as_deref(),
1723 Some("USER_KEY")
1724 );
1725 assert_eq!(result.config.semantic.model, "project-model");
1726 assert_eq!(result.config.semantic.timeout_ms, 2222);
1727 assert_eq!(result.config.lsp_servers.len(), 1);
1728 assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1729 assert!(result.config.disabled_lsp.contains("user-disabled"));
1730 assert!(!result.config.disabled_lsp.contains("project-disabled"));
1731 assert!(result.config.disabled_lsp.contains("python"));
1732 assert!(result.config.experimental_lsp_ty);
1733
1734 let keys = drop_keys(&result);
1735 let expected = [
1736 "restrict_to_project_root",
1737 "url_fetch_allow_private",
1738 "formatter_timeout_secs",
1739 "type_checker_timeout_secs",
1740 "auto_update",
1741 "bridge",
1742 "semantic.backend",
1743 "semantic.base_url",
1744 "semantic.api_key_env",
1745 "lsp.servers",
1746 "lsp.versions",
1747 "lsp.auto_install",
1748 "lsp.grace_days",
1749 "lsp.disabled",
1750 ];
1751 for key in expected {
1752 assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
1753 }
1754 assert_eq!(keys.len(), expected.len());
1755 assert!(result
1756 .dropped
1757 .iter()
1758 .all(|dropped| dropped.tier == "project"));
1759 }
1760
1761 #[test]
1762 fn config_resolve_bash_ladder_and_merge_parity() {
1763 let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
1764 assert!(true_result.config.experimental_bash_rewrite);
1765 assert!(true_result.config.experimental_bash_compress);
1766 assert!(true_result.config.experimental_bash_background);
1767
1768 let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
1769 assert!(!false_result.config.experimental_bash_rewrite);
1770 assert!(!false_result.config.experimental_bash_compress);
1771 assert!(!false_result.config.experimental_bash_background);
1772
1773 let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
1774 assert!(object_default_result.config.experimental_bash_rewrite);
1775 assert!(object_default_result.config.experimental_bash_compress);
1776 assert!(object_default_result.config.experimental_bash_background);
1777
1778 let object_partial_result =
1779 resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
1780 assert!(object_partial_result.config.experimental_bash_rewrite);
1781 assert!(!object_partial_result.config.experimental_bash_compress);
1782 assert!(object_partial_result.config.experimental_bash_background);
1783
1784 let legacy_result = resolve_config(&[tier(
1785 "user",
1786 r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
1787 )]);
1788 assert!(legacy_result.config.experimental_bash_rewrite);
1789 assert!(!legacy_result.config.experimental_bash_compress);
1790 assert!(!legacy_result.config.experimental_bash_background);
1791
1792 let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
1793 assert!(surface_default_result.config.experimental_bash_rewrite);
1794 assert!(surface_default_result.config.experimental_bash_compress);
1795 assert!(surface_default_result.config.experimental_bash_background);
1796
1797 let minimal_surface_result =
1798 resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
1799 assert!(!minimal_surface_result.config.experimental_bash_rewrite);
1800 assert!(!minimal_surface_result.config.experimental_bash_compress);
1801 assert!(!minimal_surface_result.config.experimental_bash_background);
1802
1803 let merged_result = resolve_config(&[
1804 tier("user", r#"{ "bash": true }"#),
1805 tier("project", r#"{ "bash": { "compress": false } }"#),
1806 ]);
1807 assert!(merged_result.config.experimental_bash_rewrite);
1808 assert!(!merged_result.config.experimental_bash_compress);
1809 assert!(merged_result.config.experimental_bash_background);
1810
1811 let false_then_object_result = resolve_config(&[
1812 tier("user", r#"{ "bash": false }"#),
1813 tier("project", r#"{ "bash": { "compress": true } }"#),
1814 ]);
1815 assert!(!false_then_object_result.config.experimental_bash_rewrite);
1816 assert!(false_then_object_result.config.experimental_bash_compress);
1817 assert!(!false_then_object_result.config.experimental_bash_background);
1818 }
1819
1820 #[test]
1821 fn config_resolve_bash_foreground_wait_clamps_to_floor() {
1822 let Some(raw) = parse_tier(&tier(
1823 "user",
1824 r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
1825 )) else {
1826 panic!("test tier should parse");
1827 };
1828 let bash = resolve_bash_config(&raw);
1829
1830 assert_eq!(
1831 bash.foreground_wait_window_ms,
1832 FOREGROUND_WAIT_WINDOW_MIN_MS
1833 );
1834 assert!(bash.subagent_background);
1835
1836 let result = resolve_config(&[tier(
1837 "user",
1838 r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
1839 )]);
1840 assert_eq!(
1841 result.config.foreground_wait_window_ms,
1842 FOREGROUND_WAIT_WINDOW_MIN_MS
1843 );
1844
1845 let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
1850 assert_eq!(
1851 defaulted.config.foreground_wait_window_ms,
1852 FOREGROUND_WAIT_WINDOW_DEFAULT_MS
1853 );
1854 assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
1855 }
1856
1857 #[test]
1858 fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
1859 let result = resolve_config(&[tier(
1860 "user",
1861 r#"{
1862 "semantic": { "timeout_ms": 0 },
1863 "search_index": true,
1864 "format_on_edit": false
1865 }"#,
1866 )]);
1867
1868 assert!(result.config.search_index);
1869 assert!(!result.config.format_on_edit);
1870 assert_eq!(result.config.semantic, SemanticBackendConfig::default());
1871 assert!(result.dropped.is_empty());
1872 }
1873
1874 #[test]
1875 fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
1876 let result = resolve_config(&[tier(
1877 "user",
1878 r#"{ "not_a_real_key": true, "search_index": true }"#,
1879 )]);
1880
1881 assert!(result.config.search_index);
1882 assert!(result.dropped.is_empty());
1883 }
1884
1885 #[test]
1886 fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
1887 let mut config = Config::default();
1893
1894 let dropped1 = resolve_config_onto(
1897 &[tier(
1898 "user",
1899 r#"{
1900 "url_fetch_allow_private": true,
1901 "restrict_to_project_root": true,
1902 "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
1903 }"#,
1904 )],
1905 &mut config,
1906 );
1907 assert!(dropped1.is_empty());
1908 assert!(config.url_fetch_allow_private);
1909 assert!(config.restrict_to_project_root);
1910 assert_eq!(config.lsp_servers.len(), 1);
1911
1912 let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
1915 assert!(
1916 !config.url_fetch_allow_private,
1917 "url_fetch_allow_private must reset to default, not inherit prior bind"
1918 );
1919 assert!(
1920 !config.restrict_to_project_root,
1921 "restrict_to_project_root must reset to default"
1922 );
1923 assert!(
1924 config.lsp_servers.is_empty(),
1925 "lsp_servers must reset to default, not inherit prior bind's custom server"
1926 );
1927 assert!(config.search_index, "this bind's own field still applies");
1928 }
1929
1930 #[test]
1931 fn resolve_config_onto_empty_tiers_resets_to_default() {
1932 let mut config = Config::default();
1936 let _ = resolve_config_onto(
1937 &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
1938 &mut config,
1939 );
1940 assert!(config.url_fetch_allow_private);
1941
1942 let _ = resolve_config_onto(&[], &mut config);
1943 assert!(
1944 !config.url_fetch_allow_private,
1945 "empty-tier bind must reset core config to default"
1946 );
1947 }
1948
1949 #[test]
1950 fn resolve_config_onto_preserves_process_state_fields() {
1951 let mut config = Config {
1955 storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
1956 lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
1957 bash_permissions: true,
1958 project_root: Some(std::path::PathBuf::from("/tmp/proj")),
1959 ..Default::default()
1960 };
1961
1962 let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
1963
1964 assert_eq!(
1965 config.storage_dir,
1966 Some(std::path::PathBuf::from("/tmp/aft-store"))
1967 );
1968 assert_eq!(
1969 config.lsp_paths_extra,
1970 vec![std::path::PathBuf::from("/tmp/lsp-bin")]
1971 );
1972 assert!(config.bash_permissions);
1973 assert_eq!(
1974 config.project_root,
1975 Some(std::path::PathBuf::from("/tmp/proj"))
1976 );
1977 assert!(config.search_index);
1978 }
1979
1980 #[test]
1981 fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
1982 let result = resolve_config(&[tier(
1983 "user",
1984 r#"{
1985 // line comment
1986 "search_index": true,
1987 "formatter": {
1988 "rust": "rustfmt", /* block comment */
1989 },
1990 }"#,
1991 )]);
1992
1993 assert!(result.config.search_index);
1994 assert_eq!(
1995 result.config.formatter.get("rust").map(String::as_str),
1996 Some("rustfmt")
1997 );
1998 }
1999}