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