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