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