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