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