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