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