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