Skip to main content

aft/
config_resolve.rs

1//! Pure aft.jsonc tier resolver.
2//!
3//! This module mirrors the TypeScript config pipeline for the core-consumed
4//! slice: raw JSONC tiers -> strict raw schema -> user/project trust merge ->
5//! flat [`Config`]. It intentionally performs no IO; callers supply the already
6//! read config documents.
7
8use std::collections::{BTreeMap, HashMap, HashSet};
9
10use serde::de;
11use serde::{Deserialize, Deserializer};
12use serde_json::{Map, Value};
13
14use crate::config::{
15    BackupConfig, Config, InspectConfig, SemanticBackend, SemanticBackendConfig, UserServerDef,
16};
17use crate::jsonc::strip_jsonc;
18
19const FOREGROUND_WAIT_WINDOW_DEFAULT_MS: u64 = 15_000;
20const FOREGROUND_WAIT_WINDOW_MIN_MS: u64 = 5_000;
21
22// Semantic budget clamps — restored from the deleted configure-time
23// parse_semantic_config so the tier-resolved Config matches the historical
24// clamping (zero-behavior-change relocation, not a new policy).
25const MAX_SEMANTIC_TIMEOUT_MS: u64 = 120_000;
26const MAX_SEMANTIC_BATCH_SIZE: usize = 1_024;
27
28const USER_ONLY_REASON: &str =
29    "security: this setting only honors user-level config and project values are ignored";
30const SEMANTIC_SECRET_REASON: &str =
31    "security: semantic backend credentials and endpoints must come from user-level config";
32const LSP_USER_ONLY_REASON: &str =
33    "security: LSP executable-origin and diagnostic-suppression settings must come from user-level config";
34
35/// One raw config document supplied by the host plugin.
36///
37/// `tier` is trusted process metadata stamped by the caller. The document body is
38/// never allowed to relabel itself.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ConfigTier {
41    pub tier: String,
42    pub source: String,
43    pub doc: String,
44}
45
46/// A project-tier key that was intentionally ignored at the user/project trust boundary.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct DroppedKey {
49    pub key: String,
50    pub tier: String,
51    pub reason: String,
52}
53
54/// Fully resolved core config plus trust-boundary diagnostics.
55#[derive(Debug, Clone)]
56pub struct ResolveResult {
57    pub config: Config,
58    pub dropped: Vec<DroppedKey>,
59}
60
61/// Strict raw shape for aft.jsonc. This mirrors the TypeScript Zod schema, not
62/// the flat runtime [`Config`]. Privileged process-state fields are deliberately
63/// absent and therefore rejected by `deny_unknown_fields`.
64#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
65#[serde(default, deny_unknown_fields)]
66pub struct RawAftConfig {
67    #[serde(rename = "$schema")]
68    pub schema: Option<String>,
69    /// Master switch read by the TypeScript plugins before they start AFT.
70    /// The resolver accepts and merges it for validation, but does not copy it
71    /// into `Config`; when this is false the plugin returns before launching the
72    /// Rust process.
73    pub enabled: Option<bool>,
74    pub format_on_edit: Option<bool>,
75    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
76    pub formatter_timeout_secs: Option<u32>,
77    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
78    pub type_checker_timeout_secs: Option<u32>,
79    pub validate_on_edit: Option<RawValidateOnEdit>,
80    pub formatter: Option<HashMap<String, RawFormatter>>,
81    pub checker: Option<HashMap<String, RawChecker>>,
82    pub configure_warnings_delivery: Option<RawConfigureWarningsDelivery>,
83    pub hoist_builtin_tools: Option<bool>,
84    pub tool_surface: Option<RawToolSurface>,
85    pub disabled_tools: Option<Vec<String>>,
86    pub restrict_to_project_root: Option<bool>,
87    pub search_index: Option<bool>,
88    pub semantic_search: Option<bool>,
89    pub callgraph_store: Option<bool>,
90    #[serde(deserialize_with = "deserialize_opt_usize")]
91    pub callgraph_chunk_size: Option<usize>,
92    pub inspect: Option<RawInspect>,
93    pub backup: Option<RawBackup>,
94    pub bash: Option<RawBash>,
95    pub experimental: Option<RawExperimental>,
96    pub lsp: Option<RawLsp>,
97    pub url_fetch_allow_private: Option<bool>,
98    pub semantic: Option<RawSemantic>,
99    pub auto_update: Option<bool>,
100    pub bridge: Option<RawBridge>,
101}
102
103#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
104#[serde(rename_all = "snake_case")]
105pub enum RawValidateOnEdit {
106    Syntax,
107    Full,
108}
109
110impl RawValidateOnEdit {
111    const fn as_str(self) -> &'static str {
112        match self {
113            Self::Syntax => "syntax",
114            Self::Full => "full",
115        }
116    }
117}
118
119#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "snake_case")]
121pub enum RawFormatter {
122    Biome,
123    Oxfmt,
124    Prettier,
125    Deno,
126    Ruff,
127    Black,
128    Rustfmt,
129    Goimports,
130    Gofmt,
131    None,
132}
133
134impl RawFormatter {
135    const fn as_str(self) -> &'static str {
136        match self {
137            Self::Biome => "biome",
138            Self::Oxfmt => "oxfmt",
139            Self::Prettier => "prettier",
140            Self::Deno => "deno",
141            Self::Ruff => "ruff",
142            Self::Black => "black",
143            Self::Rustfmt => "rustfmt",
144            Self::Goimports => "goimports",
145            Self::Gofmt => "gofmt",
146            Self::None => "none",
147        }
148    }
149}
150
151#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "snake_case")]
153pub enum RawChecker {
154    Tsc,
155    Tsgo,
156    Biome,
157    Pyright,
158    Ruff,
159    Cargo,
160    Go,
161    Staticcheck,
162    None,
163}
164
165impl RawChecker {
166    const fn as_str(self) -> &'static str {
167        match self {
168            Self::Tsc => "tsc",
169            Self::Tsgo => "tsgo",
170            Self::Biome => "biome",
171            Self::Pyright => "pyright",
172            Self::Ruff => "ruff",
173            Self::Cargo => "cargo",
174            Self::Go => "go",
175            Self::Staticcheck => "staticcheck",
176            Self::None => "none",
177        }
178    }
179}
180
181#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
182#[serde(rename_all = "snake_case")]
183pub enum RawConfigureWarningsDelivery {
184    Toast,
185    Log,
186    Chat,
187}
188
189#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
190#[serde(rename_all = "snake_case")]
191pub enum RawToolSurface {
192    Minimal,
193    Recommended,
194    All,
195}
196
197#[derive(Debug, Clone, Deserialize, PartialEq)]
198// Nested objects mirror TS sub-schemas, which are non-strict z.object (unknown
199// keys are silently stripped, the object survives). Only the TOP-LEVEL
200// RawAftConfig is strict (matches AftConfigSchema.strict()). Privileged denylist
201// fields are all top-level, so nested unknowns are harmlessly ignored.
202pub struct RawSemantic {
203    pub backend: Option<SemanticBackend>,
204    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
205    pub model: Option<String>,
206    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
207    pub base_url: Option<String>,
208    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
209    pub api_key_env: Option<String>,
210    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
211    pub timeout_ms: Option<u64>,
212    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
213    pub max_batch_size: Option<usize>,
214    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
215    pub max_files: Option<usize>,
216}
217
218impl RawSemantic {
219    fn is_empty(&self) -> bool {
220        self.backend.is_none()
221            && self.model.is_none()
222            && self.base_url.is_none()
223            && self.api_key_env.is_none()
224            && self.timeout_ms.is_none()
225            && self.max_batch_size.is_none()
226            && self.max_files.is_none()
227    }
228}
229
230#[derive(Debug, Clone, Deserialize, PartialEq)]
231pub struct RawLsp {
232    #[serde(default, deserialize_with = "deserialize_opt_lsp_servers")]
233    pub servers: Option<BTreeMap<String, RawLspServerEntry>>,
234    #[serde(
235        default,
236        deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec"
237    )]
238    pub disabled: Option<Vec<String>>,
239    pub python: Option<RawPythonLsp>,
240    pub diagnostics_on_edit: Option<bool>,
241    pub auto_install: Option<bool>,
242    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
243    pub grace_days: Option<u64>,
244    #[serde(default, deserialize_with = "deserialize_opt_versions_map")]
245    pub versions: Option<HashMap<String, String>>,
246}
247
248impl RawLsp {
249    fn is_empty(&self) -> bool {
250        self.servers.is_none()
251            && self.disabled.is_none()
252            && self.python.is_none()
253            && self.diagnostics_on_edit.is_none()
254            && self.auto_install.is_none()
255            && self.grace_days.is_none()
256            && self.versions.is_none()
257    }
258}
259
260#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
261#[serde(rename_all = "snake_case")]
262pub enum RawPythonLsp {
263    Pyright,
264    Ty,
265    Auto,
266}
267
268#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
269#[serde(default)]
270pub struct RawLspServerEntry {
271    #[serde(deserialize_with = "deserialize_opt_lsp_extensions")]
272    pub extensions: Option<Vec<String>>,
273    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
274    pub binary: Option<String>,
275    pub args: Option<Vec<String>>,
276    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec")]
277    pub root_markers: Option<Vec<String>>,
278    pub disabled: Option<bool>,
279    pub env: Option<HashMap<String, String>>,
280    pub initialization_options: Option<Value>,
281}
282
283#[derive(Debug, Clone, Deserialize, PartialEq)]
284#[serde(untagged)]
285pub enum RawBash {
286    Bool(bool),
287    Features(RawBashFeatures),
288}
289
290#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
291#[serde(default)]
292pub struct RawBashFeatures {
293    pub rewrite: Option<bool>,
294    pub compress: Option<bool>,
295    pub background: Option<bool>,
296    pub subagent_background: Option<bool>,
297    pub long_running_reminder_enabled: Option<bool>,
298    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
299    pub long_running_reminder_interval_ms: Option<u64>,
300    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
301    pub foreground_wait_window_ms: Option<u64>,
302}
303
304#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
305#[serde(default)]
306pub struct RawExperimental {
307    pub bash: Option<RawExperimentalBash>,
308    pub lsp_ty: Option<bool>,
309}
310
311impl RawExperimental {
312    fn is_empty(&self) -> bool {
313        self.bash.is_none() && self.lsp_ty.is_none()
314    }
315}
316
317#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
318#[serde(default)]
319pub struct RawExperimentalBash {
320    pub rewrite: Option<bool>,
321    pub compress: Option<bool>,
322    pub background: Option<bool>,
323    pub long_running_reminder_enabled: Option<bool>,
324    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
325    pub long_running_reminder_interval_ms: Option<u64>,
326}
327
328impl RawExperimentalBash {
329    fn has_any_value(&self) -> bool {
330        self.rewrite.is_some()
331            || self.compress.is_some()
332            || self.background.is_some()
333            || self.long_running_reminder_enabled.is_some()
334            || self.long_running_reminder_interval_ms.is_some()
335    }
336
337    fn has_legacy_feature_flag(&self) -> bool {
338        self.rewrite.is_some() || self.compress.is_some() || self.background.is_some()
339    }
340}
341
342#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
343#[serde(default)]
344pub struct RawInspect {
345    pub enabled: Option<bool>,
346    #[serde(deserialize_with = "deserialize_opt_nonnegative_f64")]
347    pub tier2_idle_minutes: Option<f64>,
348    pub categories: Option<HashMap<String, bool>>,
349    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
350    pub tier2_soft_deadline_ms: Option<u64>,
351    #[serde(deserialize_with = "deserialize_opt_drill_down_items")]
352    pub max_drill_down_items: Option<usize>,
353    pub duplicates: Option<RawInspectDuplicates>,
354}
355
356impl RawInspect {
357    fn is_empty(&self) -> bool {
358        self.enabled.is_none()
359            && self.tier2_idle_minutes.is_none()
360            && self.categories.is_none()
361            && self.tier2_soft_deadline_ms.is_none()
362            && self.max_drill_down_items.is_none()
363            && self.duplicates.is_none()
364    }
365}
366
367#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
368#[serde(default)]
369pub struct RawInspectDuplicates {
370    #[serde(deserialize_with = "deserialize_opt_positive_usize")]
371    pub lower_bound: Option<usize>,
372    #[serde(deserialize_with = "deserialize_opt_u64")]
373    pub discard_cost: Option<u64>,
374    pub anonymize: Option<RawInspectAnonymize>,
375    pub expected_mirrors: Option<Vec<[String; 2]>>,
376}
377
378impl RawInspectDuplicates {
379    fn is_empty(&self) -> bool {
380        self.lower_bound.is_none()
381            && self.discard_cost.is_none()
382            && self.anonymize.is_none()
383            && self.expected_mirrors.is_none()
384    }
385}
386
387#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
388#[serde(default)]
389pub struct RawInspectAnonymize {
390    pub variables: Option<bool>,
391    pub fields: Option<bool>,
392    pub methods: Option<bool>,
393    pub types: Option<bool>,
394    pub literals: Option<bool>,
395}
396
397impl RawInspectAnonymize {
398    fn is_empty(&self) -> bool {
399        self.variables.is_none()
400            && self.fields.is_none()
401            && self.methods.is_none()
402            && self.types.is_none()
403            && self.literals.is_none()
404    }
405}
406
407#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
408#[serde(default)]
409pub struct RawBridge {
410    #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
411    pub request_timeout_ms: Option<u64>,
412    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
413    pub hang_threshold: Option<u64>,
414}
415
416#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
417#[serde(default)]
418pub struct RawBackup {
419    pub enabled: Option<bool>,
420    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
421    pub max_depth: Option<usize>,
422    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
423    pub max_file_size: Option<u64>,
424}
425
426/// Resolve raw user/project config tiers into the flat core [`Config`].
427///
428/// Empty input is NOT special-cased: no config file is equivalent to an empty
429/// config object, so it still flows through the resolver and picks up the bash
430/// surface default (recommended ⇒ bash on), matching the TypeScript pipeline
431/// which always runs `resolveProjectOverridesForConfigure` even on `{}`.
432pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
433    let mut merged = RawAftConfig::default();
434    let mut dropped = Vec::new();
435
436    for tier in tiers {
437        let Some(raw) = parse_tier(tier) else {
438            continue;
439        };
440
441        if tier.tier == "user" {
442            merge_trusted_config(&mut merged, raw);
443        } else {
444            record_project_drops(&raw, &tier.tier, &mut dropped);
445            merge_project_config(&mut merged, raw);
446        }
447    }
448
449    let mut config = Config::default();
450    apply_resolved_config(&merged, &mut config);
451    ResolveResult { config, dropped }
452}
453
454/// Resolve raw config tiers into the core-domain config and RESET it onto an
455/// existing `base`, preserving only `base`'s process-state fields (storage_dir,
456/// harness, lsp_paths_extra, bash_permissions, …). This is the configure-path
457/// entry.
458///
459/// RESET, not overlay: the core-domain config is rebuilt from DEFAULT + the
460/// supplied tiers, so a field absent from the tiers returns to its default —
461/// it NEVER keeps `base`'s prior value. This closes a cross-bind privilege
462/// escalation: under the subc daemon a single `AppContext` per project root is
463/// shared across harness identities, and `configure` seeds `base` from the
464/// previous bind's config. With the old overlay semantics, a later low-trust
465/// bind (e.g. `mcp:*`) that omitted a field inherited an earlier high-trust
466/// bind's capability for it (confirmed on the wire: `url_fetch_allow_private`
467/// SSRF, and `lsp_servers` arbitrary-binary). Reset-onto-default makes the
468/// resolved core config a pure function of THIS bind's own tiers.
469///
470/// Parity-safe by construction: this routes through [`resolve_config`] (the same
471/// entry the cross-language parity gate validates), which builds onto
472/// `Config::default()` — so reset-onto-default == overlay-onto-default there and
473/// no parity/unit fixture changes. Only this configure path, seeded from a prior
474/// config, changes behavior — which is exactly the leak site.
475///
476/// Process-state fields are not part of `RawAftConfig`; they are carried from
477/// `base` here and re-applied by `handle_configure`'s flat-param parsing
478/// afterwards, so plugin-mode behavior is unchanged (the plugin re-sends them on
479/// every configure). They are also unreachable as a subc escalation vector: a
480/// subc RouteBind sends only `config:[tiers]`, never the flat process-state
481/// params, so they stay at default for every subc bind regardless.
482pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
483    let ResolveResult {
484        mut config,
485        dropped,
486    } = resolve_config(tiers);
487    carry_process_state(base, &mut config);
488    *base = config;
489    dropped
490}
491
492/// Carry the process-state (non-`RawAftConfig`) fields from `base` onto a
493/// freshly-resolved core config. EVERY `Config` field not copied here is
494/// core-domain and intentionally comes from the resolved tiers (reset). Keeping
495/// this list complete is load-bearing: a core field accidentally copied here
496/// would re-introduce cross-bind inheritance for it.
497fn carry_process_state(base: &Config, resolved: &mut Config) {
498    resolved.project_root = base.project_root.clone();
499    resolved.harness = base.harness.clone();
500    resolved.validation_depth = base.validation_depth;
501    resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
502    resolved.max_symbol_depth = base.max_symbol_depth;
503    resolved.diagnostic_cache_size = base.diagnostic_cache_size;
504    resolved.aft_search_registered = base.aft_search_registered;
505    resolved.max_background_bash_tasks = base.max_background_bash_tasks;
506    resolved.bash_permissions = base.bash_permissions;
507    resolved.search_index_max_file_size = base.search_index_max_file_size;
508    resolved.storage_dir = base.storage_dir.clone();
509    resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
510    resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
511    resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
512}
513
514fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
515    let stripped = strip_jsonc(&tier.doc);
516    let value = serde_json::from_str::<Value>(&stripped).ok()?;
517    let Value::Object(map) = value else {
518        return None;
519    };
520
521    match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
522        Ok(config) => Some(config),
523        Err(_) => Some(parse_config_partially(map)),
524    }
525}
526
527fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
528    let mut partial = RawAftConfig::default();
529
530    for (key, value) in raw_config {
531        let mut one_field = Map::new();
532        one_field.insert(key, value);
533        if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
534            merge_trusted_config(&mut partial, section);
535        }
536    }
537
538    partial
539}
540
541fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
542    if override_config.schema.is_some() {
543        base.schema = override_config.schema;
544    }
545    if override_config.enabled.is_some() {
546        base.enabled = override_config.enabled;
547    }
548    if override_config.format_on_edit.is_some() {
549        base.format_on_edit = override_config.format_on_edit;
550    }
551    if override_config.formatter_timeout_secs.is_some() {
552        base.formatter_timeout_secs = override_config.formatter_timeout_secs;
553    }
554    if override_config.type_checker_timeout_secs.is_some() {
555        base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
556    }
557    if override_config.validate_on_edit.is_some() {
558        base.validate_on_edit = override_config.validate_on_edit;
559    }
560    if override_config.formatter.is_some() {
561        base.formatter = override_config.formatter;
562    }
563    if override_config.checker.is_some() {
564        base.checker = override_config.checker;
565    }
566    if override_config.configure_warnings_delivery.is_some() {
567        base.configure_warnings_delivery = override_config.configure_warnings_delivery;
568    }
569    if override_config.hoist_builtin_tools.is_some() {
570        base.hoist_builtin_tools = override_config.hoist_builtin_tools;
571    }
572    if override_config.tool_surface.is_some() {
573        base.tool_surface = override_config.tool_surface;
574    }
575    if override_config.disabled_tools.is_some() {
576        base.disabled_tools = override_config.disabled_tools;
577    }
578    if override_config.restrict_to_project_root.is_some() {
579        base.restrict_to_project_root = override_config.restrict_to_project_root;
580    }
581    if override_config.search_index.is_some() {
582        base.search_index = override_config.search_index;
583    }
584    if override_config.semantic_search.is_some() {
585        base.semantic_search = override_config.semantic_search;
586    }
587    if override_config.callgraph_store.is_some() {
588        base.callgraph_store = override_config.callgraph_store;
589    }
590    if override_config.callgraph_chunk_size.is_some() {
591        base.callgraph_chunk_size = override_config.callgraph_chunk_size;
592    }
593    if override_config.inspect.is_some() {
594        base.inspect = override_config.inspect;
595    }
596    if override_config.backup.is_some() {
597        base.backup = override_config.backup;
598    }
599    if override_config.bash.is_some() {
600        base.bash = override_config.bash;
601    }
602    if override_config.experimental.is_some() {
603        base.experimental = override_config.experimental;
604    }
605    if override_config.lsp.is_some() {
606        base.lsp = override_config.lsp;
607    }
608    if override_config.url_fetch_allow_private.is_some() {
609        base.url_fetch_allow_private = override_config.url_fetch_allow_private;
610    }
611    if override_config.semantic.is_some() {
612        base.semantic = override_config.semantic;
613    }
614    if override_config.auto_update.is_some() {
615        base.auto_update = override_config.auto_update;
616    }
617    if override_config.bridge.is_some() {
618        base.bridge = override_config.bridge;
619    }
620}
621
622fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
623    // Project-safe shallow top-level fields.
624    if project.enabled.is_some() {
625        base.enabled = project.enabled;
626    }
627    if project.format_on_edit.is_some() {
628        base.format_on_edit = project.format_on_edit;
629    }
630    if project.validate_on_edit.is_some() {
631        base.validate_on_edit = project.validate_on_edit;
632    }
633    if project.configure_warnings_delivery.is_some() {
634        base.configure_warnings_delivery = project.configure_warnings_delivery;
635    }
636    if project.hoist_builtin_tools.is_some() {
637        base.hoist_builtin_tools = project.hoist_builtin_tools;
638    }
639    if project.tool_surface.is_some() {
640        base.tool_surface = project.tool_surface;
641    }
642    if project.search_index.is_some() {
643        base.search_index = project.search_index;
644    }
645    if project.semantic_search.is_some() {
646        base.semantic_search = project.semantic_search;
647    }
648    if project.callgraph_store.is_some() {
649        base.callgraph_store = project.callgraph_store;
650    }
651    if project.callgraph_chunk_size.is_some() {
652        base.callgraph_chunk_size = project.callgraph_chunk_size;
653    }
654
655    merge_formatter_map(&mut base.formatter, project.formatter);
656    merge_checker_map(&mut base.checker, project.checker);
657    merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
658    base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
659    base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
660    base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
661    base.bash = merge_bash_config(base.bash.clone(), project.bash);
662    base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
663}
664
665fn merge_formatter_map(
666    base: &mut Option<HashMap<String, RawFormatter>>,
667    override_map: Option<HashMap<String, RawFormatter>>,
668) {
669    let Some(override_map) = override_map else {
670        return;
671    };
672    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
673        return;
674    }
675    let target = base.get_or_insert_with(HashMap::new);
676    target.extend(override_map);
677}
678
679fn merge_checker_map(
680    base: &mut Option<HashMap<String, RawChecker>>,
681    override_map: Option<HashMap<String, RawChecker>>,
682) {
683    let Some(override_map) = override_map else {
684        return;
685    };
686    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
687        return;
688    }
689    let target = base.get_or_insert_with(HashMap::new);
690    target.extend(override_map);
691}
692
693fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
694    let Some(override_tools) = override_tools else {
695        return;
696    };
697    let mut merged = Vec::new();
698    let mut seen = HashSet::new();
699    for tool in base.iter().flatten() {
700        if seen.insert(tool.clone()) {
701            merged.push(tool.clone());
702        }
703    }
704    for tool in override_tools
705        .iter()
706        .filter(|tool| tool.as_str() != "aft_safety")
707    {
708        if seen.insert(tool.clone()) {
709            merged.push(tool.clone());
710        }
711    }
712    if !merged.is_empty() {
713        *base = Some(merged);
714    }
715}
716
717fn merge_semantic_config(
718    base: Option<RawSemantic>,
719    override_semantic: Option<RawSemantic>,
720) -> Option<RawSemantic> {
721    let mut semantic = base.unwrap_or(RawSemantic {
722        backend: None,
723        model: None,
724        base_url: None,
725        api_key_env: None,
726        timeout_ms: None,
727        max_batch_size: None,
728        max_files: None,
729    });
730
731    if let Some(project) = override_semantic {
732        if project.model.is_some() {
733            semantic.model = project.model;
734        }
735        if project.timeout_ms.is_some() {
736            semantic.timeout_ms = project.timeout_ms;
737        }
738        if project.max_batch_size.is_some() {
739            semantic.max_batch_size = project.max_batch_size;
740        }
741        if project.max_files.is_some() {
742            semantic.max_files = project.max_files;
743        }
744    }
745
746    (!semantic.is_empty()).then_some(semantic)
747}
748
749fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
750    let mut lsp = base.unwrap_or(RawLsp {
751        servers: None,
752        disabled: None,
753        python: None,
754        diagnostics_on_edit: None,
755        auto_install: None,
756        grace_days: None,
757        versions: None,
758    });
759
760    if let Some(project) = override_lsp {
761        if project.python.is_some() {
762            lsp.python = project.python;
763        }
764        if project.diagnostics_on_edit.is_some() {
765            lsp.diagnostics_on_edit = project.diagnostics_on_edit;
766        }
767    }
768
769    (!lsp.is_empty()).then_some(lsp)
770}
771
772fn merge_experimental_config(
773    base: Option<RawExperimental>,
774    override_experimental: Option<RawExperimental>,
775) -> Option<RawExperimental> {
776    let Some(override_experimental) = override_experimental else {
777        return base;
778    };
779
780    let mut experimental = base.unwrap_or_default();
781    experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
782    experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
783
784    (!experimental.is_empty()).then_some(experimental)
785}
786
787fn merge_experimental_bash(
788    base: Option<RawExperimentalBash>,
789    override_bash: Option<RawExperimentalBash>,
790) -> Option<RawExperimentalBash> {
791    let Some(override_bash) = override_bash else {
792        return base;
793    };
794    let mut bash = base.unwrap_or_default();
795    bash.rewrite = override_bash.rewrite.or(bash.rewrite);
796    bash.compress = override_bash.compress.or(bash.compress);
797    bash.background = override_bash.background.or(bash.background);
798    bash.long_running_reminder_enabled = override_bash
799        .long_running_reminder_enabled
800        .or(bash.long_running_reminder_enabled);
801    bash.long_running_reminder_interval_ms = override_bash
802        .long_running_reminder_interval_ms
803        .or(bash.long_running_reminder_interval_ms);
804
805    bash.has_any_value().then_some(bash)
806}
807
808fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
809    match (base, override_bash) {
810        (None, None) => None,
811        (None, Some(override_bash)) => Some(override_bash),
812        (Some(base), None) => Some(base),
813        (Some(base), Some(override_bash)) => {
814            let base = expand_bash_for_merge(&base);
815            let override_features = expand_bash_for_merge(&override_bash);
816            Some(RawBash::Features(RawBashFeatures {
817                rewrite: override_features.rewrite.or(base.rewrite),
818                compress: override_features.compress.or(base.compress),
819                background: override_features.background.or(base.background),
820                subagent_background: override_features
821                    .subagent_background
822                    .or(base.subagent_background),
823                long_running_reminder_enabled: override_features
824                    .long_running_reminder_enabled
825                    .or(base.long_running_reminder_enabled),
826                long_running_reminder_interval_ms: override_features
827                    .long_running_reminder_interval_ms
828                    .or(base.long_running_reminder_interval_ms),
829                foreground_wait_window_ms: override_features
830                    .foreground_wait_window_ms
831                    .or(base.foreground_wait_window_ms),
832            }))
833        }
834    }
835}
836
837fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
838    match value {
839        RawBash::Bool(enabled) => RawBashFeatures {
840            rewrite: Some(*enabled),
841            compress: Some(*enabled),
842            background: Some(*enabled),
843            subagent_background: None,
844            long_running_reminder_enabled: None,
845            long_running_reminder_interval_ms: None,
846            foreground_wait_window_ms: None,
847        },
848        RawBash::Features(features) => features.clone(),
849    }
850}
851
852fn merge_inspect_config(
853    base: Option<RawInspect>,
854    override_inspect: Option<RawInspect>,
855) -> Option<RawInspect> {
856    let Some(override_inspect) = override_inspect else {
857        return base;
858    };
859
860    let mut inspect = base.unwrap_or_default();
861    inspect.enabled = override_inspect.enabled.or(inspect.enabled);
862    inspect.tier2_idle_minutes = override_inspect
863        .tier2_idle_minutes
864        .or(inspect.tier2_idle_minutes);
865    inspect.categories = override_inspect.categories.or(inspect.categories);
866    inspect.tier2_soft_deadline_ms = override_inspect
867        .tier2_soft_deadline_ms
868        .or(inspect.tier2_soft_deadline_ms);
869    inspect.max_drill_down_items = override_inspect
870        .max_drill_down_items
871        .or(inspect.max_drill_down_items);
872    inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
873
874    (!inspect.is_empty()).then_some(inspect)
875}
876
877fn merge_inspect_duplicates(
878    base: Option<RawInspectDuplicates>,
879    override_duplicates: Option<RawInspectDuplicates>,
880) -> Option<RawInspectDuplicates> {
881    let Some(override_duplicates) = override_duplicates else {
882        return base;
883    };
884
885    let mut duplicates = base.unwrap_or_default();
886    duplicates.lower_bound = override_duplicates.lower_bound.or(duplicates.lower_bound);
887    duplicates.discard_cost = override_duplicates.discard_cost.or(duplicates.discard_cost);
888    duplicates.expected_mirrors = override_duplicates
889        .expected_mirrors
890        .or(duplicates.expected_mirrors);
891    duplicates.anonymize =
892        merge_inspect_anonymize(duplicates.anonymize, override_duplicates.anonymize);
893
894    (!duplicates.is_empty()).then_some(duplicates)
895}
896
897fn merge_inspect_anonymize(
898    base: Option<RawInspectAnonymize>,
899    override_anonymize: Option<RawInspectAnonymize>,
900) -> Option<RawInspectAnonymize> {
901    let Some(override_anonymize) = override_anonymize else {
902        return base;
903    };
904
905    let mut anonymize = base.unwrap_or_default();
906    anonymize.variables = override_anonymize.variables.or(anonymize.variables);
907    anonymize.fields = override_anonymize.fields.or(anonymize.fields);
908    anonymize.methods = override_anonymize.methods.or(anonymize.methods);
909    anonymize.types = override_anonymize.types.or(anonymize.types);
910    anonymize.literals = override_anonymize.literals.or(anonymize.literals);
911
912    (!anonymize.is_empty()).then_some(anonymize)
913}
914
915fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
916    if raw.restrict_to_project_root.is_some() {
917        push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
918    }
919    if raw.url_fetch_allow_private.is_some() {
920        push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
921    }
922    if raw.formatter_timeout_secs.is_some() {
923        push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
924    }
925    if raw.type_checker_timeout_secs.is_some() {
926        push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
927    }
928    if raw.auto_update.is_some() {
929        push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
930    }
931    if raw.bridge.is_some() {
932        push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
933    }
934    if raw.backup.is_some() {
935        push_drop(dropped, "backup", tier, USER_ONLY_REASON);
936    }
937    if raw
938        .disabled_tools
939        .as_ref()
940        .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
941    {
942        push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
943    }
944
945    if let Some(semantic) = &raw.semantic {
946        if semantic.backend.is_some() {
947            push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
948        }
949        if semantic.base_url.is_some() {
950            push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
951        }
952        if semantic.api_key_env.is_some() {
953            push_drop(
954                dropped,
955                "semantic.api_key_env",
956                tier,
957                SEMANTIC_SECRET_REASON,
958            );
959        }
960    }
961
962    if let Some(lsp) = &raw.lsp {
963        if lsp.servers.is_some() {
964            push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
965        }
966        if lsp.versions.is_some() {
967            push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
968        }
969        if lsp.auto_install.is_some() {
970            push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
971        }
972        if lsp.grace_days.is_some() {
973            push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
974        }
975        if lsp.disabled.is_some() {
976            push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
977        }
978    }
979}
980
981fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
982    dropped.push(DroppedKey {
983        key: key.to_string(),
984        tier: tier.to_string(),
985        reason: reason.to_string(),
986    });
987}
988
989/// Overlay the resolved core-domain fields from `raw` onto `config`. Scalar
990/// fields are only written when present in the merged tiers (preserving the
991/// caller's base for absent keys); semantic/inspect/lsp are core-domain and are
992/// fully resolved from the tiers. Process-state fields on `config` are never
993/// touched (they are not part of `RawAftConfig`).
994fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
995    if let Some(value) = raw.format_on_edit {
996        config.format_on_edit = value;
997    }
998    if let Some(value) = raw.formatter_timeout_secs {
999        config.formatter_timeout_secs = value;
1000    }
1001    if let Some(value) = raw.type_checker_timeout_secs {
1002        config.type_checker_timeout_secs = value;
1003    }
1004    if let Some(value) = raw.validate_on_edit {
1005        config.validate_on_edit = Some(value.as_str().to_string());
1006    }
1007    if let Some(formatter) = &raw.formatter {
1008        config.formatter = formatter
1009            .iter()
1010            .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
1011            .collect();
1012    }
1013    if let Some(checker) = &raw.checker {
1014        config.checker = checker
1015            .iter()
1016            .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
1017            .collect();
1018    }
1019    if let Some(value) = raw.restrict_to_project_root {
1020        config.restrict_to_project_root = value;
1021    }
1022    if let Some(value) = raw.search_index {
1023        config.search_index = value;
1024    }
1025    if let Some(value) = raw.semantic_search {
1026        config.semantic_search = value;
1027    }
1028    if let Some(value) = raw.callgraph_store {
1029        config.callgraph_store = value;
1030    }
1031    if let Some(value) = raw.callgraph_chunk_size {
1032        config.callgraph_chunk_size = value;
1033    }
1034    if let Some(value) = raw.url_fetch_allow_private {
1035        config.url_fetch_allow_private = value;
1036    }
1037    config.semantic = resolve_semantic_config(raw.semantic.as_ref());
1038    config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1039    config.backup = resolve_backup_config(raw.backup.as_ref());
1040    resolve_lsp_config(raw, config);
1041    resolve_bash_fields(raw, config);
1042}
1043
1044fn resolve_semantic_config(raw: Option<&RawSemantic>) -> SemanticBackendConfig {
1045    let mut semantic = SemanticBackendConfig::default();
1046    let Some(raw) = raw else {
1047        return semantic;
1048    };
1049
1050    if let Some(value) = raw.backend {
1051        semantic.backend = value;
1052    }
1053    if let Some(value) = &raw.model {
1054        semantic.model = value.clone();
1055    }
1056    if let Some(value) = &raw.base_url {
1057        semantic.base_url = Some(value.clone());
1058    }
1059    if let Some(value) = &raw.api_key_env {
1060        semantic.api_key_env = Some(value.clone());
1061    }
1062    if let Some(value) = raw.timeout_ms {
1063        semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1064    }
1065    if let Some(value) = raw.max_batch_size {
1066        semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1067    }
1068    if let Some(value) = raw.max_files {
1069        semantic.max_files = value;
1070    }
1071
1072    semantic
1073}
1074
1075fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1076    let mut inspect = InspectConfig::default();
1077    let Some(raw) = raw else {
1078        return inspect;
1079    };
1080    if let Some(enabled) = raw.enabled {
1081        inspect.enabled = enabled;
1082    }
1083    if let Some(expected_mirrors) = raw
1084        .duplicates
1085        .as_ref()
1086        .and_then(|duplicates| duplicates.expected_mirrors.clone())
1087    {
1088        inspect.duplicates.expected_mirrors = expected_mirrors;
1089    }
1090    inspect
1091}
1092
1093fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1094    let mut backup = BackupConfig::default();
1095    if let Some(raw) = raw {
1096        if raw.enabled.is_some() {
1097            backup.enabled = raw.enabled;
1098        }
1099        if raw.max_depth.is_some() {
1100            backup.max_depth = raw.max_depth;
1101        }
1102        if raw.max_file_size.is_some() {
1103            backup.max_file_size = raw.max_file_size;
1104        }
1105    }
1106    backup
1107}
1108
1109fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1110    let lsp = raw.lsp.as_ref();
1111    let mut disabled: HashSet<String> = lsp
1112        .and_then(|lsp| lsp.disabled.as_ref())
1113        .into_iter()
1114        .flatten()
1115        .map(|value| value.to_ascii_lowercase())
1116        .collect();
1117    let mut experimental_ty = raw
1118        .experimental
1119        .as_ref()
1120        .and_then(|experimental| experimental.lsp_ty);
1121
1122    match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1123        RawPythonLsp::Ty => {
1124            experimental_ty = Some(true);
1125            disabled.insert("python".to_string());
1126        }
1127        RawPythonLsp::Pyright => {
1128            experimental_ty = Some(false);
1129            disabled.insert("ty".to_string());
1130        }
1131        RawPythonLsp::Auto => {}
1132    }
1133
1134    if let Some(value) = experimental_ty {
1135        config.experimental_lsp_ty = value;
1136    }
1137
1138    if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1139        config.diagnostics_on_edit = value;
1140    }
1141
1142    if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1143        config.lsp_servers = servers
1144            .iter()
1145            .map(|(id, server)| UserServerDef {
1146                id: id.clone(),
1147                extensions: server
1148                    .extensions
1149                    .clone()
1150                    .unwrap_or_default()
1151                    .into_iter()
1152                    .map(|extension| extension.trim_start_matches('.').to_string())
1153                    .collect(),
1154                binary: server.binary.clone().unwrap_or_default(),
1155                args: server.args.clone().unwrap_or_default(),
1156                root_markers: server
1157                    .root_markers
1158                    .clone()
1159                    .unwrap_or_else(|| vec![".git".to_string()]),
1160                env: server.env.clone().unwrap_or_default(),
1161                initialization_options: server.initialization_options.clone(),
1162                disabled: server.disabled.unwrap_or(false),
1163            })
1164            .collect();
1165    }
1166
1167    if !disabled.is_empty() {
1168        config.disabled_lsp = disabled;
1169    }
1170}
1171
1172#[derive(Debug, Clone, PartialEq, Eq)]
1173struct ResolvedBashConfig {
1174    enabled: bool,
1175    rewrite: bool,
1176    compress: bool,
1177    background: bool,
1178    subagent_background: bool,
1179    long_running_reminder_enabled: Option<bool>,
1180    long_running_reminder_interval_ms: Option<u64>,
1181    foreground_wait_window_ms: u64,
1182}
1183
1184fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config) {
1185    let bash = resolve_bash_config(raw);
1186    // Resolve enabled and subagent_background, then discard them, because Rust
1187    // does not use those two bash settings at runtime yet. Doing the resolution
1188    // here keeps user config overriding project config in the same way as the
1189    // bash fields below, so a later response can expose the values without
1190    // changing how config files are interpreted.
1191    let _registration_only = (bash.enabled, bash.subagent_background);
1192    config.experimental_bash_rewrite = bash.rewrite;
1193    config.experimental_bash_compress = bash.compress;
1194    config.experimental_bash_background = bash.background;
1195    config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
1196    if let Some(value) = bash.long_running_reminder_enabled {
1197        config.bash_long_running_reminder_enabled = value;
1198    }
1199    if let Some(value) = bash.long_running_reminder_interval_ms {
1200        config.bash_long_running_reminder_interval_ms = value;
1201    }
1202}
1203
1204fn resolve_bash_config(raw: &RawAftConfig) -> ResolvedBashConfig {
1205    let top = raw.bash.as_ref();
1206    let legacy = raw
1207        .experimental
1208        .as_ref()
1209        .and_then(|experimental| experimental.bash.as_ref());
1210    let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
1211    let surface_default_enabled = surface != RawToolSurface::Minimal;
1212
1213    let top_features = match top {
1214        Some(RawBash::Features(features)) => Some(features),
1215        _ => None,
1216    };
1217    let reminder_enabled = top_features
1218        .and_then(|features| features.long_running_reminder_enabled)
1219        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
1220    let reminder_interval = top_features
1221        .and_then(|features| features.long_running_reminder_interval_ms)
1222        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
1223    let top_subagent_background = top_features
1224        .and_then(|features| features.subagent_background)
1225        .unwrap_or(false);
1226    let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
1227    let foreground_wait_window_ms = raw_foreground_wait
1228        .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
1229        .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
1230
1231    let base = ResolvedBashConfig {
1232        enabled: false,
1233        rewrite: false,
1234        compress: false,
1235        background: false,
1236        subagent_background: false,
1237        long_running_reminder_enabled: reminder_enabled,
1238        long_running_reminder_interval_ms: reminder_interval,
1239        foreground_wait_window_ms,
1240    };
1241
1242    match top {
1243        Some(RawBash::Bool(false)) => base,
1244        Some(RawBash::Bool(true)) => ResolvedBashConfig {
1245            enabled: true,
1246            rewrite: true,
1247            compress: true,
1248            background: true,
1249            ..base
1250        },
1251        Some(RawBash::Features(features)) => ResolvedBashConfig {
1252            enabled: true,
1253            rewrite: features.rewrite.unwrap_or(true),
1254            compress: features.compress.unwrap_or(true),
1255            background: features.background.unwrap_or(true),
1256            subagent_background: top_subagent_background,
1257            ..base
1258        },
1259        None => {
1260            if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
1261                let legacy = legacy.cloned().unwrap_or_default();
1262                let rewrite = legacy.rewrite == Some(true);
1263                let compress = legacy.compress == Some(true);
1264                let background = legacy.background == Some(true);
1265                return ResolvedBashConfig {
1266                    enabled: rewrite || compress || background,
1267                    rewrite,
1268                    compress,
1269                    background,
1270                    ..base
1271                };
1272            }
1273
1274            ResolvedBashConfig {
1275                enabled: surface_default_enabled,
1276                rewrite: surface_default_enabled,
1277                compress: surface_default_enabled,
1278                background: surface_default_enabled,
1279                ..base
1280            }
1281        }
1282    }
1283}
1284
1285fn deserialize_opt_trimmed_non_empty_string<'de, D>(
1286    deserializer: D,
1287) -> Result<Option<String>, D::Error>
1288where
1289    D: Deserializer<'de>,
1290{
1291    let value = Option::<String>::deserialize(deserializer)?;
1292    value
1293        .map(|value| {
1294            let trimmed = value.trim().to_string();
1295            if trimmed.is_empty() {
1296                Err(de::Error::custom("must be a non-empty string"))
1297            } else {
1298                Ok(trimmed)
1299            }
1300        })
1301        .transpose()
1302}
1303
1304fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
1305    deserializer: D,
1306) -> Result<Option<Vec<String>>, D::Error>
1307where
1308    D: Deserializer<'de>,
1309{
1310    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1311    value
1312        .map(|values| {
1313            values
1314                .into_iter()
1315                .map(|value| {
1316                    let trimmed = value.trim().to_string();
1317                    if trimmed.is_empty() {
1318                        Err(de::Error::custom("array entries must be non-empty strings"))
1319                    } else {
1320                        Ok(trimmed)
1321                    }
1322                })
1323                .collect()
1324        })
1325        .transpose()
1326}
1327
1328fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1329where
1330    D: Deserializer<'de>,
1331{
1332    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1333    value
1334        .map(|values| {
1335            if values.is_empty() {
1336                return Err(de::Error::custom(
1337                    "extensions must contain at least one entry",
1338                ));
1339            }
1340            values
1341                .into_iter()
1342                .map(|value| {
1343                    let trimmed = value.trim().to_string();
1344                    if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
1345                        Err(de::Error::custom(
1346                            "extension must include characters other than leading dots",
1347                        ))
1348                    } else {
1349                        Ok(trimmed)
1350                    }
1351                })
1352                .collect()
1353        })
1354        .transpose()
1355}
1356
1357fn deserialize_opt_lsp_servers<'de, D>(
1358    deserializer: D,
1359) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
1360where
1361    D: Deserializer<'de>,
1362{
1363    let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
1364    value
1365        .map(|entries| {
1366            entries
1367                .into_iter()
1368                .map(|(key, value)| {
1369                    let trimmed = key.trim().to_string();
1370                    if trimmed.is_empty() {
1371                        Err(de::Error::custom(
1372                            "lsp.servers keys must be non-empty strings",
1373                        ))
1374                    } else {
1375                        Ok((trimmed, value))
1376                    }
1377                })
1378                .collect()
1379        })
1380        .transpose()
1381}
1382
1383fn deserialize_opt_versions_map<'de, D>(
1384    deserializer: D,
1385) -> Result<Option<HashMap<String, String>>, D::Error>
1386where
1387    D: Deserializer<'de>,
1388{
1389    let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
1390    value
1391        .map(|entries| {
1392            entries
1393                .into_iter()
1394                .map(|(key, value)| {
1395                    let trimmed_key = key.trim().to_string();
1396                    let trimmed_value = value.trim().to_string();
1397                    if trimmed_key.is_empty() || trimmed_value.is_empty() {
1398                        Err(de::Error::custom(
1399                            "lsp.versions keys and values must be non-empty strings",
1400                        ))
1401                    } else {
1402                        Ok((trimmed_key, trimmed_value))
1403                    }
1404                })
1405                .collect()
1406        })
1407        .transpose()
1408}
1409
1410fn deserialize_opt_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1411where
1412    D: Deserializer<'de>,
1413{
1414    Option::<u64>::deserialize(deserializer)
1415}
1416
1417fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1418where
1419    D: Deserializer<'de>,
1420{
1421    let value = Option::<u64>::deserialize(deserializer)?;
1422    value
1423        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1424        .transpose()
1425}
1426
1427fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1428where
1429    D: Deserializer<'de>,
1430{
1431    let value = Option::<u64>::deserialize(deserializer)?;
1432    match value {
1433        Some(0) => Err(de::Error::custom("must be a positive integer")),
1434        other => Ok(other),
1435    }
1436}
1437
1438fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1439where
1440    D: Deserializer<'de>,
1441{
1442    let value = deserialize_opt_positive_u64(deserializer)?;
1443    value
1444        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1445        .transpose()
1446}
1447
1448fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1449where
1450    D: Deserializer<'de>,
1451{
1452    let value = Option::<u64>::deserialize(deserializer)?;
1453    match value {
1454        Some(value) if !(1..=600).contains(&value) => {
1455            Err(de::Error::custom("timeout must be in 1..=600 seconds"))
1456        }
1457        Some(value) => u32::try_from(value)
1458            .map(Some)
1459            .map_err(|_| de::Error::custom("timeout is too large")),
1460        None => Ok(None),
1461    }
1462}
1463
1464fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
1465    deserializer: D,
1466) -> Result<Option<u64>, D::Error>
1467where
1468    D: Deserializer<'de>,
1469{
1470    let value = Option::<u64>::deserialize(deserializer)?;
1471    match value {
1472        Some(value) if value < 1_000 => Err(de::Error::custom(
1473            "bridge.request_timeout_ms must be at least 1000",
1474        )),
1475        other => Ok(other),
1476    }
1477}
1478
1479fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1480where
1481    D: Deserializer<'de>,
1482{
1483    let value = Option::<f64>::deserialize(deserializer)?;
1484    match value {
1485        Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
1486        other => Ok(other),
1487    }
1488}
1489
1490fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1491where
1492    D: Deserializer<'de>,
1493{
1494    let value = Option::<u64>::deserialize(deserializer)?;
1495    match value {
1496        Some(value) if value == 0 || value > 100 => {
1497            Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
1498        }
1499        Some(value) => usize::try_from(value)
1500            .map(Some)
1501            .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
1502        None => Ok(None),
1503    }
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use super::*;
1509
1510    fn tier(tier: &str, doc: &str) -> ConfigTier {
1511        ConfigTier {
1512            tier: tier.to_string(),
1513            source: format!("/tmp/{tier}/aft.jsonc"),
1514            doc: doc.to_string(),
1515        }
1516    }
1517
1518    fn drop_keys(result: &ResolveResult) -> Vec<String> {
1519        result
1520            .dropped
1521            .iter()
1522            .map(|dropped| dropped.key.clone())
1523            .collect()
1524    }
1525
1526    /// Security invariant (Oracle drift decision): nested objects are non-strict
1527    /// (match TS z.object — unknown nested keys are stripped, object survives),
1528    /// but the TOP-LEVEL RawAftConfig stays strict. A privileged process-state
1529    /// field is top-level, so a project tier trying to smuggle one still hits the
1530    /// strict top-level → that tier fails full parse, and partial-parse drops the
1531    /// unknown key. It can NEVER reach Config.
1532    #[test]
1533    fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
1534        // Nested unknown key: stripped, object survives — parity with TS (golden
1535        // `bash_unknown_nested_key`). `bash: { unknown_key }` resolves like
1536        // `bash: {}` → object form → bash ENABLED (object presence beats the
1537        // minimal surface default). The point: the unknown key did not fail the
1538        // parse — the object survived and resolved.
1539        let nested = resolve_config(&[tier(
1540            "user",
1541            r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
1542        )]);
1543        assert!(nested.config.experimental_bash_rewrite);
1544        assert!(nested.config.experimental_bash_compress);
1545        assert!(nested.config.experimental_bash_background);
1546
1547        // Top-level privileged process-state field (storage_dir) from a PROJECT
1548        // tier: not in RawAftConfig → full parse fails → partial-parse drops it.
1549        // It must never appear in Config (Config keeps its default storage_dir).
1550        let smuggle = resolve_config(&[
1551            tier("user", r#"{ "search_index": true }"#),
1552            tier(
1553                "project",
1554                r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
1555            ),
1556        ]);
1557        // The valid project key (search_index) still applies via partial-parse...
1558        assert!(!smuggle.config.search_index);
1559        // ...but the smuggled process-state fields never reach Config.
1560        assert!(smuggle.config.storage_dir.is_none());
1561        assert!(!smuggle.config.bash_permissions);
1562    }
1563
1564    #[test]
1565    fn config_resolve_empty_tiers_applies_bash_surface_default() {
1566        // No config file ⇒ empty config object, which still flows through the
1567        // resolver and picks up the bash surface default (recommended ⇒ on),
1568        // matching the TS pipeline (golden fixture `empty`). NOT Config::default()
1569        // — that would leave bash off, diverging from TS.
1570        let result = resolve_config(&[]);
1571        let default_config = Config::default();
1572
1573        assert!(result.dropped.is_empty());
1574        // Non-bash fields stay at runtime default.
1575        assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
1576        assert_eq!(result.config.search_index, default_config.search_index);
1577        assert_eq!(
1578            result.config.semantic_search,
1579            default_config.semantic_search
1580        );
1581        assert_eq!(result.config.semantic, default_config.semantic);
1582        assert_eq!(
1583            result.config.inspect.enabled,
1584            default_config.inspect.enabled
1585        );
1586        assert_eq!(result.config.lsp_servers.len(), 0);
1587        // Bash surface default: recommended ⇒ rewrite/compress/background all on.
1588        assert!(result.config.experimental_bash_rewrite);
1589        assert!(result.config.experimental_bash_compress);
1590        assert!(result.config.experimental_bash_background);
1591    }
1592
1593    #[test]
1594    fn config_resolve_user_only_config_applies_fields() {
1595        let result = resolve_config(&[tier(
1596            "user",
1597            r#"{
1598              "$schema": "https://example.test/aft.schema.json",
1599              "format_on_edit": false,
1600              "formatter_timeout_secs": 42,
1601              "type_checker_timeout_secs": 43,
1602              "validate_on_edit": "full",
1603              "formatter": { "rust": "rustfmt", "typescript": "prettier" },
1604              "checker": { "rust": "cargo", "typescript": "tsc" },
1605              "restrict_to_project_root": true,
1606              "search_index": true,
1607              "semantic_search": true,
1608              "callgraph_store": false,
1609              "callgraph_chunk_size": 17,
1610              "url_fetch_allow_private": true,
1611              "semantic": {
1612                "backend": "openai_compatible",
1613                "model": "  user-model  ",
1614                "base_url": "https://semantic.example.test",
1615                "api_key_env": "AFT_API_KEY",
1616                "timeout_ms": 12345,
1617                "max_batch_size": 12,
1618                "max_files": 3456
1619              },
1620              "inspect": { "enabled": false },
1621              "experimental": { "lsp_ty": true },
1622              "lsp": {
1623                "servers": {
1624                  "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1625                },
1626                "disabled": ["Python"],
1627                "python": "pyright"
1628              },
1629              "bash": { "rewrite": false, "compress": true, "background": false,
1630                        "long_running_reminder_enabled": false,
1631                        "long_running_reminder_interval_ms": 123000 }
1632            }"#,
1633        )]);
1634
1635        assert!(result.dropped.is_empty());
1636        assert!(!result.config.format_on_edit);
1637        assert_eq!(result.config.formatter_timeout_secs, 42);
1638        assert_eq!(result.config.type_checker_timeout_secs, 43);
1639        assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
1640        assert_eq!(
1641            result.config.formatter.get("rust").map(String::as_str),
1642            Some("rustfmt")
1643        );
1644        assert_eq!(
1645            result.config.checker.get("typescript").map(String::as_str),
1646            Some("tsc")
1647        );
1648        assert!(result.config.restrict_to_project_root);
1649        assert!(result.config.search_index);
1650        assert!(result.config.semantic_search);
1651        assert!(!result.config.callgraph_store);
1652        assert_eq!(result.config.callgraph_chunk_size, 17);
1653        assert!(result.config.url_fetch_allow_private);
1654        assert_eq!(
1655            result.config.semantic.backend,
1656            SemanticBackend::OpenAiCompatible
1657        );
1658        assert_eq!(result.config.semantic.model, "user-model");
1659        assert_eq!(
1660            result.config.semantic.base_url.as_deref(),
1661            Some("https://semantic.example.test")
1662        );
1663        assert_eq!(
1664            result.config.semantic.api_key_env.as_deref(),
1665            Some("AFT_API_KEY")
1666        );
1667        assert_eq!(result.config.semantic.timeout_ms, 12345);
1668        assert_eq!(result.config.semantic.max_batch_size, 12);
1669        assert_eq!(result.config.semantic.max_files, 3456);
1670        assert!(!result.config.inspect.enabled);
1671        assert!(!result.config.experimental_lsp_ty);
1672        assert!(result.config.disabled_lsp.contains("ty"));
1673        assert_eq!(result.config.lsp_servers.len(), 1);
1674        assert_eq!(result.config.lsp_servers[0].id, "rust");
1675        assert_eq!(
1676            result.config.lsp_servers[0].extensions,
1677            vec!["rs".to_string()]
1678        );
1679        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1680        assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
1681        assert_eq!(
1682            result.config.lsp_servers[0].root_markers,
1683            vec![".git".to_string()]
1684        );
1685        assert!(!result.config.experimental_bash_rewrite);
1686        assert!(result.config.experimental_bash_compress);
1687        assert!(!result.config.experimental_bash_background);
1688        assert!(!result.config.bash_long_running_reminder_enabled);
1689        assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
1690    }
1691
1692    #[test]
1693    fn config_resolve_project_allowed_search_index_wins() {
1694        let result = resolve_config(&[
1695            tier("user", r#"{ "search_index": false }"#),
1696            tier("project", r#"{ "search_index": true }"#),
1697        ]);
1698
1699        assert!(result.config.search_index);
1700        assert!(result.dropped.is_empty());
1701    }
1702
1703    #[test]
1704    fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
1705        let result = resolve_config(&[
1706            tier(
1707                "user",
1708                r#"{
1709                  "restrict_to_project_root": true,
1710                  "url_fetch_allow_private": true,
1711                  "formatter_timeout_secs": 11,
1712                  "type_checker_timeout_secs": 33,
1713                  "auto_update": true,
1714                  "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
1715                  "semantic": {
1716                    "backend": "openai_compatible",
1717                    "base_url": "https://user.example.test",
1718                    "api_key_env": "USER_KEY",
1719                    "model": "user-model"
1720                  },
1721                  "lsp": {
1722                    "servers": {
1723                      "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1724                    },
1725                    "disabled": ["user-disabled"],
1726                    "versions": { "typescript-language-server": "1.0.0" },
1727                    "auto_install": true,
1728                    "grace_days": 7
1729                  }
1730                }"#,
1731            ),
1732            tier(
1733                "project",
1734                r#"{
1735                  "restrict_to_project_root": false,
1736                  "url_fetch_allow_private": false,
1737                  "formatter_timeout_secs": 22,
1738                  "type_checker_timeout_secs": 44,
1739                  "auto_update": false,
1740                  "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
1741                  "semantic": {
1742                    "backend": "ollama",
1743                    "base_url": "https://project.example.test",
1744                    "api_key_env": "PROJECT_KEY",
1745                    "model": "project-model",
1746                    "timeout_ms": 2222
1747                  },
1748                  "lsp": {
1749                    "servers": {
1750                      "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
1751                    },
1752                    "disabled": ["project-disabled"],
1753                    "versions": { "evil-lsp": "9.9.9" },
1754                    "auto_install": false,
1755                    "grace_days": 1,
1756                    "python": "ty"
1757                  }
1758                }"#,
1759            ),
1760        ]);
1761
1762        assert!(result.config.restrict_to_project_root);
1763        assert!(result.config.url_fetch_allow_private);
1764        assert_eq!(result.config.formatter_timeout_secs, 11);
1765        assert_eq!(result.config.type_checker_timeout_secs, 33);
1766        assert_eq!(
1767            result.config.semantic.backend,
1768            SemanticBackend::OpenAiCompatible
1769        );
1770        assert_eq!(
1771            result.config.semantic.base_url.as_deref(),
1772            Some("https://user.example.test")
1773        );
1774        assert_eq!(
1775            result.config.semantic.api_key_env.as_deref(),
1776            Some("USER_KEY")
1777        );
1778        assert_eq!(result.config.semantic.model, "project-model");
1779        assert_eq!(result.config.semantic.timeout_ms, 2222);
1780        assert_eq!(result.config.lsp_servers.len(), 1);
1781        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1782        assert!(result.config.disabled_lsp.contains("user-disabled"));
1783        assert!(!result.config.disabled_lsp.contains("project-disabled"));
1784        assert!(result.config.disabled_lsp.contains("python"));
1785        assert!(result.config.experimental_lsp_ty);
1786
1787        let keys = drop_keys(&result);
1788        let expected = [
1789            "restrict_to_project_root",
1790            "url_fetch_allow_private",
1791            "formatter_timeout_secs",
1792            "type_checker_timeout_secs",
1793            "auto_update",
1794            "bridge",
1795            "semantic.backend",
1796            "semantic.base_url",
1797            "semantic.api_key_env",
1798            "lsp.servers",
1799            "lsp.versions",
1800            "lsp.auto_install",
1801            "lsp.grace_days",
1802            "lsp.disabled",
1803        ];
1804        for key in expected {
1805            assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
1806        }
1807        assert_eq!(keys.len(), expected.len());
1808        assert!(result
1809            .dropped
1810            .iter()
1811            .all(|dropped| dropped.tier == "project"));
1812    }
1813
1814    #[test]
1815    fn config_resolve_bash_ladder_and_merge_parity() {
1816        let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
1817        assert!(true_result.config.experimental_bash_rewrite);
1818        assert!(true_result.config.experimental_bash_compress);
1819        assert!(true_result.config.experimental_bash_background);
1820
1821        let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
1822        assert!(!false_result.config.experimental_bash_rewrite);
1823        assert!(!false_result.config.experimental_bash_compress);
1824        assert!(!false_result.config.experimental_bash_background);
1825
1826        let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
1827        assert!(object_default_result.config.experimental_bash_rewrite);
1828        assert!(object_default_result.config.experimental_bash_compress);
1829        assert!(object_default_result.config.experimental_bash_background);
1830
1831        let object_partial_result =
1832            resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
1833        assert!(object_partial_result.config.experimental_bash_rewrite);
1834        assert!(!object_partial_result.config.experimental_bash_compress);
1835        assert!(object_partial_result.config.experimental_bash_background);
1836
1837        let legacy_result = resolve_config(&[tier(
1838            "user",
1839            r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
1840        )]);
1841        assert!(legacy_result.config.experimental_bash_rewrite);
1842        assert!(!legacy_result.config.experimental_bash_compress);
1843        assert!(!legacy_result.config.experimental_bash_background);
1844
1845        let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
1846        assert!(surface_default_result.config.experimental_bash_rewrite);
1847        assert!(surface_default_result.config.experimental_bash_compress);
1848        assert!(surface_default_result.config.experimental_bash_background);
1849
1850        let minimal_surface_result =
1851            resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
1852        assert!(!minimal_surface_result.config.experimental_bash_rewrite);
1853        assert!(!minimal_surface_result.config.experimental_bash_compress);
1854        assert!(!minimal_surface_result.config.experimental_bash_background);
1855
1856        let merged_result = resolve_config(&[
1857            tier("user", r#"{ "bash": true }"#),
1858            tier("project", r#"{ "bash": { "compress": false } }"#),
1859        ]);
1860        assert!(merged_result.config.experimental_bash_rewrite);
1861        assert!(!merged_result.config.experimental_bash_compress);
1862        assert!(merged_result.config.experimental_bash_background);
1863
1864        let false_then_object_result = resolve_config(&[
1865            tier("user", r#"{ "bash": false }"#),
1866            tier("project", r#"{ "bash": { "compress": true } }"#),
1867        ]);
1868        assert!(!false_then_object_result.config.experimental_bash_rewrite);
1869        assert!(false_then_object_result.config.experimental_bash_compress);
1870        assert!(!false_then_object_result.config.experimental_bash_background);
1871    }
1872
1873    #[test]
1874    fn config_resolve_bash_foreground_wait_clamps_to_floor() {
1875        let Some(raw) = parse_tier(&tier(
1876            "user",
1877            r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
1878        )) else {
1879            panic!("test tier should parse");
1880        };
1881        let bash = resolve_bash_config(&raw);
1882
1883        assert_eq!(
1884            bash.foreground_wait_window_ms,
1885            FOREGROUND_WAIT_WINDOW_MIN_MS
1886        );
1887        assert!(bash.subagent_background);
1888
1889        let result = resolve_config(&[tier(
1890            "user",
1891            r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
1892        )]);
1893        assert_eq!(
1894            result.config.foreground_wait_window_ms,
1895            FOREGROUND_WAIT_WINDOW_MIN_MS
1896        );
1897
1898        // Unset → the default wait-window (matches the plugin's
1899        // FOREGROUND_WAIT_WINDOW_DEFAULT_MS = 15_000). This locks the default,
1900        // not just the floor, so a future edit can't silently shorten the
1901        // server-side promotion window once the plugin orchestrates through Rust.
1902        let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
1903        assert_eq!(
1904            defaulted.config.foreground_wait_window_ms,
1905            FOREGROUND_WAIT_WINDOW_DEFAULT_MS
1906        );
1907        assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
1908    }
1909
1910    #[test]
1911    fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
1912        let result = resolve_config(&[tier(
1913            "user",
1914            r#"{
1915              "semantic": { "timeout_ms": 0 },
1916              "search_index": true,
1917              "format_on_edit": false
1918            }"#,
1919        )]);
1920
1921        assert!(result.config.search_index);
1922        assert!(!result.config.format_on_edit);
1923        assert_eq!(result.config.semantic, SemanticBackendConfig::default());
1924        assert!(result.dropped.is_empty());
1925    }
1926
1927    #[test]
1928    fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
1929        let result = resolve_config(&[tier(
1930            "user",
1931            r#"{ "not_a_real_key": true, "search_index": true }"#,
1932        )]);
1933
1934        assert!(result.config.search_index);
1935        assert!(result.dropped.is_empty());
1936    }
1937
1938    #[test]
1939    fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
1940        // Cross-bind escalation regression: a first (trusted) bind sets a
1941        // capability field; a second bind that omits it must NOT inherit it.
1942        // This is the configure-path entry (seeded from a prior config), where
1943        // reset-onto-default — unlike the old overlay — makes the resolved core
1944        // config a pure function of the CURRENT bind's tiers.
1945        let mut config = Config::default();
1946
1947        // Bind 1 (trusted user tier) sets url_fetch_allow_private + a custom LSP
1948        // server + restrict_to_project_root.
1949        let dropped1 = resolve_config_onto(
1950            &[tier(
1951                "user",
1952                r#"{
1953                  "url_fetch_allow_private": true,
1954                  "restrict_to_project_root": true,
1955                  "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
1956                }"#,
1957            )],
1958            &mut config,
1959        );
1960        assert!(dropped1.is_empty());
1961        assert!(config.url_fetch_allow_private);
1962        assert!(config.restrict_to_project_root);
1963        assert_eq!(config.lsp_servers.len(), 1);
1964
1965        // Bind 2 omits all three. With reset semantics they return to DEFAULT —
1966        // the second bind cannot inherit the first bind's capabilities.
1967        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
1968        assert!(
1969            !config.url_fetch_allow_private,
1970            "url_fetch_allow_private must reset to default, not inherit prior bind"
1971        );
1972        assert!(
1973            !config.restrict_to_project_root,
1974            "restrict_to_project_root must reset to default"
1975        );
1976        assert!(
1977            config.lsp_servers.is_empty(),
1978            "lsp_servers must reset to default, not inherit prior bind's custom server"
1979        );
1980        assert!(config.search_index, "this bind's own field still applies");
1981    }
1982
1983    #[test]
1984    fn resolve_config_onto_empty_tiers_resets_to_default() {
1985        // The empty-tier path must still reset (it routes through the same
1986        // always-run resolution in handle_configure). A bind with no tiers after
1987        // a privileged bind must drop the privileged config.
1988        let mut config = Config::default();
1989        let _ = resolve_config_onto(
1990            &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
1991            &mut config,
1992        );
1993        assert!(config.url_fetch_allow_private);
1994
1995        let _ = resolve_config_onto(&[], &mut config);
1996        assert!(
1997            !config.url_fetch_allow_private,
1998            "empty-tier bind must reset core config to default"
1999        );
2000    }
2001
2002    #[test]
2003    fn resolve_config_onto_preserves_process_state_fields() {
2004        // Process-state fields (not part of RawAftConfig) are carried across the
2005        // reset so plugin-mode behavior is unchanged (the plugin re-sends them
2006        // via flat configure params right after this call).
2007        let mut config = Config {
2008            storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
2009            lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
2010            bash_permissions: true,
2011            project_root: Some(std::path::PathBuf::from("/tmp/proj")),
2012            ..Default::default()
2013        };
2014
2015        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2016
2017        assert_eq!(
2018            config.storage_dir,
2019            Some(std::path::PathBuf::from("/tmp/aft-store"))
2020        );
2021        assert_eq!(
2022            config.lsp_paths_extra,
2023            vec![std::path::PathBuf::from("/tmp/lsp-bin")]
2024        );
2025        assert!(config.bash_permissions);
2026        assert_eq!(
2027            config.project_root,
2028            Some(std::path::PathBuf::from("/tmp/proj"))
2029        );
2030        assert!(config.search_index);
2031    }
2032
2033    #[test]
2034    fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
2035        let result = resolve_config(&[tier(
2036            "user",
2037            r#"{
2038              // line comment
2039              "search_index": true,
2040              "formatter": {
2041                "rust": "rustfmt", /* block comment */
2042              },
2043            }"#,
2044        )]);
2045
2046        assert!(result.config.search_index);
2047        assert_eq!(
2048            result.config.formatter.get("rust").map(String::as_str),
2049            Some("rustfmt")
2050        );
2051    }
2052}