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