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