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