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