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)]
368#[serde(default)]
369pub struct RawInspectDuplicates {
370 #[serde(deserialize_with = "deserialize_opt_positive_usize")]
371 pub lower_bound: Option<usize>,
372 #[serde(deserialize_with = "deserialize_opt_u64")]
373 pub discard_cost: Option<u64>,
374 pub anonymize: Option<RawInspectAnonymize>,
375 pub expected_mirrors: Option<Vec<[String; 2]>>,
376}
377
378impl RawInspectDuplicates {
379 fn is_empty(&self) -> bool {
380 self.lower_bound.is_none()
381 && self.discard_cost.is_none()
382 && self.anonymize.is_none()
383 && self.expected_mirrors.is_none()
384 }
385}
386
387#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
388#[serde(default)]
389pub struct RawInspectAnonymize {
390 pub variables: Option<bool>,
391 pub fields: Option<bool>,
392 pub methods: Option<bool>,
393 pub types: Option<bool>,
394 pub literals: Option<bool>,
395}
396
397impl RawInspectAnonymize {
398 fn is_empty(&self) -> bool {
399 self.variables.is_none()
400 && self.fields.is_none()
401 && self.methods.is_none()
402 && self.types.is_none()
403 && self.literals.is_none()
404 }
405}
406
407#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
408#[serde(default)]
409pub struct RawBridge {
410 #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
411 pub request_timeout_ms: Option<u64>,
412 #[serde(deserialize_with = "deserialize_opt_positive_u64")]
413 pub hang_threshold: Option<u64>,
414}
415
416#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
417#[serde(default)]
418pub struct RawBackup {
419 pub enabled: Option<bool>,
420 #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
421 pub max_depth: Option<usize>,
422 #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
423 pub max_file_size: Option<u64>,
424}
425
426pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
433 let mut merged = RawAftConfig::default();
434 let mut dropped = Vec::new();
435
436 for tier in tiers {
437 let Some(raw) = parse_tier(tier) else {
438 continue;
439 };
440
441 if tier.tier == "user" {
442 merge_trusted_config(&mut merged, raw);
443 } else {
444 record_project_drops(&raw, &tier.tier, &mut dropped);
445 merge_project_config(&mut merged, raw);
446 }
447 }
448
449 let mut config = Config::default();
450 apply_resolved_config(&merged, &mut config);
451 ResolveResult { config, dropped }
452}
453
454pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
483 let ResolveResult {
484 mut config,
485 dropped,
486 } = resolve_config(tiers);
487 carry_process_state(base, &mut config);
488 *base = config;
489 dropped
490}
491
492fn carry_process_state(base: &Config, resolved: &mut Config) {
498 resolved.project_root = base.project_root.clone();
499 resolved.harness = base.harness.clone();
500 resolved.validation_depth = base.validation_depth;
501 resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
502 resolved.max_symbol_depth = base.max_symbol_depth;
503 resolved.diagnostic_cache_size = base.diagnostic_cache_size;
504 resolved.aft_search_registered = base.aft_search_registered;
505 resolved.max_background_bash_tasks = base.max_background_bash_tasks;
506 resolved.bash_permissions = base.bash_permissions;
507 resolved.search_index_max_file_size = base.search_index_max_file_size;
508 resolved.storage_dir = base.storage_dir.clone();
509 resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
510 resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
511 resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
512}
513
514fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
515 let stripped = strip_jsonc(&tier.doc);
516 let value = serde_json::from_str::<Value>(&stripped).ok()?;
517 let Value::Object(map) = value else {
518 return None;
519 };
520
521 match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
522 Ok(config) => Some(config),
523 Err(_) => Some(parse_config_partially(map)),
524 }
525}
526
527fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
528 let mut partial = RawAftConfig::default();
529
530 for (key, value) in raw_config {
531 let mut one_field = Map::new();
532 one_field.insert(key, value);
533 if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
534 merge_trusted_config(&mut partial, section);
535 }
536 }
537
538 partial
539}
540
541fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
542 if override_config.schema.is_some() {
543 base.schema = override_config.schema;
544 }
545 if override_config.enabled.is_some() {
546 base.enabled = override_config.enabled;
547 }
548 if override_config.format_on_edit.is_some() {
549 base.format_on_edit = override_config.format_on_edit;
550 }
551 if override_config.formatter_timeout_secs.is_some() {
552 base.formatter_timeout_secs = override_config.formatter_timeout_secs;
553 }
554 if override_config.type_checker_timeout_secs.is_some() {
555 base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
556 }
557 if override_config.validate_on_edit.is_some() {
558 base.validate_on_edit = override_config.validate_on_edit;
559 }
560 if override_config.formatter.is_some() {
561 base.formatter = override_config.formatter;
562 }
563 if override_config.checker.is_some() {
564 base.checker = override_config.checker;
565 }
566 if override_config.configure_warnings_delivery.is_some() {
567 base.configure_warnings_delivery = override_config.configure_warnings_delivery;
568 }
569 if override_config.hoist_builtin_tools.is_some() {
570 base.hoist_builtin_tools = override_config.hoist_builtin_tools;
571 }
572 if override_config.tool_surface.is_some() {
573 base.tool_surface = override_config.tool_surface;
574 }
575 if override_config.disabled_tools.is_some() {
576 base.disabled_tools = override_config.disabled_tools;
577 }
578 if override_config.restrict_to_project_root.is_some() {
579 base.restrict_to_project_root = override_config.restrict_to_project_root;
580 }
581 if override_config.search_index.is_some() {
582 base.search_index = override_config.search_index;
583 }
584 if override_config.semantic_search.is_some() {
585 base.semantic_search = override_config.semantic_search;
586 }
587 if override_config.callgraph_store.is_some() {
588 base.callgraph_store = override_config.callgraph_store;
589 }
590 if override_config.callgraph_chunk_size.is_some() {
591 base.callgraph_chunk_size = override_config.callgraph_chunk_size;
592 }
593 if override_config.inspect.is_some() {
594 base.inspect = override_config.inspect;
595 }
596 if override_config.backup.is_some() {
597 base.backup = override_config.backup;
598 }
599 if override_config.bash.is_some() {
600 base.bash = override_config.bash;
601 }
602 if override_config.experimental.is_some() {
603 base.experimental = override_config.experimental;
604 }
605 if override_config.lsp.is_some() {
606 base.lsp = override_config.lsp;
607 }
608 if override_config.url_fetch_allow_private.is_some() {
609 base.url_fetch_allow_private = override_config.url_fetch_allow_private;
610 }
611 if override_config.semantic.is_some() {
612 base.semantic = override_config.semantic;
613 }
614 if override_config.auto_update.is_some() {
615 base.auto_update = override_config.auto_update;
616 }
617 if override_config.bridge.is_some() {
618 base.bridge = override_config.bridge;
619 }
620}
621
622fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
623 if project.enabled.is_some() {
625 base.enabled = project.enabled;
626 }
627 if project.format_on_edit.is_some() {
628 base.format_on_edit = project.format_on_edit;
629 }
630 if project.validate_on_edit.is_some() {
631 base.validate_on_edit = project.validate_on_edit;
632 }
633 if project.configure_warnings_delivery.is_some() {
634 base.configure_warnings_delivery = project.configure_warnings_delivery;
635 }
636 if project.hoist_builtin_tools.is_some() {
637 base.hoist_builtin_tools = project.hoist_builtin_tools;
638 }
639 if project.tool_surface.is_some() {
640 base.tool_surface = project.tool_surface;
641 }
642 if project.search_index.is_some() {
643 base.search_index = project.search_index;
644 }
645 if project.semantic_search.is_some() {
646 base.semantic_search = project.semantic_search;
647 }
648 if project.callgraph_store.is_some() {
649 base.callgraph_store = project.callgraph_store;
650 }
651 if project.callgraph_chunk_size.is_some() {
652 base.callgraph_chunk_size = project.callgraph_chunk_size;
653 }
654
655 merge_formatter_map(&mut base.formatter, project.formatter);
656 merge_checker_map(&mut base.checker, project.checker);
657 merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
658 base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
659 base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
660 base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
661 base.bash = merge_bash_config(base.bash.clone(), project.bash);
662 base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
663}
664
665fn merge_formatter_map(
666 base: &mut Option<HashMap<String, RawFormatter>>,
667 override_map: Option<HashMap<String, RawFormatter>>,
668) {
669 let Some(override_map) = override_map else {
670 return;
671 };
672 if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
673 return;
674 }
675 let target = base.get_or_insert_with(HashMap::new);
676 target.extend(override_map);
677}
678
679fn merge_checker_map(
680 base: &mut Option<HashMap<String, RawChecker>>,
681 override_map: Option<HashMap<String, RawChecker>>,
682) {
683 let Some(override_map) = override_map else {
684 return;
685 };
686 if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
687 return;
688 }
689 let target = base.get_or_insert_with(HashMap::new);
690 target.extend(override_map);
691}
692
693fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
694 let Some(override_tools) = override_tools else {
695 return;
696 };
697 let mut merged = Vec::new();
698 let mut seen = HashSet::new();
699 for tool in base.iter().flatten() {
700 if seen.insert(tool.clone()) {
701 merged.push(tool.clone());
702 }
703 }
704 for tool in override_tools
705 .iter()
706 .filter(|tool| tool.as_str() != "aft_safety")
707 {
708 if seen.insert(tool.clone()) {
709 merged.push(tool.clone());
710 }
711 }
712 if !merged.is_empty() {
713 *base = Some(merged);
714 }
715}
716
717fn merge_semantic_config(
718 base: Option<RawSemantic>,
719 override_semantic: Option<RawSemantic>,
720) -> Option<RawSemantic> {
721 let mut semantic = base.unwrap_or(RawSemantic {
722 backend: None,
723 model: None,
724 base_url: None,
725 api_key_env: None,
726 timeout_ms: None,
727 max_batch_size: None,
728 max_files: None,
729 });
730
731 if let Some(project) = override_semantic {
732 if project.model.is_some() {
733 semantic.model = project.model;
734 }
735 if project.timeout_ms.is_some() {
736 semantic.timeout_ms = project.timeout_ms;
737 }
738 if project.max_batch_size.is_some() {
739 semantic.max_batch_size = project.max_batch_size;
740 }
741 if project.max_files.is_some() {
742 semantic.max_files = project.max_files;
743 }
744 }
745
746 (!semantic.is_empty()).then_some(semantic)
747}
748
749fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
750 let mut lsp = base.unwrap_or(RawLsp {
751 servers: None,
752 disabled: None,
753 python: None,
754 diagnostics_on_edit: None,
755 auto_install: None,
756 grace_days: None,
757 versions: None,
758 });
759
760 if let Some(project) = override_lsp {
761 if project.python.is_some() {
762 lsp.python = project.python;
763 }
764 if project.diagnostics_on_edit.is_some() {
765 lsp.diagnostics_on_edit = project.diagnostics_on_edit;
766 }
767 }
768
769 (!lsp.is_empty()).then_some(lsp)
770}
771
772fn merge_experimental_config(
773 base: Option<RawExperimental>,
774 override_experimental: Option<RawExperimental>,
775) -> Option<RawExperimental> {
776 let Some(override_experimental) = override_experimental else {
777 return base;
778 };
779
780 let mut experimental = base.unwrap_or_default();
781 experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
782 experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
783
784 (!experimental.is_empty()).then_some(experimental)
785}
786
787fn merge_experimental_bash(
788 base: Option<RawExperimentalBash>,
789 override_bash: Option<RawExperimentalBash>,
790) -> Option<RawExperimentalBash> {
791 let Some(override_bash) = override_bash else {
792 return base;
793 };
794 let mut bash = base.unwrap_or_default();
795 bash.rewrite = override_bash.rewrite.or(bash.rewrite);
796 bash.compress = override_bash.compress.or(bash.compress);
797 bash.background = override_bash.background.or(bash.background);
798 bash.long_running_reminder_enabled = override_bash
799 .long_running_reminder_enabled
800 .or(bash.long_running_reminder_enabled);
801 bash.long_running_reminder_interval_ms = override_bash
802 .long_running_reminder_interval_ms
803 .or(bash.long_running_reminder_interval_ms);
804
805 bash.has_any_value().then_some(bash)
806}
807
808fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
809 match (base, override_bash) {
810 (None, None) => None,
811 (None, Some(override_bash)) => Some(override_bash),
812 (Some(base), None) => Some(base),
813 (Some(base), Some(override_bash)) => {
814 let base = expand_bash_for_merge(&base);
815 let override_features = expand_bash_for_merge(&override_bash);
816 Some(RawBash::Features(RawBashFeatures {
817 rewrite: override_features.rewrite.or(base.rewrite),
818 compress: override_features.compress.or(base.compress),
819 background: override_features.background.or(base.background),
820 subagent_background: override_features
821 .subagent_background
822 .or(base.subagent_background),
823 long_running_reminder_enabled: override_features
824 .long_running_reminder_enabled
825 .or(base.long_running_reminder_enabled),
826 long_running_reminder_interval_ms: override_features
827 .long_running_reminder_interval_ms
828 .or(base.long_running_reminder_interval_ms),
829 foreground_wait_window_ms: override_features
830 .foreground_wait_window_ms
831 .or(base.foreground_wait_window_ms),
832 }))
833 }
834 }
835}
836
837fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
838 match value {
839 RawBash::Bool(enabled) => RawBashFeatures {
840 rewrite: Some(*enabled),
841 compress: Some(*enabled),
842 background: Some(*enabled),
843 subagent_background: None,
844 long_running_reminder_enabled: None,
845 long_running_reminder_interval_ms: None,
846 foreground_wait_window_ms: None,
847 },
848 RawBash::Features(features) => features.clone(),
849 }
850}
851
852fn merge_inspect_config(
853 base: Option<RawInspect>,
854 override_inspect: Option<RawInspect>,
855) -> Option<RawInspect> {
856 let Some(override_inspect) = override_inspect else {
857 return base;
858 };
859
860 let mut inspect = base.unwrap_or_default();
861 inspect.enabled = override_inspect.enabled.or(inspect.enabled);
862 inspect.tier2_idle_minutes = override_inspect
863 .tier2_idle_minutes
864 .or(inspect.tier2_idle_minutes);
865 inspect.categories = override_inspect.categories.or(inspect.categories);
866 inspect.tier2_soft_deadline_ms = override_inspect
867 .tier2_soft_deadline_ms
868 .or(inspect.tier2_soft_deadline_ms);
869 inspect.max_drill_down_items = override_inspect
870 .max_drill_down_items
871 .or(inspect.max_drill_down_items);
872 inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
873
874 (!inspect.is_empty()).then_some(inspect)
875}
876
877fn merge_inspect_duplicates(
878 base: Option<RawInspectDuplicates>,
879 override_duplicates: Option<RawInspectDuplicates>,
880) -> Option<RawInspectDuplicates> {
881 let Some(override_duplicates) = override_duplicates else {
882 return base;
883 };
884
885 let mut duplicates = base.unwrap_or_default();
886 duplicates.lower_bound = override_duplicates.lower_bound.or(duplicates.lower_bound);
887 duplicates.discard_cost = override_duplicates.discard_cost.or(duplicates.discard_cost);
888 duplicates.expected_mirrors = override_duplicates
889 .expected_mirrors
890 .or(duplicates.expected_mirrors);
891 duplicates.anonymize =
892 merge_inspect_anonymize(duplicates.anonymize, override_duplicates.anonymize);
893
894 (!duplicates.is_empty()).then_some(duplicates)
895}
896
897fn merge_inspect_anonymize(
898 base: Option<RawInspectAnonymize>,
899 override_anonymize: Option<RawInspectAnonymize>,
900) -> Option<RawInspectAnonymize> {
901 let Some(override_anonymize) = override_anonymize else {
902 return base;
903 };
904
905 let mut anonymize = base.unwrap_or_default();
906 anonymize.variables = override_anonymize.variables.or(anonymize.variables);
907 anonymize.fields = override_anonymize.fields.or(anonymize.fields);
908 anonymize.methods = override_anonymize.methods.or(anonymize.methods);
909 anonymize.types = override_anonymize.types.or(anonymize.types);
910 anonymize.literals = override_anonymize.literals.or(anonymize.literals);
911
912 (!anonymize.is_empty()).then_some(anonymize)
913}
914
915fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
916 if raw.restrict_to_project_root.is_some() {
917 push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
918 }
919 if raw.url_fetch_allow_private.is_some() {
920 push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
921 }
922 if raw.formatter_timeout_secs.is_some() {
923 push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
924 }
925 if raw.type_checker_timeout_secs.is_some() {
926 push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
927 }
928 if raw.auto_update.is_some() {
929 push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
930 }
931 if raw.bridge.is_some() {
932 push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
933 }
934 if raw.backup.is_some() {
935 push_drop(dropped, "backup", tier, USER_ONLY_REASON);
936 }
937 if raw
938 .disabled_tools
939 .as_ref()
940 .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
941 {
942 push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
943 }
944
945 if let Some(semantic) = &raw.semantic {
946 if semantic.backend.is_some() {
947 push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
948 }
949 if semantic.base_url.is_some() {
950 push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
951 }
952 if semantic.api_key_env.is_some() {
953 push_drop(
954 dropped,
955 "semantic.api_key_env",
956 tier,
957 SEMANTIC_SECRET_REASON,
958 );
959 }
960 }
961
962 if let Some(lsp) = &raw.lsp {
963 if lsp.servers.is_some() {
964 push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
965 }
966 if lsp.versions.is_some() {
967 push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
968 }
969 if lsp.auto_install.is_some() {
970 push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
971 }
972 if lsp.grace_days.is_some() {
973 push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
974 }
975 if lsp.disabled.is_some() {
976 push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
977 }
978 }
979}
980
981fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
982 dropped.push(DroppedKey {
983 key: key.to_string(),
984 tier: tier.to_string(),
985 reason: reason.to_string(),
986 });
987}
988
989fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
995 if let Some(value) = raw.format_on_edit {
996 config.format_on_edit = value;
997 }
998 if let Some(value) = raw.formatter_timeout_secs {
999 config.formatter_timeout_secs = value;
1000 }
1001 if let Some(value) = raw.type_checker_timeout_secs {
1002 config.type_checker_timeout_secs = value;
1003 }
1004 if let Some(value) = raw.validate_on_edit {
1005 config.validate_on_edit = Some(value.as_str().to_string());
1006 }
1007 if let Some(formatter) = &raw.formatter {
1008 config.formatter = formatter
1009 .iter()
1010 .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
1011 .collect();
1012 }
1013 if let Some(checker) = &raw.checker {
1014 config.checker = checker
1015 .iter()
1016 .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
1017 .collect();
1018 }
1019 if let Some(value) = raw.restrict_to_project_root {
1020 config.restrict_to_project_root = value;
1021 }
1022 if let Some(value) = raw.search_index {
1023 config.search_index = value;
1024 }
1025 if let Some(value) = raw.semantic_search {
1026 config.semantic_search = value;
1027 }
1028 if let Some(value) = raw.callgraph_store {
1029 config.callgraph_store = value;
1030 }
1031 if let Some(value) = raw.callgraph_chunk_size {
1032 config.callgraph_chunk_size = value;
1033 }
1034 if let Some(value) = raw.url_fetch_allow_private {
1035 config.url_fetch_allow_private = value;
1036 }
1037 config.semantic = resolve_semantic_config(raw.semantic.as_ref());
1038 config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1039 config.backup = resolve_backup_config(raw.backup.as_ref());
1040 resolve_lsp_config(raw, config);
1041 resolve_bash_fields(raw, config);
1042}
1043
1044fn resolve_semantic_config(raw: Option<&RawSemantic>) -> SemanticBackendConfig {
1045 let mut semantic = SemanticBackendConfig::default();
1046 let Some(raw) = raw else {
1047 return semantic;
1048 };
1049
1050 if let Some(value) = raw.backend {
1051 semantic.backend = value;
1052 }
1053 if let Some(value) = &raw.model {
1054 semantic.model = value.clone();
1055 }
1056 if let Some(value) = &raw.base_url {
1057 semantic.base_url = Some(value.clone());
1058 }
1059 if let Some(value) = &raw.api_key_env {
1060 semantic.api_key_env = Some(value.clone());
1061 }
1062 if let Some(value) = raw.timeout_ms {
1063 semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1064 }
1065 if let Some(value) = raw.max_batch_size {
1066 semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1067 }
1068 if let Some(value) = raw.max_files {
1069 semantic.max_files = value;
1070 }
1071
1072 semantic
1073}
1074
1075fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1076 let mut inspect = InspectConfig::default();
1077 let Some(raw) = raw else {
1078 return inspect;
1079 };
1080 if let Some(enabled) = raw.enabled {
1081 inspect.enabled = enabled;
1082 }
1083 if let Some(expected_mirrors) = raw
1084 .duplicates
1085 .as_ref()
1086 .and_then(|duplicates| duplicates.expected_mirrors.clone())
1087 {
1088 inspect.duplicates.expected_mirrors = expected_mirrors;
1089 }
1090 inspect
1091}
1092
1093fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1094 let mut backup = BackupConfig::default();
1095 if let Some(raw) = raw {
1096 if raw.enabled.is_some() {
1097 backup.enabled = raw.enabled;
1098 }
1099 if raw.max_depth.is_some() {
1100 backup.max_depth = raw.max_depth;
1101 }
1102 if raw.max_file_size.is_some() {
1103 backup.max_file_size = raw.max_file_size;
1104 }
1105 }
1106 backup
1107}
1108
1109fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1110 let lsp = raw.lsp.as_ref();
1111 let mut disabled: HashSet<String> = lsp
1112 .and_then(|lsp| lsp.disabled.as_ref())
1113 .into_iter()
1114 .flatten()
1115 .map(|value| value.to_ascii_lowercase())
1116 .collect();
1117 let mut experimental_ty = raw
1118 .experimental
1119 .as_ref()
1120 .and_then(|experimental| experimental.lsp_ty);
1121
1122 match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1123 RawPythonLsp::Ty => {
1124 experimental_ty = Some(true);
1125 disabled.insert("python".to_string());
1126 }
1127 RawPythonLsp::Pyright => {
1128 experimental_ty = Some(false);
1129 disabled.insert("ty".to_string());
1130 }
1131 RawPythonLsp::Auto => {}
1132 }
1133
1134 if let Some(value) = experimental_ty {
1135 config.experimental_lsp_ty = value;
1136 }
1137
1138 if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1139 config.diagnostics_on_edit = value;
1140 }
1141
1142 if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1143 config.lsp_servers = servers
1144 .iter()
1145 .map(|(id, server)| UserServerDef {
1146 id: id.clone(),
1147 extensions: server
1148 .extensions
1149 .clone()
1150 .unwrap_or_default()
1151 .into_iter()
1152 .map(|extension| extension.trim_start_matches('.').to_string())
1153 .collect(),
1154 binary: server.binary.clone().unwrap_or_default(),
1155 args: server.args.clone().unwrap_or_default(),
1156 root_markers: server
1157 .root_markers
1158 .clone()
1159 .unwrap_or_else(|| vec![".git".to_string()]),
1160 env: server.env.clone().unwrap_or_default(),
1161 initialization_options: server.initialization_options.clone(),
1162 disabled: server.disabled.unwrap_or(false),
1163 })
1164 .collect();
1165 }
1166
1167 if !disabled.is_empty() {
1168 config.disabled_lsp = disabled;
1169 }
1170}
1171
1172#[derive(Debug, Clone, PartialEq, Eq)]
1173struct ResolvedBashConfig {
1174 enabled: bool,
1175 rewrite: bool,
1176 compress: bool,
1177 background: bool,
1178 subagent_background: bool,
1179 long_running_reminder_enabled: Option<bool>,
1180 long_running_reminder_interval_ms: Option<u64>,
1181 foreground_wait_window_ms: u64,
1182}
1183
1184fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config) {
1185 let bash = resolve_bash_config(raw);
1186 let _registration_only = (bash.enabled, bash.subagent_background);
1192 config.experimental_bash_rewrite = bash.rewrite;
1193 config.experimental_bash_compress = bash.compress;
1194 config.experimental_bash_background = bash.background;
1195 config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
1196 if let Some(value) = bash.long_running_reminder_enabled {
1197 config.bash_long_running_reminder_enabled = value;
1198 }
1199 if let Some(value) = bash.long_running_reminder_interval_ms {
1200 config.bash_long_running_reminder_interval_ms = value;
1201 }
1202}
1203
1204fn resolve_bash_config(raw: &RawAftConfig) -> ResolvedBashConfig {
1205 let top = raw.bash.as_ref();
1206 let legacy = raw
1207 .experimental
1208 .as_ref()
1209 .and_then(|experimental| experimental.bash.as_ref());
1210 let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
1211 let surface_default_enabled = surface != RawToolSurface::Minimal;
1212
1213 let top_features = match top {
1214 Some(RawBash::Features(features)) => Some(features),
1215 _ => None,
1216 };
1217 let reminder_enabled = top_features
1218 .and_then(|features| features.long_running_reminder_enabled)
1219 .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
1220 let reminder_interval = top_features
1221 .and_then(|features| features.long_running_reminder_interval_ms)
1222 .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
1223 let top_subagent_background = top_features
1224 .and_then(|features| features.subagent_background)
1225 .unwrap_or(false);
1226 let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
1227 let foreground_wait_window_ms = raw_foreground_wait
1228 .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
1229 .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
1230
1231 let base = ResolvedBashConfig {
1232 enabled: false,
1233 rewrite: false,
1234 compress: false,
1235 background: false,
1236 subagent_background: false,
1237 long_running_reminder_enabled: reminder_enabled,
1238 long_running_reminder_interval_ms: reminder_interval,
1239 foreground_wait_window_ms,
1240 };
1241
1242 match top {
1243 Some(RawBash::Bool(false)) => base,
1244 Some(RawBash::Bool(true)) => ResolvedBashConfig {
1245 enabled: true,
1246 rewrite: true,
1247 compress: true,
1248 background: true,
1249 ..base
1250 },
1251 Some(RawBash::Features(features)) => ResolvedBashConfig {
1252 enabled: true,
1253 rewrite: features.rewrite.unwrap_or(true),
1254 compress: features.compress.unwrap_or(true),
1255 background: features.background.unwrap_or(true),
1256 subagent_background: top_subagent_background,
1257 ..base
1258 },
1259 None => {
1260 if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
1261 let legacy = legacy.cloned().unwrap_or_default();
1262 let rewrite = legacy.rewrite == Some(true);
1263 let compress = legacy.compress == Some(true);
1264 let background = legacy.background == Some(true);
1265 return ResolvedBashConfig {
1266 enabled: rewrite || compress || background,
1267 rewrite,
1268 compress,
1269 background,
1270 ..base
1271 };
1272 }
1273
1274 ResolvedBashConfig {
1275 enabled: surface_default_enabled,
1276 rewrite: surface_default_enabled,
1277 compress: surface_default_enabled,
1278 background: surface_default_enabled,
1279 ..base
1280 }
1281 }
1282 }
1283}
1284
1285fn deserialize_opt_trimmed_non_empty_string<'de, D>(
1286 deserializer: D,
1287) -> Result<Option<String>, D::Error>
1288where
1289 D: Deserializer<'de>,
1290{
1291 let value = Option::<String>::deserialize(deserializer)?;
1292 value
1293 .map(|value| {
1294 let trimmed = value.trim().to_string();
1295 if trimmed.is_empty() {
1296 Err(de::Error::custom("must be a non-empty string"))
1297 } else {
1298 Ok(trimmed)
1299 }
1300 })
1301 .transpose()
1302}
1303
1304fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
1305 deserializer: D,
1306) -> Result<Option<Vec<String>>, D::Error>
1307where
1308 D: Deserializer<'de>,
1309{
1310 let value = Option::<Vec<String>>::deserialize(deserializer)?;
1311 value
1312 .map(|values| {
1313 values
1314 .into_iter()
1315 .map(|value| {
1316 let trimmed = value.trim().to_string();
1317 if trimmed.is_empty() {
1318 Err(de::Error::custom("array entries must be non-empty strings"))
1319 } else {
1320 Ok(trimmed)
1321 }
1322 })
1323 .collect()
1324 })
1325 .transpose()
1326}
1327
1328fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1329where
1330 D: Deserializer<'de>,
1331{
1332 let value = Option::<Vec<String>>::deserialize(deserializer)?;
1333 value
1334 .map(|values| {
1335 if values.is_empty() {
1336 return Err(de::Error::custom(
1337 "extensions must contain at least one entry",
1338 ));
1339 }
1340 values
1341 .into_iter()
1342 .map(|value| {
1343 let trimmed = value.trim().to_string();
1344 if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
1345 Err(de::Error::custom(
1346 "extension must include characters other than leading dots",
1347 ))
1348 } else {
1349 Ok(trimmed)
1350 }
1351 })
1352 .collect()
1353 })
1354 .transpose()
1355}
1356
1357fn deserialize_opt_lsp_servers<'de, D>(
1358 deserializer: D,
1359) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
1360where
1361 D: Deserializer<'de>,
1362{
1363 let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
1364 value
1365 .map(|entries| {
1366 entries
1367 .into_iter()
1368 .map(|(key, value)| {
1369 let trimmed = key.trim().to_string();
1370 if trimmed.is_empty() {
1371 Err(de::Error::custom(
1372 "lsp.servers keys must be non-empty strings",
1373 ))
1374 } else {
1375 Ok((trimmed, value))
1376 }
1377 })
1378 .collect()
1379 })
1380 .transpose()
1381}
1382
1383fn deserialize_opt_versions_map<'de, D>(
1384 deserializer: D,
1385) -> Result<Option<HashMap<String, String>>, D::Error>
1386where
1387 D: Deserializer<'de>,
1388{
1389 let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
1390 value
1391 .map(|entries| {
1392 entries
1393 .into_iter()
1394 .map(|(key, value)| {
1395 let trimmed_key = key.trim().to_string();
1396 let trimmed_value = value.trim().to_string();
1397 if trimmed_key.is_empty() || trimmed_value.is_empty() {
1398 Err(de::Error::custom(
1399 "lsp.versions keys and values must be non-empty strings",
1400 ))
1401 } else {
1402 Ok((trimmed_key, trimmed_value))
1403 }
1404 })
1405 .collect()
1406 })
1407 .transpose()
1408}
1409
1410fn deserialize_opt_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1411where
1412 D: Deserializer<'de>,
1413{
1414 Option::<u64>::deserialize(deserializer)
1415}
1416
1417fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1418where
1419 D: Deserializer<'de>,
1420{
1421 let value = Option::<u64>::deserialize(deserializer)?;
1422 value
1423 .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1424 .transpose()
1425}
1426
1427fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1428where
1429 D: Deserializer<'de>,
1430{
1431 let value = Option::<u64>::deserialize(deserializer)?;
1432 match value {
1433 Some(0) => Err(de::Error::custom("must be a positive integer")),
1434 other => Ok(other),
1435 }
1436}
1437
1438fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1439where
1440 D: Deserializer<'de>,
1441{
1442 let value = deserialize_opt_positive_u64(deserializer)?;
1443 value
1444 .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1445 .transpose()
1446}
1447
1448fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1449where
1450 D: Deserializer<'de>,
1451{
1452 let value = Option::<u64>::deserialize(deserializer)?;
1453 match value {
1454 Some(value) if !(1..=600).contains(&value) => {
1455 Err(de::Error::custom("timeout must be in 1..=600 seconds"))
1456 }
1457 Some(value) => u32::try_from(value)
1458 .map(Some)
1459 .map_err(|_| de::Error::custom("timeout is too large")),
1460 None => Ok(None),
1461 }
1462}
1463
1464fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
1465 deserializer: D,
1466) -> Result<Option<u64>, D::Error>
1467where
1468 D: Deserializer<'de>,
1469{
1470 let value = Option::<u64>::deserialize(deserializer)?;
1471 match value {
1472 Some(value) if value < 1_000 => Err(de::Error::custom(
1473 "bridge.request_timeout_ms must be at least 1000",
1474 )),
1475 other => Ok(other),
1476 }
1477}
1478
1479fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1480where
1481 D: Deserializer<'de>,
1482{
1483 let value = Option::<f64>::deserialize(deserializer)?;
1484 match value {
1485 Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
1486 other => Ok(other),
1487 }
1488}
1489
1490fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1491where
1492 D: Deserializer<'de>,
1493{
1494 let value = Option::<u64>::deserialize(deserializer)?;
1495 match value {
1496 Some(value) if value == 0 || value > 100 => {
1497 Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
1498 }
1499 Some(value) => usize::try_from(value)
1500 .map(Some)
1501 .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
1502 None => Ok(None),
1503 }
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 use super::*;
1509
1510 fn tier(tier: &str, doc: &str) -> ConfigTier {
1511 ConfigTier {
1512 tier: tier.to_string(),
1513 source: format!("/tmp/{tier}/aft.jsonc"),
1514 doc: doc.to_string(),
1515 }
1516 }
1517
1518 fn drop_keys(result: &ResolveResult) -> Vec<String> {
1519 result
1520 .dropped
1521 .iter()
1522 .map(|dropped| dropped.key.clone())
1523 .collect()
1524 }
1525
1526 #[test]
1533 fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
1534 let nested = resolve_config(&[tier(
1540 "user",
1541 r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
1542 )]);
1543 assert!(nested.config.experimental_bash_rewrite);
1544 assert!(nested.config.experimental_bash_compress);
1545 assert!(nested.config.experimental_bash_background);
1546
1547 let smuggle = resolve_config(&[
1551 tier("user", r#"{ "search_index": true }"#),
1552 tier(
1553 "project",
1554 r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
1555 ),
1556 ]);
1557 assert!(!smuggle.config.search_index);
1559 assert!(smuggle.config.storage_dir.is_none());
1561 assert!(!smuggle.config.bash_permissions);
1562 }
1563
1564 #[test]
1565 fn config_resolve_empty_tiers_applies_bash_surface_default() {
1566 let result = resolve_config(&[]);
1571 let default_config = Config::default();
1572
1573 assert!(result.dropped.is_empty());
1574 assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
1576 assert_eq!(result.config.search_index, default_config.search_index);
1577 assert_eq!(
1578 result.config.semantic_search,
1579 default_config.semantic_search
1580 );
1581 assert_eq!(result.config.semantic, default_config.semantic);
1582 assert_eq!(
1583 result.config.inspect.enabled,
1584 default_config.inspect.enabled
1585 );
1586 assert_eq!(result.config.lsp_servers.len(), 0);
1587 assert!(result.config.experimental_bash_rewrite);
1589 assert!(result.config.experimental_bash_compress);
1590 assert!(result.config.experimental_bash_background);
1591 }
1592
1593 #[test]
1594 fn config_resolve_user_only_config_applies_fields() {
1595 let result = resolve_config(&[tier(
1596 "user",
1597 r#"{
1598 "$schema": "https://example.test/aft.schema.json",
1599 "format_on_edit": false,
1600 "formatter_timeout_secs": 42,
1601 "type_checker_timeout_secs": 43,
1602 "validate_on_edit": "full",
1603 "formatter": { "rust": "rustfmt", "typescript": "prettier" },
1604 "checker": { "rust": "cargo", "typescript": "tsc" },
1605 "restrict_to_project_root": true,
1606 "search_index": true,
1607 "semantic_search": true,
1608 "callgraph_store": false,
1609 "callgraph_chunk_size": 17,
1610 "url_fetch_allow_private": true,
1611 "semantic": {
1612 "backend": "openai_compatible",
1613 "model": " user-model ",
1614 "base_url": "https://semantic.example.test",
1615 "api_key_env": "AFT_API_KEY",
1616 "timeout_ms": 12345,
1617 "max_batch_size": 12,
1618 "max_files": 3456
1619 },
1620 "inspect": { "enabled": false },
1621 "experimental": { "lsp_ty": true },
1622 "lsp": {
1623 "servers": {
1624 "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1625 },
1626 "disabled": ["Python"],
1627 "python": "pyright"
1628 },
1629 "bash": { "rewrite": false, "compress": true, "background": false,
1630 "long_running_reminder_enabled": false,
1631 "long_running_reminder_interval_ms": 123000 }
1632 }"#,
1633 )]);
1634
1635 assert!(result.dropped.is_empty());
1636 assert!(!result.config.format_on_edit);
1637 assert_eq!(result.config.formatter_timeout_secs, 42);
1638 assert_eq!(result.config.type_checker_timeout_secs, 43);
1639 assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
1640 assert_eq!(
1641 result.config.formatter.get("rust").map(String::as_str),
1642 Some("rustfmt")
1643 );
1644 assert_eq!(
1645 result.config.checker.get("typescript").map(String::as_str),
1646 Some("tsc")
1647 );
1648 assert!(result.config.restrict_to_project_root);
1649 assert!(result.config.search_index);
1650 assert!(result.config.semantic_search);
1651 assert!(!result.config.callgraph_store);
1652 assert_eq!(result.config.callgraph_chunk_size, 17);
1653 assert!(result.config.url_fetch_allow_private);
1654 assert_eq!(
1655 result.config.semantic.backend,
1656 SemanticBackend::OpenAiCompatible
1657 );
1658 assert_eq!(result.config.semantic.model, "user-model");
1659 assert_eq!(
1660 result.config.semantic.base_url.as_deref(),
1661 Some("https://semantic.example.test")
1662 );
1663 assert_eq!(
1664 result.config.semantic.api_key_env.as_deref(),
1665 Some("AFT_API_KEY")
1666 );
1667 assert_eq!(result.config.semantic.timeout_ms, 12345);
1668 assert_eq!(result.config.semantic.max_batch_size, 12);
1669 assert_eq!(result.config.semantic.max_files, 3456);
1670 assert!(!result.config.inspect.enabled);
1671 assert!(!result.config.experimental_lsp_ty);
1672 assert!(result.config.disabled_lsp.contains("ty"));
1673 assert_eq!(result.config.lsp_servers.len(), 1);
1674 assert_eq!(result.config.lsp_servers[0].id, "rust");
1675 assert_eq!(
1676 result.config.lsp_servers[0].extensions,
1677 vec!["rs".to_string()]
1678 );
1679 assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1680 assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
1681 assert_eq!(
1682 result.config.lsp_servers[0].root_markers,
1683 vec![".git".to_string()]
1684 );
1685 assert!(!result.config.experimental_bash_rewrite);
1686 assert!(result.config.experimental_bash_compress);
1687 assert!(!result.config.experimental_bash_background);
1688 assert!(!result.config.bash_long_running_reminder_enabled);
1689 assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
1690 }
1691
1692 #[test]
1693 fn config_resolve_project_allowed_search_index_wins() {
1694 let result = resolve_config(&[
1695 tier("user", r#"{ "search_index": false }"#),
1696 tier("project", r#"{ "search_index": true }"#),
1697 ]);
1698
1699 assert!(result.config.search_index);
1700 assert!(result.dropped.is_empty());
1701 }
1702
1703 #[test]
1704 fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
1705 let result = resolve_config(&[
1706 tier(
1707 "user",
1708 r#"{
1709 "restrict_to_project_root": true,
1710 "url_fetch_allow_private": true,
1711 "formatter_timeout_secs": 11,
1712 "type_checker_timeout_secs": 33,
1713 "auto_update": true,
1714 "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
1715 "semantic": {
1716 "backend": "openai_compatible",
1717 "base_url": "https://user.example.test",
1718 "api_key_env": "USER_KEY",
1719 "model": "user-model"
1720 },
1721 "lsp": {
1722 "servers": {
1723 "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1724 },
1725 "disabled": ["user-disabled"],
1726 "versions": { "typescript-language-server": "1.0.0" },
1727 "auto_install": true,
1728 "grace_days": 7
1729 }
1730 }"#,
1731 ),
1732 tier(
1733 "project",
1734 r#"{
1735 "restrict_to_project_root": false,
1736 "url_fetch_allow_private": false,
1737 "formatter_timeout_secs": 22,
1738 "type_checker_timeout_secs": 44,
1739 "auto_update": false,
1740 "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
1741 "semantic": {
1742 "backend": "ollama",
1743 "base_url": "https://project.example.test",
1744 "api_key_env": "PROJECT_KEY",
1745 "model": "project-model",
1746 "timeout_ms": 2222
1747 },
1748 "lsp": {
1749 "servers": {
1750 "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
1751 },
1752 "disabled": ["project-disabled"],
1753 "versions": { "evil-lsp": "9.9.9" },
1754 "auto_install": false,
1755 "grace_days": 1,
1756 "python": "ty"
1757 }
1758 }"#,
1759 ),
1760 ]);
1761
1762 assert!(result.config.restrict_to_project_root);
1763 assert!(result.config.url_fetch_allow_private);
1764 assert_eq!(result.config.formatter_timeout_secs, 11);
1765 assert_eq!(result.config.type_checker_timeout_secs, 33);
1766 assert_eq!(
1767 result.config.semantic.backend,
1768 SemanticBackend::OpenAiCompatible
1769 );
1770 assert_eq!(
1771 result.config.semantic.base_url.as_deref(),
1772 Some("https://user.example.test")
1773 );
1774 assert_eq!(
1775 result.config.semantic.api_key_env.as_deref(),
1776 Some("USER_KEY")
1777 );
1778 assert_eq!(result.config.semantic.model, "project-model");
1779 assert_eq!(result.config.semantic.timeout_ms, 2222);
1780 assert_eq!(result.config.lsp_servers.len(), 1);
1781 assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1782 assert!(result.config.disabled_lsp.contains("user-disabled"));
1783 assert!(!result.config.disabled_lsp.contains("project-disabled"));
1784 assert!(result.config.disabled_lsp.contains("python"));
1785 assert!(result.config.experimental_lsp_ty);
1786
1787 let keys = drop_keys(&result);
1788 let expected = [
1789 "restrict_to_project_root",
1790 "url_fetch_allow_private",
1791 "formatter_timeout_secs",
1792 "type_checker_timeout_secs",
1793 "auto_update",
1794 "bridge",
1795 "semantic.backend",
1796 "semantic.base_url",
1797 "semantic.api_key_env",
1798 "lsp.servers",
1799 "lsp.versions",
1800 "lsp.auto_install",
1801 "lsp.grace_days",
1802 "lsp.disabled",
1803 ];
1804 for key in expected {
1805 assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
1806 }
1807 assert_eq!(keys.len(), expected.len());
1808 assert!(result
1809 .dropped
1810 .iter()
1811 .all(|dropped| dropped.tier == "project"));
1812 }
1813
1814 #[test]
1815 fn config_resolve_bash_ladder_and_merge_parity() {
1816 let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
1817 assert!(true_result.config.experimental_bash_rewrite);
1818 assert!(true_result.config.experimental_bash_compress);
1819 assert!(true_result.config.experimental_bash_background);
1820
1821 let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
1822 assert!(!false_result.config.experimental_bash_rewrite);
1823 assert!(!false_result.config.experimental_bash_compress);
1824 assert!(!false_result.config.experimental_bash_background);
1825
1826 let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
1827 assert!(object_default_result.config.experimental_bash_rewrite);
1828 assert!(object_default_result.config.experimental_bash_compress);
1829 assert!(object_default_result.config.experimental_bash_background);
1830
1831 let object_partial_result =
1832 resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
1833 assert!(object_partial_result.config.experimental_bash_rewrite);
1834 assert!(!object_partial_result.config.experimental_bash_compress);
1835 assert!(object_partial_result.config.experimental_bash_background);
1836
1837 let legacy_result = resolve_config(&[tier(
1838 "user",
1839 r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
1840 )]);
1841 assert!(legacy_result.config.experimental_bash_rewrite);
1842 assert!(!legacy_result.config.experimental_bash_compress);
1843 assert!(!legacy_result.config.experimental_bash_background);
1844
1845 let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
1846 assert!(surface_default_result.config.experimental_bash_rewrite);
1847 assert!(surface_default_result.config.experimental_bash_compress);
1848 assert!(surface_default_result.config.experimental_bash_background);
1849
1850 let minimal_surface_result =
1851 resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
1852 assert!(!minimal_surface_result.config.experimental_bash_rewrite);
1853 assert!(!minimal_surface_result.config.experimental_bash_compress);
1854 assert!(!minimal_surface_result.config.experimental_bash_background);
1855
1856 let merged_result = resolve_config(&[
1857 tier("user", r#"{ "bash": true }"#),
1858 tier("project", r#"{ "bash": { "compress": false } }"#),
1859 ]);
1860 assert!(merged_result.config.experimental_bash_rewrite);
1861 assert!(!merged_result.config.experimental_bash_compress);
1862 assert!(merged_result.config.experimental_bash_background);
1863
1864 let false_then_object_result = resolve_config(&[
1865 tier("user", r#"{ "bash": false }"#),
1866 tier("project", r#"{ "bash": { "compress": true } }"#),
1867 ]);
1868 assert!(!false_then_object_result.config.experimental_bash_rewrite);
1869 assert!(false_then_object_result.config.experimental_bash_compress);
1870 assert!(!false_then_object_result.config.experimental_bash_background);
1871 }
1872
1873 #[test]
1874 fn config_resolve_bash_foreground_wait_clamps_to_floor() {
1875 let Some(raw) = parse_tier(&tier(
1876 "user",
1877 r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
1878 )) else {
1879 panic!("test tier should parse");
1880 };
1881 let bash = resolve_bash_config(&raw);
1882
1883 assert_eq!(
1884 bash.foreground_wait_window_ms,
1885 FOREGROUND_WAIT_WINDOW_MIN_MS
1886 );
1887 assert!(bash.subagent_background);
1888
1889 let result = resolve_config(&[tier(
1890 "user",
1891 r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
1892 )]);
1893 assert_eq!(
1894 result.config.foreground_wait_window_ms,
1895 FOREGROUND_WAIT_WINDOW_MIN_MS
1896 );
1897
1898 let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
1903 assert_eq!(
1904 defaulted.config.foreground_wait_window_ms,
1905 FOREGROUND_WAIT_WINDOW_DEFAULT_MS
1906 );
1907 assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
1908 }
1909
1910 #[test]
1911 fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
1912 let result = resolve_config(&[tier(
1913 "user",
1914 r#"{
1915 "semantic": { "timeout_ms": 0 },
1916 "search_index": true,
1917 "format_on_edit": false
1918 }"#,
1919 )]);
1920
1921 assert!(result.config.search_index);
1922 assert!(!result.config.format_on_edit);
1923 assert_eq!(result.config.semantic, SemanticBackendConfig::default());
1924 assert!(result.dropped.is_empty());
1925 }
1926
1927 #[test]
1928 fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
1929 let result = resolve_config(&[tier(
1930 "user",
1931 r#"{ "not_a_real_key": true, "search_index": true }"#,
1932 )]);
1933
1934 assert!(result.config.search_index);
1935 assert!(result.dropped.is_empty());
1936 }
1937
1938 #[test]
1939 fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
1940 let mut config = Config::default();
1946
1947 let dropped1 = resolve_config_onto(
1950 &[tier(
1951 "user",
1952 r#"{
1953 "url_fetch_allow_private": true,
1954 "restrict_to_project_root": true,
1955 "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
1956 }"#,
1957 )],
1958 &mut config,
1959 );
1960 assert!(dropped1.is_empty());
1961 assert!(config.url_fetch_allow_private);
1962 assert!(config.restrict_to_project_root);
1963 assert_eq!(config.lsp_servers.len(), 1);
1964
1965 let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
1968 assert!(
1969 !config.url_fetch_allow_private,
1970 "url_fetch_allow_private must reset to default, not inherit prior bind"
1971 );
1972 assert!(
1973 !config.restrict_to_project_root,
1974 "restrict_to_project_root must reset to default"
1975 );
1976 assert!(
1977 config.lsp_servers.is_empty(),
1978 "lsp_servers must reset to default, not inherit prior bind's custom server"
1979 );
1980 assert!(config.search_index, "this bind's own field still applies");
1981 }
1982
1983 #[test]
1984 fn resolve_config_onto_empty_tiers_resets_to_default() {
1985 let mut config = Config::default();
1989 let _ = resolve_config_onto(
1990 &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
1991 &mut config,
1992 );
1993 assert!(config.url_fetch_allow_private);
1994
1995 let _ = resolve_config_onto(&[], &mut config);
1996 assert!(
1997 !config.url_fetch_allow_private,
1998 "empty-tier bind must reset core config to default"
1999 );
2000 }
2001
2002 #[test]
2003 fn resolve_config_onto_preserves_process_state_fields() {
2004 let mut config = Config {
2008 storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
2009 lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
2010 bash_permissions: true,
2011 project_root: Some(std::path::PathBuf::from("/tmp/proj")),
2012 ..Default::default()
2013 };
2014
2015 let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2016
2017 assert_eq!(
2018 config.storage_dir,
2019 Some(std::path::PathBuf::from("/tmp/aft-store"))
2020 );
2021 assert_eq!(
2022 config.lsp_paths_extra,
2023 vec![std::path::PathBuf::from("/tmp/lsp-bin")]
2024 );
2025 assert!(config.bash_permissions);
2026 assert_eq!(
2027 config.project_root,
2028 Some(std::path::PathBuf::from("/tmp/proj"))
2029 );
2030 assert!(config.search_index);
2031 }
2032
2033 #[test]
2034 fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
2035 let result = resolve_config(&[tier(
2036 "user",
2037 r#"{
2038 // line comment
2039 "search_index": true,
2040 "formatter": {
2041 "rust": "rustfmt", /* block comment */
2042 },
2043 }"#,
2044 )]);
2045
2046 assert!(result.config.search_index);
2047 assert_eq!(
2048 result.config.formatter.get("rust").map(String::as_str),
2049 Some("rustfmt")
2050 );
2051 }
2052}