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