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