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