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