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