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