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