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};
9use std::path::PathBuf;
10
11use serde::de;
12use serde::{Deserialize, Deserializer};
13use serde_json::{Map, Value};
14
15use crate::config::{
16    expand_index_root_path, normalize_git_co_author, BackupConfig, Config, GhShimConfig, GitConfig,
17    IndexConfig, IndexKind, IndexRootConfig, InspectConfig, SandboxConfig, SemanticBackend,
18    SemanticBackendConfig, UserServerDef, WorktreeConfig, DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
19    MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS,
20    MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
21};
22use crate::jsonc::strip_jsonc;
23
24const FOREGROUND_WAIT_WINDOW_DEFAULT_MS: u64 = 15_000;
25const FOREGROUND_WAIT_WINDOW_MIN_MS: u64 = 5_000;
26
27// Semantic budget clamps — restored from the deleted configure-time
28// parse_semantic_config so the tier-resolved Config matches the historical
29// clamping (zero-behavior-change relocation, not a new policy).
30const MAX_SEMANTIC_TIMEOUT_MS: u64 = 120_000;
31const MAX_SEMANTIC_BATCH_SIZE: usize = 1_024;
32
33const USER_ONLY_REASON: &str =
34    "security: this setting only honors user-level config and project values are ignored";
35const SEMANTIC_SECRET_REASON: &str =
36    "security: semantic backend credentials and endpoints must come from user-level config";
37const LSP_USER_ONLY_REASON: &str =
38    "security: LSP executable-origin and diagnostic-suppression settings must come from user-level config";
39
40/// One raw config document supplied by the host plugin.
41///
42/// `tier` is trusted process metadata stamped by the caller. The document body is
43/// never allowed to relabel itself.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ConfigTier {
46    pub tier: String,
47    pub source: String,
48    pub doc: String,
49}
50
51/// A project-tier key that was intentionally ignored at the user/project trust boundary.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct DroppedKey {
54    pub key: String,
55    pub tier: String,
56    pub reason: String,
57}
58
59/// A non-fatal config issue reported back to the caller during configuration.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ConfigWarning {
62    pub code: &'static str,
63    pub key: &'static str,
64    pub tier: String,
65    pub value: String,
66    pub message: String,
67}
68
69/// Diagnostics produced while resetting an existing runtime config.
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct ResolveDiagnostics {
72    pub dropped: Vec<DroppedKey>,
73    pub warnings: Vec<ConfigWarning>,
74}
75
76/// Fully resolved core config plus trust-boundary diagnostics.
77#[derive(Debug, Clone)]
78pub struct ResolveResult {
79    pub config: Config,
80    pub dropped: Vec<DroppedKey>,
81    pub warnings: Vec<ConfigWarning>,
82}
83
84/// Strict raw shape for aft.jsonc. This mirrors the TypeScript Zod schema, not
85/// the flat runtime [`Config`]. Privileged process-state fields are deliberately
86/// absent and therefore rejected by `deny_unknown_fields`.
87#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
88#[serde(default, deny_unknown_fields)]
89pub struct RawAftConfig {
90    #[serde(rename = "$schema")]
91    pub schema: Option<String>,
92    /// Master switch read by the TypeScript plugins before they start AFT.
93    /// The resolver accepts and merges it for validation, but does not copy it
94    /// into `Config`; when this is false the plugin returns before launching the
95    /// Rust process.
96    pub enabled: Option<bool>,
97    pub edit_mode: Option<RawEditMode>,
98    pub format_on_edit: Option<bool>,
99    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
100    pub formatter_timeout_secs: Option<u32>,
101    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
102    pub type_checker_timeout_secs: Option<u32>,
103    pub validate_on_edit: Option<RawValidateOnEdit>,
104    pub formatter: Option<HashMap<String, RawFormatter>>,
105    pub checker: Option<HashMap<String, RawChecker>>,
106    pub configure_warnings_delivery: Option<RawConfigureWarningsDelivery>,
107    pub hoist_builtin_tools: Option<bool>,
108    pub tool_surface: Option<RawToolSurface>,
109    pub disabled_tools: Option<Vec<String>>,
110    pub restrict_to_project_root: Option<bool>,
111    pub search_index: Option<bool>,
112    pub index: Option<RawIndex>,
113    pub semantic_search: Option<bool>,
114    pub callgraph_store: Option<bool>,
115    #[serde(deserialize_with = "deserialize_opt_usize")]
116    pub callgraph_chunk_size: Option<usize>,
117    pub inspect: Option<RawInspect>,
118    pub backup: Option<RawBackup>,
119    pub worktree: Option<RawWorktree>,
120    pub gh_shim: Option<RawGhShim>,
121    pub git: Option<RawGit>,
122    pub sandbox: Option<RawSandbox>,
123    pub bash: Option<RawBash>,
124    pub experimental: Option<RawExperimental>,
125    pub lsp: Option<RawLsp>,
126    pub url_fetch_allow_private: Option<bool>,
127    pub semantic: Option<RawSemantic>,
128    pub auto_update: Option<bool>,
129    pub bridge: Option<RawBridge>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum RawEditMode {
134    Default,
135    Hashline,
136    Unknown(String),
137}
138
139impl<'de> Deserialize<'de> for RawEditMode {
140    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
141    where
142        D: Deserializer<'de>,
143    {
144        let value = String::deserialize(deserializer)?;
145        Ok(match value.as_str() {
146            "default" => Self::Default,
147            "hashline" => Self::Hashline,
148            _ => Self::Unknown(value),
149        })
150    }
151}
152
153#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
154#[serde(rename_all = "snake_case")]
155pub enum RawValidateOnEdit {
156    Syntax,
157    Full,
158}
159
160impl RawValidateOnEdit {
161    const fn as_str(self) -> &'static str {
162        match self {
163            Self::Syntax => "syntax",
164            Self::Full => "full",
165        }
166    }
167}
168
169#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
170#[serde(rename_all = "snake_case")]
171pub enum RawFormatter {
172    Biome,
173    Oxfmt,
174    Prettier,
175    Deno,
176    Ruff,
177    Black,
178    Rustfmt,
179    Goimports,
180    Gofmt,
181    None,
182}
183
184impl RawFormatter {
185    const fn as_str(self) -> &'static str {
186        match self {
187            Self::Biome => "biome",
188            Self::Oxfmt => "oxfmt",
189            Self::Prettier => "prettier",
190            Self::Deno => "deno",
191            Self::Ruff => "ruff",
192            Self::Black => "black",
193            Self::Rustfmt => "rustfmt",
194            Self::Goimports => "goimports",
195            Self::Gofmt => "gofmt",
196            Self::None => "none",
197        }
198    }
199}
200
201#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
202#[serde(rename_all = "snake_case")]
203pub enum RawChecker {
204    Tsc,
205    Tsgo,
206    Biome,
207    Pyright,
208    Ruff,
209    Cargo,
210    Go,
211    Staticcheck,
212    None,
213}
214
215impl RawChecker {
216    const fn as_str(self) -> &'static str {
217        match self {
218            Self::Tsc => "tsc",
219            Self::Tsgo => "tsgo",
220            Self::Biome => "biome",
221            Self::Pyright => "pyright",
222            Self::Ruff => "ruff",
223            Self::Cargo => "cargo",
224            Self::Go => "go",
225            Self::Staticcheck => "staticcheck",
226            Self::None => "none",
227        }
228    }
229}
230
231#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "snake_case")]
233pub enum RawConfigureWarningsDelivery {
234    Toast,
235    Log,
236    Chat,
237}
238
239#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
240#[serde(rename_all = "snake_case")]
241pub enum RawToolSurface {
242    Minimal,
243    Recommended,
244    All,
245}
246
247#[derive(Debug, Clone, Deserialize, PartialEq)]
248// Nested objects mirror TS sub-schemas, which are non-strict z.object (unknown
249// keys are silently stripped, the object survives). Only the TOP-LEVEL
250// RawAftConfig is strict (matches AftConfigSchema.strict()). Privileged denylist
251// fields are all top-level, so nested unknowns are harmlessly ignored.
252pub struct RawSemantic {
253    pub backend: Option<SemanticBackend>,
254    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
255    pub model: Option<String>,
256    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
257    pub base_url: Option<String>,
258    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
259    pub api_key_env: Option<String>,
260    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
261    pub timeout_ms: Option<u64>,
262    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
263    pub query_timeout_ms: Option<u64>,
264    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
265    pub max_batch_size: Option<usize>,
266    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
267    pub max_files: Option<usize>,
268}
269
270impl RawSemantic {
271    fn is_empty(&self) -> bool {
272        self.backend.is_none()
273            && self.model.is_none()
274            && self.base_url.is_none()
275            && self.api_key_env.is_none()
276            && self.timeout_ms.is_none()
277            && self.query_timeout_ms.is_none()
278            && self.max_batch_size.is_none()
279            && self.max_files.is_none()
280    }
281}
282
283#[derive(Debug, Clone, Deserialize, PartialEq)]
284pub struct RawLsp {
285    #[serde(default, deserialize_with = "deserialize_opt_lsp_servers")]
286    pub servers: Option<BTreeMap<String, RawLspServerEntry>>,
287    #[serde(
288        default,
289        deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec"
290    )]
291    pub disabled: Option<Vec<String>>,
292    pub python: Option<RawPythonLsp>,
293    pub diagnostics_on_edit: Option<bool>,
294    pub auto_install: Option<bool>,
295    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
296    pub grace_days: Option<u64>,
297    #[serde(default, deserialize_with = "deserialize_opt_versions_map")]
298    pub versions: Option<HashMap<String, String>>,
299}
300
301impl RawLsp {
302    fn is_empty(&self) -> bool {
303        self.servers.is_none()
304            && self.disabled.is_none()
305            && self.python.is_none()
306            && self.diagnostics_on_edit.is_none()
307            && self.auto_install.is_none()
308            && self.grace_days.is_none()
309            && self.versions.is_none()
310    }
311}
312
313#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
314#[serde(rename_all = "snake_case")]
315pub enum RawPythonLsp {
316    Pyright,
317    Ty,
318    Auto,
319}
320
321#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
322#[serde(default)]
323pub struct RawLspServerEntry {
324    #[serde(deserialize_with = "deserialize_opt_lsp_extensions")]
325    pub extensions: Option<Vec<String>>,
326    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
327    pub binary: Option<String>,
328    pub args: Option<Vec<String>>,
329    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec")]
330    pub root_markers: Option<Vec<String>>,
331    pub disabled: Option<bool>,
332    pub env: Option<HashMap<String, String>>,
333    pub initialization_options: Option<Value>,
334}
335
336#[derive(Debug, Clone, Deserialize, PartialEq)]
337#[serde(untagged)]
338pub enum RawBash {
339    Bool(bool),
340    Features(RawBashFeatures),
341}
342
343#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
344#[serde(default)]
345pub struct RawBashFeatures {
346    pub rewrite: Option<bool>,
347    pub compress: Option<bool>,
348    pub background: Option<bool>,
349    pub host_fallback: Option<bool>,
350    pub subagent_background: Option<bool>,
351    pub long_running_reminder_enabled: Option<bool>,
352    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
353    pub long_running_reminder_interval_ms: Option<u64>,
354    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
355    pub foreground_wait_window_ms: Option<u64>,
356}
357
358#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
359#[serde(default)]
360pub struct RawExperimental {
361    pub bash: Option<RawExperimentalBash>,
362    pub lsp_ty: Option<bool>,
363}
364
365impl RawExperimental {
366    fn is_empty(&self) -> bool {
367        self.bash.is_none() && self.lsp_ty.is_none()
368    }
369}
370
371#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
372#[serde(default)]
373pub struct RawExperimentalBash {
374    pub rewrite: Option<bool>,
375    pub compress: Option<bool>,
376    pub background: Option<bool>,
377    pub long_running_reminder_enabled: Option<bool>,
378    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
379    pub long_running_reminder_interval_ms: Option<u64>,
380}
381
382impl RawExperimentalBash {
383    fn has_any_value(&self) -> bool {
384        self.rewrite.is_some()
385            || self.compress.is_some()
386            || self.background.is_some()
387            || self.long_running_reminder_enabled.is_some()
388            || self.long_running_reminder_interval_ms.is_some()
389    }
390
391    fn has_legacy_feature_flag(&self) -> bool {
392        self.rewrite.is_some() || self.compress.is_some() || self.background.is_some()
393    }
394}
395
396#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
397#[serde(default)]
398pub struct RawInspect {
399    pub enabled: Option<bool>,
400    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
401    pub diagnostics_timeout_ms: Option<u64>,
402    #[serde(deserialize_with = "deserialize_opt_nonnegative_f64")]
403    pub tier2_idle_minutes: Option<f64>,
404    pub categories: Option<HashMap<String, bool>>,
405    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
406    pub tier2_soft_deadline_ms: Option<u64>,
407    #[serde(deserialize_with = "deserialize_opt_drill_down_items")]
408    pub max_drill_down_items: Option<usize>,
409    pub duplicates: Option<RawInspectDuplicates>,
410}
411
412impl RawInspect {
413    fn is_empty(&self) -> bool {
414        self.enabled.is_none()
415            && self.diagnostics_timeout_ms.is_none()
416            && self.tier2_idle_minutes.is_none()
417            && self.categories.is_none()
418            && self.tier2_soft_deadline_ms.is_none()
419            && self.max_drill_down_items.is_none()
420            && self.duplicates.is_none()
421    }
422}
423
424/// Only `expected_mirrors` survives here: `lower_bound`, `discard_cost`, and
425/// `anonymize` were accepted-but-never-read knobs (the scanner hardcodes its
426/// cost bounds and anonymization rules), so they were removed from the schema
427/// rather than wired up.
428#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
429#[serde(default)]
430pub struct RawInspectDuplicates {
431    pub expected_mirrors: Option<Vec<[String; 2]>>,
432}
433
434impl RawInspectDuplicates {
435    fn is_empty(&self) -> bool {
436        self.expected_mirrors.is_none()
437    }
438}
439
440#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
441#[serde(default)]
442pub struct RawBridge {
443    #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
444    pub request_timeout_ms: Option<u64>,
445    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
446    pub hang_threshold: Option<u64>,
447}
448
449#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
450#[serde(default)]
451pub struct RawWorktree {
452    pub ram_overlay: Option<bool>,
453}
454
455impl RawWorktree {
456    fn is_empty(&self) -> bool {
457        self.ram_overlay.is_none()
458    }
459}
460
461#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
462#[serde(default)]
463pub struct RawGhShim {
464    pub enabled: Option<bool>,
465    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
466    pub binary_path: Option<String>,
467}
468
469#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
470#[serde(default)]
471pub struct RawGit {
472    #[serde(deserialize_with = "deserialize_opt_git_co_author")]
473    pub co_author: Option<String>,
474}
475
476#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
477#[serde(default)]
478pub struct RawBackup {
479    pub enabled: Option<bool>,
480    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
481    pub max_depth: Option<usize>,
482    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
483    pub max_file_size: Option<u64>,
484}
485
486#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
487#[serde(default)]
488pub struct RawSandbox {
489    pub enabled: Option<bool>,
490    pub write_allow: Option<Vec<PathBuf>>,
491    pub read_deny: Option<Vec<PathBuf>>,
492}
493
494/// Raw user-tier standing index configuration. Unlike normally stripped nested
495/// fields, unknown entry fields are retained only long enough to emit an
496/// explicit warn-and-ignore notice.
497#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
498#[serde(default)]
499pub struct RawIndex {
500    pub roots: Option<Vec<RawIndexRoot>>,
501}
502
503#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
504#[serde(default)]
505pub struct RawIndexRoot {
506    pub path: Option<String>,
507    pub indexes: Option<Vec<String>>,
508    #[serde(flatten)]
509    pub unknown: BTreeMap<String, Value>,
510}
511
512/// Resolve raw user/project config tiers into the flat core [`Config`].
513///
514/// Empty input is NOT special-cased: no config file is equivalent to an empty
515/// config object, so it still flows through the resolver and picks up the bash
516/// surface default (recommended ⇒ bash on), matching the TypeScript pipeline
517/// which always runs `resolveProjectOverridesForConfigure` even on `{}`.
518pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
519    let mut merged = RawAftConfig::default();
520    let mut dropped = Vec::new();
521    let mut warnings = Vec::new();
522
523    for tier in tiers {
524        let Some(raw) = parse_tier(tier) else {
525            continue;
526        };
527        if let Some(RawEditMode::Unknown(value)) = raw.edit_mode.as_ref() {
528            warnings.push(ConfigWarning {
529                code: "invalid_edit_mode",
530                key: "edit_mode",
531                tier: tier.tier.clone(),
532                value: value.clone(),
533                message: format!("Unknown edit_mode value {value:?}; falling back to \"default\""),
534            });
535        }
536
537        if tier.tier == "user" {
538            merge_trusted_config(&mut merged, raw);
539        } else {
540            record_project_drops(&raw, &tier.tier, &mut dropped);
541            merge_project_config(&mut merged, raw);
542        }
543    }
544
545    let mut config = Config::default();
546    apply_resolved_config(&merged, &mut config);
547    config.index = resolve_index_config(merged.index.as_ref(), &mut warnings);
548    ResolveResult {
549        config,
550        dropped,
551        warnings,
552    }
553}
554
555/// Resolve raw config tiers into the core-domain config and RESET it onto an
556/// existing `base`, preserving only `base`'s process-state fields (storage_dir,
557/// harness, lsp_paths_extra, bash_permissions, …). This is the configure-path
558/// entry.
559///
560/// RESET, not overlay: the core-domain config is rebuilt from DEFAULT + the
561/// supplied tiers, so a field absent from the tiers returns to its default —
562/// it NEVER keeps `base`'s prior value. This closes a cross-bind privilege
563/// escalation: under the subc daemon a single `AppContext` per project root is
564/// shared across harness identities, and `configure` seeds `base` from the
565/// previous bind's config. With the old overlay semantics, a later low-trust
566/// bind (e.g. `mcp:*` or `fed:*`) that omitted a field inherited an earlier high-trust
567/// bind's capability for it (confirmed on the wire: `url_fetch_allow_private`
568/// SSRF, and `lsp_servers` arbitrary-binary). Reset-onto-default makes the
569/// resolved core config a pure function of THIS bind's own tiers.
570///
571/// Parity-safe by construction: this routes through [`resolve_config`] (the same
572/// entry the cross-language parity gate validates), which builds onto
573/// `Config::default()` — so reset-onto-default == overlay-onto-default there and
574/// no parity/unit fixture changes. Only this configure path, seeded from a prior
575/// config, changes behavior — which is exactly the leak site.
576///
577/// Process-state fields are not part of `RawAftConfig`; they are carried from
578/// `base` here and re-applied by `handle_configure`'s flat-param parsing
579/// afterwards, so plugin-mode behavior is unchanged (the plugin re-sends them on
580/// every configure). They are also unreachable as a subc escalation vector: a
581/// subc RouteBind sends only `config:[tiers]`, never the flat process-state
582/// params, so they stay at default for every subc bind regardless.
583pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
584    resolve_config_onto_with_diagnostics(tiers, base).dropped
585}
586
587/// Reset a runtime config while retaining both trust-boundary drops and
588/// non-fatal value warnings for the configure response.
589pub fn resolve_config_onto_with_diagnostics(
590    tiers: &[ConfigTier],
591    base: &mut Config,
592) -> ResolveDiagnostics {
593    let ResolveResult {
594        mut config,
595        dropped,
596        warnings,
597    } = resolve_config(tiers);
598    carry_process_state(base, &mut config);
599    *base = config;
600    ResolveDiagnostics { dropped, warnings }
601}
602
603/// Carry the process-state (non-`RawAftConfig`) fields from `base` onto a
604/// freshly-resolved core config. EVERY `Config` field not copied here is
605/// core-domain and intentionally comes from the resolved tiers (reset). Keeping
606/// this list complete is load-bearing: a core field accidentally copied here
607/// would re-introduce cross-bind inheritance for it.
608fn carry_process_state(base: &Config, resolved: &mut Config) {
609    resolved.project_root = base.project_root.clone();
610    resolved.harness = base.harness.clone();
611    resolved.validation_depth = base.validation_depth;
612    resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
613    resolved.max_symbol_depth = base.max_symbol_depth;
614    resolved.diagnostic_cache_size = base.diagnostic_cache_size;
615    resolved.aft_search_registered = base.aft_search_registered;
616    resolved.max_background_bash_tasks = base.max_background_bash_tasks;
617    resolved.bash_permissions = base.bash_permissions;
618    resolved.search_index_max_file_size = base.search_index_max_file_size;
619    resolved.storage_dir = base.storage_dir.clone();
620    resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
621    resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
622    resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
623}
624
625fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
626    let stripped = strip_jsonc(&tier.doc);
627    let value = serde_json::from_str::<Value>(&stripped).ok()?;
628    let Value::Object(map) = value else {
629        return None;
630    };
631
632    match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
633        Ok(config) => Some(config),
634        Err(_) => Some(parse_config_partially(map)),
635    }
636}
637
638fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
639    let mut partial = RawAftConfig::default();
640
641    for (key, value) in raw_config {
642        let mut one_field = Map::new();
643        one_field.insert(key, value);
644        if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
645            merge_trusted_config(&mut partial, section);
646        }
647    }
648
649    partial
650}
651
652fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
653    if override_config.schema.is_some() {
654        base.schema = override_config.schema;
655    }
656    if override_config.enabled.is_some() {
657        base.enabled = override_config.enabled;
658    }
659    if override_config.edit_mode.is_some() {
660        base.edit_mode = override_config.edit_mode;
661    }
662    if override_config.format_on_edit.is_some() {
663        base.format_on_edit = override_config.format_on_edit;
664    }
665    if override_config.formatter_timeout_secs.is_some() {
666        base.formatter_timeout_secs = override_config.formatter_timeout_secs;
667    }
668    if override_config.type_checker_timeout_secs.is_some() {
669        base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
670    }
671    if override_config.validate_on_edit.is_some() {
672        base.validate_on_edit = override_config.validate_on_edit;
673    }
674    if override_config.formatter.is_some() {
675        base.formatter = override_config.formatter;
676    }
677    if override_config.checker.is_some() {
678        base.checker = override_config.checker;
679    }
680    if override_config.configure_warnings_delivery.is_some() {
681        base.configure_warnings_delivery = override_config.configure_warnings_delivery;
682    }
683    if override_config.hoist_builtin_tools.is_some() {
684        base.hoist_builtin_tools = override_config.hoist_builtin_tools;
685    }
686    if override_config.tool_surface.is_some() {
687        base.tool_surface = override_config.tool_surface;
688    }
689    if override_config.disabled_tools.is_some() {
690        base.disabled_tools = override_config.disabled_tools;
691    }
692    if override_config.restrict_to_project_root.is_some() {
693        base.restrict_to_project_root = override_config.restrict_to_project_root;
694    }
695    if override_config.search_index.is_some() {
696        base.search_index = override_config.search_index;
697    }
698    if override_config.index.is_some() {
699        base.index = override_config.index;
700    }
701    if override_config.semantic_search.is_some() {
702        base.semantic_search = override_config.semantic_search;
703    }
704    if override_config.callgraph_store.is_some() {
705        base.callgraph_store = override_config.callgraph_store;
706    }
707    if override_config.callgraph_chunk_size.is_some() {
708        base.callgraph_chunk_size = override_config.callgraph_chunk_size;
709    }
710    if override_config.inspect.is_some() {
711        base.inspect = override_config.inspect;
712    }
713    if override_config.backup.is_some() {
714        base.backup = override_config.backup;
715    }
716    if override_config.worktree.is_some() {
717        base.worktree = override_config.worktree;
718    }
719    if override_config.gh_shim.is_some() {
720        base.gh_shim = override_config.gh_shim;
721    }
722    if override_config.git.is_some() {
723        base.git = override_config.git;
724    }
725    if override_config.sandbox.is_some() {
726        base.sandbox = override_config.sandbox;
727    }
728    if override_config.bash.is_some() {
729        base.bash = override_config.bash;
730    }
731    if override_config.experimental.is_some() {
732        base.experimental = override_config.experimental;
733    }
734    if override_config.lsp.is_some() {
735        base.lsp = override_config.lsp;
736    }
737    if override_config.url_fetch_allow_private.is_some() {
738        base.url_fetch_allow_private = override_config.url_fetch_allow_private;
739    }
740    if override_config.semantic.is_some() {
741        base.semantic = override_config.semantic;
742    }
743    if override_config.auto_update.is_some() {
744        base.auto_update = override_config.auto_update;
745    }
746    if override_config.bridge.is_some() {
747        base.bridge = override_config.bridge;
748    }
749}
750
751fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
752    // Project-safe shallow top-level fields.
753    if project.enabled.is_some() {
754        base.enabled = project.enabled;
755    }
756    if project.edit_mode.is_some() {
757        base.edit_mode = project.edit_mode;
758    }
759    if project.format_on_edit.is_some() {
760        base.format_on_edit = project.format_on_edit;
761    }
762    if project.validate_on_edit.is_some() {
763        base.validate_on_edit = project.validate_on_edit;
764    }
765    if project.configure_warnings_delivery.is_some() {
766        base.configure_warnings_delivery = project.configure_warnings_delivery;
767    }
768    if project.hoist_builtin_tools.is_some() {
769        base.hoist_builtin_tools = project.hoist_builtin_tools;
770    }
771    if project.tool_surface.is_some() {
772        base.tool_surface = project.tool_surface;
773    }
774    if project.search_index.is_some() {
775        base.search_index = project.search_index;
776    }
777    if project.semantic_search.is_some() {
778        base.semantic_search = project.semantic_search;
779    }
780    if project.callgraph_store.is_some() {
781        base.callgraph_store = project.callgraph_store;
782    }
783    if project.callgraph_chunk_size.is_some() {
784        base.callgraph_chunk_size = project.callgraph_chunk_size;
785    }
786
787    merge_formatter_map(&mut base.formatter, project.formatter);
788    merge_checker_map(&mut base.checker, project.checker);
789    merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
790    base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
791    base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
792    base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
793    base.bash = merge_bash_config(base.bash.clone(), project.bash);
794    base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
795    base.worktree = merge_worktree_config(base.worktree.clone(), project.worktree);
796    if project.git.is_some() {
797        base.git = project.git;
798    }
799    base.sandbox = merge_project_sandbox(base.sandbox.clone(), project.sandbox);
800}
801
802fn merge_project_sandbox(
803    base: Option<RawSandbox>,
804    project: Option<RawSandbox>,
805) -> Option<RawSandbox> {
806    let Some(project) = project else {
807        return base;
808    };
809    let mut sandbox = base.unwrap_or_default();
810    if let Some(project_denies) = project.read_deny {
811        let denies = sandbox.read_deny.get_or_insert_with(Vec::new);
812        for path in project_denies {
813            if !denies.contains(&path) {
814                denies.push(path);
815            }
816        }
817    }
818    // A project may ENABLE the sandbox for itself (hardening is one-way): a
819    // repo can opt its own bash into kernel confinement, but a project-tier
820    // `enabled: false` can never switch off what the user turned on.
821    if project.enabled == Some(true) {
822        sandbox.enabled = Some(true);
823    }
824    (sandbox.enabled.is_some() || sandbox.write_allow.is_some() || sandbox.read_deny.is_some())
825        .then_some(sandbox)
826}
827
828fn merge_formatter_map(
829    base: &mut Option<HashMap<String, RawFormatter>>,
830    override_map: Option<HashMap<String, RawFormatter>>,
831) {
832    let Some(override_map) = override_map else {
833        return;
834    };
835    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
836        return;
837    }
838    let target = base.get_or_insert_with(HashMap::new);
839    target.extend(override_map);
840}
841
842fn merge_checker_map(
843    base: &mut Option<HashMap<String, RawChecker>>,
844    override_map: Option<HashMap<String, RawChecker>>,
845) {
846    let Some(override_map) = override_map else {
847        return;
848    };
849    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
850        return;
851    }
852    let target = base.get_or_insert_with(HashMap::new);
853    target.extend(override_map);
854}
855
856fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
857    let Some(override_tools) = override_tools else {
858        return;
859    };
860    let mut merged = Vec::new();
861    let mut seen = HashSet::new();
862    for tool in base.iter().flatten() {
863        if seen.insert(tool.clone()) {
864            merged.push(tool.clone());
865        }
866    }
867    for tool in override_tools
868        .iter()
869        .filter(|tool| tool.as_str() != "aft_safety")
870    {
871        if seen.insert(tool.clone()) {
872            merged.push(tool.clone());
873        }
874    }
875    if !merged.is_empty() {
876        *base = Some(merged);
877    }
878}
879
880fn merge_semantic_config(
881    base: Option<RawSemantic>,
882    override_semantic: Option<RawSemantic>,
883) -> Option<RawSemantic> {
884    let mut semantic = base.unwrap_or(RawSemantic {
885        backend: None,
886        model: None,
887        base_url: None,
888        api_key_env: None,
889        timeout_ms: None,
890        query_timeout_ms: None,
891        max_batch_size: None,
892        max_files: None,
893    });
894
895    if let Some(project) = override_semantic {
896        if project.model.is_some() {
897            semantic.model = project.model;
898        }
899        if project.timeout_ms.is_some() {
900            semantic.timeout_ms = project.timeout_ms;
901        }
902        if project.max_batch_size.is_some() {
903            semantic.max_batch_size = project.max_batch_size;
904        }
905        if project.max_files.is_some() {
906            semantic.max_files = project.max_files;
907        }
908    }
909
910    (!semantic.is_empty()).then_some(semantic)
911}
912
913fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
914    let mut lsp = base.unwrap_or(RawLsp {
915        servers: None,
916        disabled: None,
917        python: None,
918        diagnostics_on_edit: None,
919        auto_install: None,
920        grace_days: None,
921        versions: None,
922    });
923
924    if let Some(project) = override_lsp {
925        if project.python.is_some() {
926            lsp.python = project.python;
927        }
928        if project.diagnostics_on_edit.is_some() {
929            lsp.diagnostics_on_edit = project.diagnostics_on_edit;
930        }
931    }
932
933    (!lsp.is_empty()).then_some(lsp)
934}
935
936fn merge_experimental_config(
937    base: Option<RawExperimental>,
938    override_experimental: Option<RawExperimental>,
939) -> Option<RawExperimental> {
940    let Some(override_experimental) = override_experimental else {
941        return base;
942    };
943
944    let mut experimental = base.unwrap_or_default();
945    experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
946    experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
947
948    (!experimental.is_empty()).then_some(experimental)
949}
950
951fn merge_experimental_bash(
952    base: Option<RawExperimentalBash>,
953    override_bash: Option<RawExperimentalBash>,
954) -> Option<RawExperimentalBash> {
955    let Some(override_bash) = override_bash else {
956        return base;
957    };
958    let mut bash = base.unwrap_or_default();
959    bash.rewrite = override_bash.rewrite.or(bash.rewrite);
960    bash.compress = override_bash.compress.or(bash.compress);
961    bash.background = override_bash.background.or(bash.background);
962    bash.long_running_reminder_enabled = override_bash
963        .long_running_reminder_enabled
964        .or(bash.long_running_reminder_enabled);
965    bash.long_running_reminder_interval_ms = override_bash
966        .long_running_reminder_interval_ms
967        .or(bash.long_running_reminder_interval_ms);
968
969    bash.has_any_value().then_some(bash)
970}
971
972fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
973    match (base, override_bash) {
974        (None, None) => None,
975        (None, Some(override_bash)) => Some(override_bash),
976        (Some(base), None) => Some(base),
977        (Some(base), Some(override_bash)) => {
978            let base = expand_bash_for_merge(&base);
979            let override_features = expand_bash_for_merge(&override_bash);
980            Some(RawBash::Features(RawBashFeatures {
981                rewrite: override_features.rewrite.or(base.rewrite),
982                compress: override_features.compress.or(base.compress),
983                background: override_features.background.or(base.background),
984                host_fallback: override_features.host_fallback.or(base.host_fallback),
985                subagent_background: override_features
986                    .subagent_background
987                    .or(base.subagent_background),
988                long_running_reminder_enabled: override_features
989                    .long_running_reminder_enabled
990                    .or(base.long_running_reminder_enabled),
991                long_running_reminder_interval_ms: override_features
992                    .long_running_reminder_interval_ms
993                    .or(base.long_running_reminder_interval_ms),
994                foreground_wait_window_ms: override_features
995                    .foreground_wait_window_ms
996                    .or(base.foreground_wait_window_ms),
997            }))
998        }
999    }
1000}
1001
1002fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
1003    match value {
1004        RawBash::Bool(enabled) => RawBashFeatures {
1005            rewrite: Some(*enabled),
1006            compress: Some(*enabled),
1007            background: Some(*enabled),
1008            host_fallback: None,
1009            subagent_background: None,
1010            long_running_reminder_enabled: None,
1011            long_running_reminder_interval_ms: None,
1012            foreground_wait_window_ms: None,
1013        },
1014        RawBash::Features(features) => features.clone(),
1015    }
1016}
1017
1018fn merge_inspect_config(
1019    base: Option<RawInspect>,
1020    override_inspect: Option<RawInspect>,
1021) -> Option<RawInspect> {
1022    let Some(override_inspect) = override_inspect else {
1023        return base;
1024    };
1025
1026    let mut inspect = base.unwrap_or_default();
1027    inspect.enabled = override_inspect.enabled.or(inspect.enabled);
1028    if let Some(project_timeout) = override_inspect.diagnostics_timeout_ms {
1029        // A project may ask for more time, but it must not silently shrink another
1030        // consumer's diagnostic completeness by reducing the user's effective wait.
1031        inspect.diagnostics_timeout_ms = Some(
1032            project_timeout.max(
1033                inspect
1034                    .diagnostics_timeout_ms
1035                    .unwrap_or(DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS),
1036            ),
1037        );
1038    }
1039    inspect.tier2_idle_minutes = override_inspect
1040        .tier2_idle_minutes
1041        .or(inspect.tier2_idle_minutes);
1042    inspect.categories = override_inspect.categories.or(inspect.categories);
1043    inspect.tier2_soft_deadline_ms = override_inspect
1044        .tier2_soft_deadline_ms
1045        .or(inspect.tier2_soft_deadline_ms);
1046    inspect.max_drill_down_items = override_inspect
1047        .max_drill_down_items
1048        .or(inspect.max_drill_down_items);
1049    inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
1050
1051    (!inspect.is_empty()).then_some(inspect)
1052}
1053
1054fn merge_worktree_config(
1055    base: Option<RawWorktree>,
1056    override_worktree: Option<RawWorktree>,
1057) -> Option<RawWorktree> {
1058    let Some(override_worktree) = override_worktree else {
1059        return base;
1060    };
1061
1062    let mut worktree = base.unwrap_or_default();
1063    worktree.ram_overlay = override_worktree.ram_overlay.or(worktree.ram_overlay);
1064    (!worktree.is_empty()).then_some(worktree)
1065}
1066
1067fn merge_inspect_duplicates(
1068    base: Option<RawInspectDuplicates>,
1069    override_duplicates: Option<RawInspectDuplicates>,
1070) -> Option<RawInspectDuplicates> {
1071    let Some(override_duplicates) = override_duplicates else {
1072        return base;
1073    };
1074
1075    let mut duplicates = base.unwrap_or_default();
1076    duplicates.expected_mirrors = override_duplicates
1077        .expected_mirrors
1078        .or(duplicates.expected_mirrors);
1079
1080    (!duplicates.is_empty()).then_some(duplicates)
1081}
1082
1083fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
1084    if raw.restrict_to_project_root.is_some() {
1085        push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
1086    }
1087    if raw.url_fetch_allow_private.is_some() {
1088        push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
1089    }
1090    if raw.formatter_timeout_secs.is_some() {
1091        push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
1092    }
1093    if raw.type_checker_timeout_secs.is_some() {
1094        push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
1095    }
1096    if raw.auto_update.is_some() {
1097        push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
1098    }
1099    if raw.bridge.is_some() {
1100        push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
1101    }
1102    if raw.backup.is_some() {
1103        push_drop(dropped, "backup", tier, USER_ONLY_REASON);
1104    }
1105    if raw.gh_shim.is_some() {
1106        push_drop(dropped, "gh_shim", tier, USER_ONLY_REASON);
1107    }
1108    if raw
1109        .index
1110        .as_ref()
1111        .and_then(|index| index.roots.as_ref())
1112        .is_some()
1113    {
1114        push_drop(dropped, "index.roots", tier, USER_ONLY_REASON);
1115    }
1116    if let Some(sandbox) = &raw.sandbox {
1117        // enabled:true is an accepted project-tier hardening opt-in (merged by
1118        // merge_project_sandbox); only the weakening direction is dropped.
1119        if sandbox.enabled == Some(false) {
1120            push_drop(dropped, "sandbox.enabled", tier, USER_ONLY_REASON);
1121        }
1122        if sandbox.write_allow.is_some() {
1123            push_drop(dropped, "sandbox.write_allow", tier, USER_ONLY_REASON);
1124        }
1125    }
1126    if raw
1127        .disabled_tools
1128        .as_ref()
1129        .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
1130    {
1131        push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
1132    }
1133
1134    if let Some(semantic) = &raw.semantic {
1135        if semantic.backend.is_some() {
1136            push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
1137        }
1138        if semantic.base_url.is_some() {
1139            push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
1140        }
1141        if semantic.api_key_env.is_some() {
1142            push_drop(
1143                dropped,
1144                "semantic.api_key_env",
1145                tier,
1146                SEMANTIC_SECRET_REASON,
1147            );
1148        }
1149        if semantic.query_timeout_ms.is_some() {
1150            push_drop(dropped, "semantic.query_timeout_ms", tier, USER_ONLY_REASON);
1151        }
1152    }
1153
1154    if let Some(lsp) = &raw.lsp {
1155        if lsp.servers.is_some() {
1156            push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
1157        }
1158        if lsp.versions.is_some() {
1159            push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
1160        }
1161        if lsp.auto_install.is_some() {
1162            push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
1163        }
1164        if lsp.grace_days.is_some() {
1165            push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
1166        }
1167        if lsp.disabled.is_some() {
1168            push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
1169        }
1170    }
1171}
1172
1173fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
1174    dropped.push(DroppedKey {
1175        key: key.to_string(),
1176        tier: tier.to_string(),
1177        reason: reason.to_string(),
1178    });
1179}
1180
1181/// Apply merged core-domain fields onto a freshly defaulted `Config`. Absent
1182/// scalar fields therefore retain defaults, while semantic, inspect, and LSP
1183/// fields are fully resolved from the tiers. Process-state fields are not part
1184/// of `RawAftConfig` and are preserved separately by `resolve_config_onto`.
1185fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
1186    config.hashline_enabled = matches!(raw.edit_mode, Some(RawEditMode::Hashline));
1187    if let Some(value) = raw.format_on_edit {
1188        config.format_on_edit = value;
1189    }
1190    if let Some(value) = raw.formatter_timeout_secs {
1191        config.formatter_timeout_secs = value;
1192    }
1193    if let Some(value) = raw.type_checker_timeout_secs {
1194        config.type_checker_timeout_secs = value;
1195    }
1196    if let Some(value) = raw.validate_on_edit {
1197        config.validate_on_edit = Some(value.as_str().to_string());
1198    }
1199    if let Some(formatter) = &raw.formatter {
1200        config.formatter = formatter
1201            .iter()
1202            .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
1203            .collect();
1204    }
1205    if let Some(checker) = &raw.checker {
1206        config.checker = checker
1207            .iter()
1208            .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
1209            .collect();
1210    }
1211    if let Some(value) = raw.restrict_to_project_root {
1212        config.restrict_to_project_root = value;
1213    }
1214    if let Some(value) = raw.search_index {
1215        config.search_index = value;
1216    }
1217    if let Some(value) = raw.semantic_search {
1218        config.semantic_search = value;
1219    }
1220    if let Some(value) = raw.callgraph_store {
1221        config.callgraph_store = value;
1222    }
1223    if let Some(value) = raw.callgraph_chunk_size {
1224        config.callgraph_chunk_size = value;
1225    }
1226    if let Some(value) = raw.url_fetch_allow_private {
1227        config.url_fetch_allow_private = value;
1228    }
1229    config.semantic = resolve_semantic_config(raw.semantic.as_ref());
1230    config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1231    config.backup = resolve_backup_config(raw.backup.as_ref());
1232    config.worktree = resolve_worktree_config(raw.worktree.as_ref());
1233    config.gh_shim = resolve_gh_shim_config(raw.gh_shim.as_ref());
1234    config.git = resolve_git_config(raw.git.as_ref());
1235    config.sandbox = resolve_sandbox_config(raw.sandbox.as_ref());
1236    resolve_lsp_config(raw, config);
1237    resolve_bash_fields(raw, config);
1238}
1239
1240fn resolve_index_config(raw: Option<&RawIndex>, warnings: &mut Vec<ConfigWarning>) -> IndexConfig {
1241    let Some(raw) = raw else {
1242        return IndexConfig::default();
1243    };
1244    let Some(roots) = raw.roots.as_ref() else {
1245        return IndexConfig::default();
1246    };
1247
1248    let home = std::env::var_os("HOME")
1249        .or_else(|| std::env::var_os("USERPROFILE"))
1250        .map(PathBuf::from);
1251    let mut normalized_roots = Vec::with_capacity(roots.len());
1252
1253    for (position, root) in roots.iter().enumerate() {
1254        for field in root.unknown.keys() {
1255            warnings.push(ConfigWarning {
1256                code: "unknown_index_root_field",
1257                key: "index.roots",
1258                tier: "user".to_string(),
1259                value: field.clone(),
1260                message: format!(
1261                    "Ignoring unknown field index.roots[{position}].{field}; only path and indexes are defined"
1262                ),
1263            });
1264        }
1265
1266        let result = (|| -> Result<IndexRootConfig, String> {
1267            let path = root.path.as_deref().ok_or_else(|| {
1268                "index.roots entry is missing required string field path".to_string()
1269            })?;
1270            expand_index_root_path(path, home.as_deref())?;
1271
1272            let indexes = root.indexes.as_ref().ok_or_else(|| {
1273                "index.roots entry is missing required non-empty indexes array".to_string()
1274            })?;
1275            if indexes.is_empty() {
1276                return Err("index.roots indexes must be a non-empty array".to_string());
1277            }
1278
1279            let mut normalized = Vec::with_capacity(indexes.len() + 1);
1280            for name in indexes {
1281                let kind = IndexKind::from_name(name).ok_or_else(|| {
1282                    format!(
1283                        "index.roots indexes contains unknown name {name:?}; valid names: search, semantic, callgraph"
1284                    )
1285                })?;
1286                if normalized.contains(&kind) {
1287                    return Err(format!(
1288                        "index.roots indexes contains duplicate name {name:?}"
1289                    ));
1290                }
1291                normalized.push(kind);
1292            }
1293            if normalized.contains(&IndexKind::Semantic) && !normalized.contains(&IndexKind::Search)
1294            {
1295                normalized.push(IndexKind::Search);
1296                warnings.push(ConfigWarning {
1297                    code: "index_dependency_closure",
1298                    key: "index.roots",
1299                    tier: "user".to_string(),
1300                    value: path.to_string(),
1301                    message: format!(
1302                        "Added search to index.roots[{position}].indexes because semantic depends on search"
1303                    ),
1304                });
1305            }
1306            normalized.sort_unstable();
1307            Ok(IndexRootConfig {
1308                path: path.to_string(),
1309                indexes: normalized,
1310            })
1311        })();
1312
1313        match result {
1314            Ok(root) => normalized_roots.push(root),
1315            Err(message) => {
1316                warnings.push(ConfigWarning {
1317                    code: "invalid_index_roots",
1318                    key: "index.roots",
1319                    tier: "user".to_string(),
1320                    value: position.to_string(),
1321                    message,
1322                });
1323                return IndexConfig::default();
1324            }
1325        }
1326    }
1327
1328    IndexConfig {
1329        roots: normalized_roots,
1330    }
1331}
1332
1333fn resolve_semantic_config(raw: Option<&RawSemantic>) -> SemanticBackendConfig {
1334    let mut semantic = SemanticBackendConfig::default();
1335    let Some(raw) = raw else {
1336        return semantic;
1337    };
1338
1339    if let Some(value) = raw.backend {
1340        semantic.backend = value;
1341    }
1342    if let Some(value) = &raw.model {
1343        semantic.model = value.clone();
1344    }
1345    if let Some(value) = &raw.base_url {
1346        semantic.base_url = Some(value.clone());
1347    }
1348    if let Some(value) = &raw.api_key_env {
1349        semantic.api_key_env = Some(value.clone());
1350    }
1351    if let Some(value) = raw.timeout_ms {
1352        semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1353    }
1354    if let Some(value) = raw.query_timeout_ms {
1355        semantic.query_timeout_ms =
1356            value.clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS);
1357    }
1358    if let Some(value) = raw.max_batch_size {
1359        semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1360    }
1361    if let Some(value) = raw.max_files {
1362        semantic.max_files = value;
1363    }
1364
1365    semantic
1366}
1367
1368fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1369    let mut inspect = InspectConfig::default();
1370    let Some(raw) = raw else {
1371        return inspect;
1372    };
1373    if let Some(enabled) = raw.enabled {
1374        inspect.enabled = enabled;
1375    }
1376    if let Some(value) = raw.diagnostics_timeout_ms {
1377        inspect.diagnostics_timeout_ms = value.clamp(
1378            MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1379            MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1380        );
1381    }
1382    if let Some(expected_mirrors) = raw
1383        .duplicates
1384        .as_ref()
1385        .and_then(|duplicates| duplicates.expected_mirrors.clone())
1386    {
1387        inspect.duplicates.expected_mirrors = expected_mirrors;
1388    }
1389    inspect
1390}
1391
1392fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1393    let mut backup = BackupConfig::default();
1394    if let Some(raw) = raw {
1395        if raw.enabled.is_some() {
1396            backup.enabled = raw.enabled;
1397        }
1398        if raw.max_depth.is_some() {
1399            backup.max_depth = raw.max_depth;
1400        }
1401        if raw.max_file_size.is_some() {
1402            backup.max_file_size = raw.max_file_size;
1403        }
1404    }
1405    backup
1406}
1407
1408fn resolve_worktree_config(raw: Option<&RawWorktree>) -> WorktreeConfig {
1409    let mut worktree = WorktreeConfig::default();
1410    if let Some(value) = raw.and_then(|raw| raw.ram_overlay) {
1411        worktree.ram_overlay = value;
1412    }
1413    worktree
1414}
1415
1416fn resolve_gh_shim_config(raw: Option<&RawGhShim>) -> GhShimConfig {
1417    let mut gh_shim = GhShimConfig::default();
1418    if let Some(value) = raw.and_then(|raw| raw.enabled) {
1419        gh_shim.enabled = value;
1420    }
1421    gh_shim.binary_path = raw
1422        .and_then(|raw| raw.binary_path.as_ref())
1423        .map(PathBuf::from);
1424    gh_shim
1425}
1426
1427fn resolve_git_config(raw: Option<&RawGit>) -> GitConfig {
1428    GitConfig {
1429        co_author: raw
1430            .and_then(|raw| raw.co_author.clone())
1431            .unwrap_or_else(|| "off".to_string()),
1432    }
1433}
1434
1435fn resolve_sandbox_config(raw: Option<&RawSandbox>) -> SandboxConfig {
1436    let Some(raw) = raw else {
1437        return SandboxConfig::default();
1438    };
1439    SandboxConfig {
1440        enabled: raw.enabled.unwrap_or(false),
1441        write_allow: raw.write_allow.clone().unwrap_or_default(),
1442        read_deny: raw.read_deny.clone().unwrap_or_default(),
1443    }
1444}
1445
1446fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1447    let lsp = raw.lsp.as_ref();
1448    let mut disabled: HashSet<String> = lsp
1449        .and_then(|lsp| lsp.disabled.as_ref())
1450        .into_iter()
1451        .flatten()
1452        .map(|value| value.to_ascii_lowercase())
1453        .collect();
1454    let mut experimental_ty = raw
1455        .experimental
1456        .as_ref()
1457        .and_then(|experimental| experimental.lsp_ty);
1458
1459    match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1460        RawPythonLsp::Ty => {
1461            experimental_ty = Some(true);
1462            disabled.insert("python".to_string());
1463        }
1464        RawPythonLsp::Pyright => {
1465            experimental_ty = Some(false);
1466            disabled.insert("ty".to_string());
1467        }
1468        RawPythonLsp::Auto => {}
1469    }
1470
1471    if let Some(value) = experimental_ty {
1472        config.experimental_lsp_ty = value;
1473    }
1474
1475    if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1476        config.diagnostics_on_edit = value;
1477    }
1478
1479    if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1480        config.lsp_servers = servers
1481            .iter()
1482            .map(|(id, server)| UserServerDef {
1483                id: id.clone(),
1484                extensions: server
1485                    .extensions
1486                    .clone()
1487                    .unwrap_or_default()
1488                    .into_iter()
1489                    .map(|extension| extension.trim_start_matches('.').to_string())
1490                    .collect(),
1491                binary: server.binary.clone().unwrap_or_default(),
1492                args: server.args.clone().unwrap_or_default(),
1493                root_markers: server
1494                    .root_markers
1495                    .clone()
1496                    .unwrap_or_else(|| vec![".git".to_string()]),
1497                env: server.env.clone().unwrap_or_default(),
1498                initialization_options: server.initialization_options.clone(),
1499                disabled: server.disabled.unwrap_or(false),
1500            })
1501            .collect();
1502    }
1503
1504    if !disabled.is_empty() {
1505        config.disabled_lsp = disabled;
1506    }
1507}
1508
1509#[derive(Debug, Clone, PartialEq, Eq)]
1510struct ResolvedBashConfig {
1511    enabled: bool,
1512    rewrite: bool,
1513    compress: bool,
1514    background: bool,
1515    host_fallback: bool,
1516    subagent_background: bool,
1517    long_running_reminder_enabled: Option<bool>,
1518    long_running_reminder_interval_ms: Option<u64>,
1519    foreground_wait_window_ms: u64,
1520}
1521
1522fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config) {
1523    let bash = resolve_bash_config(raw);
1524    // The plugins use `enabled` and `subagent_background` when registering bash
1525    // capabilities. Rust resolves them only to accept and merge the same config;
1526    // they do not control engine behavior.
1527    let _registration_only = (bash.enabled, bash.subagent_background);
1528    config.bash.host_fallback = bash.host_fallback;
1529    config.experimental_bash_rewrite = bash.rewrite;
1530    config.experimental_bash_compress = bash.compress;
1531    config.experimental_bash_background = bash.background;
1532    config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
1533    if let Some(value) = bash.long_running_reminder_enabled {
1534        config.bash_long_running_reminder_enabled = value;
1535    }
1536    if let Some(value) = bash.long_running_reminder_interval_ms {
1537        config.bash_long_running_reminder_interval_ms = value;
1538    }
1539}
1540
1541fn resolve_bash_config(raw: &RawAftConfig) -> ResolvedBashConfig {
1542    let top = raw.bash.as_ref();
1543    let legacy = raw
1544        .experimental
1545        .as_ref()
1546        .and_then(|experimental| experimental.bash.as_ref());
1547    let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
1548    let surface_default_enabled = surface != RawToolSurface::Minimal;
1549
1550    let top_features = match top {
1551        Some(RawBash::Features(features)) => Some(features),
1552        _ => None,
1553    };
1554    let reminder_enabled = top_features
1555        .and_then(|features| features.long_running_reminder_enabled)
1556        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
1557    let reminder_interval = top_features
1558        .and_then(|features| features.long_running_reminder_interval_ms)
1559        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
1560    let top_host_fallback = top_features
1561        .and_then(|features| features.host_fallback)
1562        .unwrap_or(false);
1563    let top_subagent_background = top_features
1564        .and_then(|features| features.subagent_background)
1565        .unwrap_or(false);
1566    let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
1567    let foreground_wait_window_ms = raw_foreground_wait
1568        .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
1569        .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
1570
1571    let base = ResolvedBashConfig {
1572        enabled: false,
1573        rewrite: false,
1574        compress: false,
1575        background: false,
1576        host_fallback: false,
1577        subagent_background: false,
1578        long_running_reminder_enabled: reminder_enabled,
1579        long_running_reminder_interval_ms: reminder_interval,
1580        foreground_wait_window_ms,
1581    };
1582
1583    match top {
1584        Some(RawBash::Bool(false)) => base,
1585        Some(RawBash::Bool(true)) => ResolvedBashConfig {
1586            enabled: true,
1587            rewrite: true,
1588            compress: true,
1589            background: true,
1590            ..base
1591        },
1592        Some(RawBash::Features(features)) => ResolvedBashConfig {
1593            enabled: true,
1594            rewrite: features.rewrite.unwrap_or(true),
1595            compress: features.compress.unwrap_or(true),
1596            background: features.background.unwrap_or(true),
1597            host_fallback: top_host_fallback,
1598            subagent_background: top_subagent_background,
1599            ..base
1600        },
1601        None => {
1602            if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
1603                let legacy = legacy.cloned().unwrap_or_default();
1604                let rewrite = legacy.rewrite == Some(true);
1605                let compress = legacy.compress == Some(true);
1606                let background = legacy.background == Some(true);
1607                return ResolvedBashConfig {
1608                    enabled: rewrite || compress || background,
1609                    rewrite,
1610                    compress,
1611                    background,
1612                    ..base
1613                };
1614            }
1615
1616            ResolvedBashConfig {
1617                enabled: surface_default_enabled,
1618                rewrite: surface_default_enabled,
1619                compress: surface_default_enabled,
1620                background: surface_default_enabled,
1621                ..base
1622            }
1623        }
1624    }
1625}
1626
1627fn deserialize_opt_git_co_author<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
1628where
1629    D: Deserializer<'de>,
1630{
1631    let value = Option::<String>::deserialize(deserializer)?;
1632    value
1633        .map(|value| {
1634            normalize_git_co_author(&value).ok_or_else(|| {
1635                de::Error::custom(
1636                    "git.co_author must be 'off', 'auto', or an explicit 'Name <email>' identity",
1637                )
1638            })
1639        })
1640        .transpose()
1641}
1642
1643fn deserialize_opt_trimmed_non_empty_string<'de, D>(
1644    deserializer: D,
1645) -> Result<Option<String>, D::Error>
1646where
1647    D: Deserializer<'de>,
1648{
1649    let value = Option::<String>::deserialize(deserializer)?;
1650    value
1651        .map(|value| {
1652            let trimmed = value.trim().to_string();
1653            if trimmed.is_empty() {
1654                Err(de::Error::custom("must be a non-empty string"))
1655            } else {
1656                Ok(trimmed)
1657            }
1658        })
1659        .transpose()
1660}
1661
1662fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
1663    deserializer: D,
1664) -> Result<Option<Vec<String>>, D::Error>
1665where
1666    D: Deserializer<'de>,
1667{
1668    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1669    value
1670        .map(|values| {
1671            values
1672                .into_iter()
1673                .map(|value| {
1674                    let trimmed = value.trim().to_string();
1675                    if trimmed.is_empty() {
1676                        Err(de::Error::custom("array entries must be non-empty strings"))
1677                    } else {
1678                        Ok(trimmed)
1679                    }
1680                })
1681                .collect()
1682        })
1683        .transpose()
1684}
1685
1686fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1687where
1688    D: Deserializer<'de>,
1689{
1690    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1691    value
1692        .map(|values| {
1693            if values.is_empty() {
1694                return Err(de::Error::custom(
1695                    "extensions must contain at least one entry",
1696                ));
1697            }
1698            values
1699                .into_iter()
1700                .map(|value| {
1701                    let trimmed = value.trim().to_string();
1702                    if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
1703                        Err(de::Error::custom(
1704                            "extension must include characters other than leading dots",
1705                        ))
1706                    } else {
1707                        Ok(trimmed)
1708                    }
1709                })
1710                .collect()
1711        })
1712        .transpose()
1713}
1714
1715fn deserialize_opt_lsp_servers<'de, D>(
1716    deserializer: D,
1717) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
1718where
1719    D: Deserializer<'de>,
1720{
1721    let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
1722    value
1723        .map(|entries| {
1724            entries
1725                .into_iter()
1726                .map(|(key, value)| {
1727                    let trimmed = key.trim().to_string();
1728                    if trimmed.is_empty() {
1729                        Err(de::Error::custom(
1730                            "lsp.servers keys must be non-empty strings",
1731                        ))
1732                    } else {
1733                        Ok((trimmed, value))
1734                    }
1735                })
1736                .collect()
1737        })
1738        .transpose()
1739}
1740
1741fn deserialize_opt_versions_map<'de, D>(
1742    deserializer: D,
1743) -> Result<Option<HashMap<String, String>>, D::Error>
1744where
1745    D: Deserializer<'de>,
1746{
1747    let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
1748    value
1749        .map(|entries| {
1750            entries
1751                .into_iter()
1752                .map(|(key, value)| {
1753                    let trimmed_key = key.trim().to_string();
1754                    let trimmed_value = value.trim().to_string();
1755                    if trimmed_key.is_empty() || trimmed_value.is_empty() {
1756                        Err(de::Error::custom(
1757                            "lsp.versions keys and values must be non-empty strings",
1758                        ))
1759                    } else {
1760                        Ok((trimmed_key, trimmed_value))
1761                    }
1762                })
1763                .collect()
1764        })
1765        .transpose()
1766}
1767
1768fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1769where
1770    D: Deserializer<'de>,
1771{
1772    let value = Option::<u64>::deserialize(deserializer)?;
1773    value
1774        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1775        .transpose()
1776}
1777
1778fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1779where
1780    D: Deserializer<'de>,
1781{
1782    let value = Option::<u64>::deserialize(deserializer)?;
1783    match value {
1784        Some(0) => Err(de::Error::custom("must be a positive integer")),
1785        other => Ok(other),
1786    }
1787}
1788
1789fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1790where
1791    D: Deserializer<'de>,
1792{
1793    let value = deserialize_opt_positive_u64(deserializer)?;
1794    value
1795        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1796        .transpose()
1797}
1798
1799fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1800where
1801    D: Deserializer<'de>,
1802{
1803    let value = Option::<u64>::deserialize(deserializer)?;
1804    match value {
1805        Some(value) if !(1..=600).contains(&value) => {
1806            Err(de::Error::custom("timeout must be in 1..=600 seconds"))
1807        }
1808        Some(value) => u32::try_from(value)
1809            .map(Some)
1810            .map_err(|_| de::Error::custom("timeout is too large")),
1811        None => Ok(None),
1812    }
1813}
1814
1815fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
1816    deserializer: D,
1817) -> Result<Option<u64>, D::Error>
1818where
1819    D: Deserializer<'de>,
1820{
1821    let value = Option::<u64>::deserialize(deserializer)?;
1822    match value {
1823        Some(value) if value < 1_000 => Err(de::Error::custom(
1824            "bridge.request_timeout_ms must be at least 1000",
1825        )),
1826        other => Ok(other),
1827    }
1828}
1829
1830fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1831where
1832    D: Deserializer<'de>,
1833{
1834    let value = Option::<f64>::deserialize(deserializer)?;
1835    match value {
1836        Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
1837        other => Ok(other),
1838    }
1839}
1840
1841fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1842where
1843    D: Deserializer<'de>,
1844{
1845    let value = Option::<u64>::deserialize(deserializer)?;
1846    match value {
1847        Some(value) if value == 0 || value > 100 => {
1848            Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
1849        }
1850        Some(value) => usize::try_from(value)
1851            .map(Some)
1852            .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
1853        None => Ok(None),
1854    }
1855}
1856
1857#[cfg(test)]
1858mod tests {
1859    use super::*;
1860
1861    fn tier(tier: &str, doc: &str) -> ConfigTier {
1862        ConfigTier {
1863            tier: tier.to_string(),
1864            source: format!("/tmp/{tier}/aft.jsonc"),
1865            doc: doc.to_string(),
1866        }
1867    }
1868
1869    fn drop_keys(result: &ResolveResult) -> Vec<String> {
1870        result
1871            .dropped
1872            .iter()
1873            .map(|dropped| dropped.key.clone())
1874            .collect()
1875    }
1876
1877    /// Security invariant (Oracle drift decision): nested objects are non-strict
1878    /// (match TS z.object — unknown nested keys are stripped, object survives),
1879    /// but the TOP-LEVEL RawAftConfig stays strict. A privileged process-state
1880    /// field is top-level, so a project tier trying to smuggle one still hits the
1881    /// strict top-level → that tier fails full parse, and partial-parse drops the
1882    /// unknown key. It can NEVER reach Config.
1883    #[test]
1884    fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
1885        // Nested unknown key: stripped, object survives — parity with TS (golden
1886        // `bash_unknown_nested_key`). `bash: { unknown_key }` resolves like
1887        // `bash: {}` → object form → bash ENABLED (object presence beats the
1888        // minimal surface default). The point: the unknown key did not fail the
1889        // parse — the object survived and resolved.
1890        let nested = resolve_config(&[tier(
1891            "user",
1892            r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
1893        )]);
1894        assert!(nested.config.experimental_bash_rewrite);
1895        assert!(nested.config.experimental_bash_compress);
1896        assert!(nested.config.experimental_bash_background);
1897
1898        // Top-level privileged process-state field (storage_dir) from a PROJECT
1899        // tier: not in RawAftConfig → full parse fails → partial-parse drops it.
1900        // It must never appear in Config (Config keeps its default storage_dir).
1901        let smuggle = resolve_config(&[
1902            tier("user", r#"{ "search_index": true }"#),
1903            tier(
1904                "project",
1905                r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
1906            ),
1907        ]);
1908        // The valid project key (search_index) still applies via partial-parse...
1909        assert!(!smuggle.config.search_index);
1910        // ...but the smuggled process-state fields never reach Config.
1911        assert!(smuggle.config.storage_dir.is_none());
1912        assert!(!smuggle.config.bash_permissions);
1913    }
1914
1915    #[test]
1916    fn config_resolve_empty_tiers_applies_bash_surface_default() {
1917        // No config file ⇒ empty config object, which still flows through the
1918        // resolver and picks up the bash surface default (recommended ⇒ on),
1919        // matching the TS pipeline (golden fixture `empty`). NOT Config::default()
1920        // — that would leave bash off, diverging from TS.
1921        let result = resolve_config(&[]);
1922        let default_config = Config::default();
1923
1924        assert!(result.dropped.is_empty());
1925        // Non-bash fields stay at runtime default.
1926        assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
1927        assert_eq!(result.config.search_index, default_config.search_index);
1928        assert_eq!(
1929            result.config.semantic_search,
1930            default_config.semantic_search
1931        );
1932        assert_eq!(result.config.semantic, default_config.semantic);
1933        assert_eq!(
1934            result.config.inspect.enabled,
1935            default_config.inspect.enabled
1936        );
1937        assert_eq!(result.config.lsp_servers.len(), 0);
1938        // Bash surface default: recommended ⇒ rewrite/compress/background all on.
1939        assert!(result.config.experimental_bash_rewrite);
1940        assert!(result.config.experimental_bash_compress);
1941        assert!(result.config.experimental_bash_background);
1942    }
1943
1944    #[test]
1945    fn config_resolve_user_only_config_applies_fields() {
1946        let result = resolve_config(&[tier(
1947            "user",
1948            r#"{
1949              "$schema": "https://example.test/aft.schema.json",
1950              "format_on_edit": false,
1951              "formatter_timeout_secs": 42,
1952              "type_checker_timeout_secs": 43,
1953              "validate_on_edit": "full",
1954              "formatter": { "rust": "rustfmt", "typescript": "prettier" },
1955              "checker": { "rust": "cargo", "typescript": "tsc" },
1956              "restrict_to_project_root": true,
1957              "search_index": true,
1958              "semantic_search": true,
1959              "callgraph_store": false,
1960              "callgraph_chunk_size": 17,
1961              "url_fetch_allow_private": true,
1962              "semantic": {
1963                "backend": "openai_compatible",
1964                "model": "  user-model  ",
1965                "base_url": "https://semantic.example.test",
1966                "api_key_env": "AFT_API_KEY",
1967                "timeout_ms": 12345,
1968                "query_timeout_ms": 2345,
1969                "max_batch_size": 12,
1970                "max_files": 3456
1971              },
1972              "inspect": { "enabled": false, "diagnostics_timeout_ms": 15000 },
1973              "experimental": { "lsp_ty": true },
1974              "lsp": {
1975                "servers": {
1976                  "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1977                },
1978                "disabled": ["Python"],
1979                "python": "pyright"
1980              },
1981              "bash": { "rewrite": false, "compress": true, "background": false,
1982                        "long_running_reminder_enabled": false,
1983                        "long_running_reminder_interval_ms": 123000 }
1984            }"#,
1985        )]);
1986
1987        assert!(result.dropped.is_empty());
1988        assert!(!result.config.format_on_edit);
1989        assert_eq!(result.config.formatter_timeout_secs, 42);
1990        assert_eq!(result.config.type_checker_timeout_secs, 43);
1991        assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
1992        assert_eq!(
1993            result.config.formatter.get("rust").map(String::as_str),
1994            Some("rustfmt")
1995        );
1996        assert_eq!(
1997            result.config.checker.get("typescript").map(String::as_str),
1998            Some("tsc")
1999        );
2000        assert!(result.config.restrict_to_project_root);
2001        assert!(result.config.search_index);
2002        assert!(result.config.semantic_search);
2003        assert!(!result.config.callgraph_store);
2004        assert_eq!(result.config.callgraph_chunk_size, 17);
2005        assert!(result.config.url_fetch_allow_private);
2006        assert_eq!(
2007            result.config.semantic.backend,
2008            SemanticBackend::OpenAiCompatible
2009        );
2010        assert_eq!(result.config.semantic.model, "user-model");
2011        assert_eq!(
2012            result.config.semantic.base_url.as_deref(),
2013            Some("https://semantic.example.test")
2014        );
2015        assert_eq!(
2016            result.config.semantic.api_key_env.as_deref(),
2017            Some("AFT_API_KEY")
2018        );
2019        assert_eq!(result.config.semantic.timeout_ms, 12345);
2020        assert_eq!(result.config.semantic.query_timeout_ms, 2345);
2021        assert_eq!(result.config.semantic.max_batch_size, 12);
2022        assert_eq!(result.config.semantic.max_files, 3456);
2023        assert!(!result.config.inspect.enabled);
2024        assert_eq!(result.config.inspect.diagnostics_timeout_ms, 15_000);
2025        assert!(!result.config.experimental_lsp_ty);
2026        assert!(result.config.disabled_lsp.contains("ty"));
2027        assert_eq!(result.config.lsp_servers.len(), 1);
2028        assert_eq!(result.config.lsp_servers[0].id, "rust");
2029        assert_eq!(
2030            result.config.lsp_servers[0].extensions,
2031            vec!["rs".to_string()]
2032        );
2033        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
2034        assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
2035        assert_eq!(
2036            result.config.lsp_servers[0].root_markers,
2037            vec![".git".to_string()]
2038        );
2039        assert!(!result.config.experimental_bash_rewrite);
2040        assert!(result.config.experimental_bash_compress);
2041        assert!(!result.config.experimental_bash_background);
2042        assert!(!result.config.bash_long_running_reminder_enabled);
2043        assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
2044    }
2045
2046    #[test]
2047    fn edit_mode_resolves_at_user_and_project_tiers_with_project_precedence() {
2048        let hashline = resolve_config(&[
2049            tier("user", r#"{"edit_mode":"default"}"#),
2050            tier("project", r#"{"edit_mode":"hashline"}"#),
2051        ]);
2052        assert!(hashline.config.hashline_enabled);
2053        assert!(hashline.dropped.is_empty());
2054
2055        let default = resolve_config(&[
2056            tier("user", r#"{"edit_mode":"hashline"}"#),
2057            tier("project", r#"{"edit_mode":"default"}"#),
2058        ]);
2059        assert!(!default.config.hashline_enabled);
2060        assert!(default.dropped.is_empty());
2061    }
2062
2063    #[test]
2064    fn git_co_author_accepts_project_precedence_and_rejects_invalid_identities() {
2065        let resolved = resolve_config(&[
2066            tier("user", r#"{"git":{"co_author":"auto"}}"#),
2067            tier(
2068                "project",
2069                r#"{"git":{"co_author":"Pair Agent <pair@example.test>"}}"#,
2070            ),
2071        ]);
2072        assert_eq!(
2073            resolved.config.git.co_author,
2074            "Pair Agent <pair@example.test>"
2075        );
2076        assert!(resolved.dropped.is_empty());
2077
2078        let invalid = resolve_config(&[tier(
2079            "user",
2080            r#"{"git":{"co_author":"not-an-identity"},"search_index":true}"#,
2081        )]);
2082        assert_eq!(invalid.config.git.co_author, "off");
2083        assert!(invalid.config.search_index);
2084    }
2085
2086    #[test]
2087    fn unknown_edit_mode_warns_and_falls_back_without_dropping_valid_keys() {
2088        let result = resolve_config(&[tier(
2089            "project",
2090            r#"{"edit_mode":"future","format_on_edit":true}"#,
2091        )]);
2092
2093        assert!(!result.config.hashline_enabled);
2094        assert!(result.config.format_on_edit);
2095        assert_eq!(result.warnings.len(), 1);
2096        assert_eq!(result.warnings[0].code, "invalid_edit_mode");
2097        assert_eq!(result.warnings[0].key, "edit_mode");
2098        assert_eq!(result.warnings[0].tier, "project");
2099        assert_eq!(result.warnings[0].value, "future");
2100    }
2101
2102    #[test]
2103    fn semantic_query_timeout_clamps_to_interactive_budget_range() {
2104        let below_min =
2105            resolve_config(&[tier("user", r#"{ "semantic": { "query_timeout_ms": 1 } }"#)]);
2106        assert_eq!(
2107            below_min.config.semantic.query_timeout_ms,
2108            MIN_SEMANTIC_QUERY_TIMEOUT_MS
2109        );
2110
2111        let above_max = resolve_config(&[tier(
2112            "user",
2113            r#"{ "semantic": { "query_timeout_ms": 50000 } }"#,
2114        )]);
2115        assert_eq!(
2116            above_max.config.semantic.query_timeout_ms,
2117            MAX_SEMANTIC_QUERY_TIMEOUT_MS
2118        );
2119    }
2120
2121    #[test]
2122    fn inspect_diagnostics_timeout_clamps_to_blocking_phase_range() {
2123        let below_min = resolve_config(&[tier(
2124            "user",
2125            r#"{ "inspect": { "diagnostics_timeout_ms": 1 } }"#,
2126        )]);
2127        assert_eq!(
2128            below_min.config.inspect.diagnostics_timeout_ms,
2129            MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS
2130        );
2131
2132        let above_max = resolve_config(&[tier(
2133            "user",
2134            r#"{ "inspect": { "diagnostics_timeout_ms": 700000 } }"#,
2135        )]);
2136        assert_eq!(
2137            above_max.config.inspect.diagnostics_timeout_ms,
2138            MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS
2139        );
2140    }
2141
2142    #[test]
2143    fn project_inspect_diagnostics_timeout_can_raise_but_never_lower_user_value() {
2144        let lower = resolve_config(&[
2145            tier(
2146                "user",
2147                r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
2148            ),
2149            tier(
2150                "project",
2151                r#"{ "inspect": { "diagnostics_timeout_ms": 90000 } }"#,
2152            ),
2153        ]);
2154        assert_eq!(lower.config.inspect.diagnostics_timeout_ms, 180_000);
2155
2156        let higher = resolve_config(&[
2157            tier(
2158                "user",
2159                r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
2160            ),
2161            tier(
2162                "project",
2163                r#"{ "inspect": { "diagnostics_timeout_ms": 240000 } }"#,
2164            ),
2165        ]);
2166        assert_eq!(higher.config.inspect.diagnostics_timeout_ms, 240_000);
2167    }
2168
2169    #[test]
2170    fn config_resolve_project_allowed_search_index_wins() {
2171        let result = resolve_config(&[
2172            tier("user", r#"{ "search_index": false }"#),
2173            tier("project", r#"{ "search_index": true }"#),
2174        ]);
2175
2176        assert!(result.config.search_index);
2177        assert!(result.dropped.is_empty());
2178    }
2179
2180    #[test]
2181    fn worktree_ram_overlay_resolves_at_user_and_project_tiers() {
2182        assert!(!resolve_config(&[]).config.worktree.ram_overlay);
2183
2184        let user = resolve_config(&[tier("user", r#"{ "worktree": { "ram_overlay": true } }"#)]);
2185        assert!(user.config.worktree.ram_overlay);
2186        assert!(user.dropped.is_empty());
2187
2188        let project = resolve_config(&[
2189            tier("user", r#"{ "worktree": { "ram_overlay": false } }"#),
2190            tier("project", r#"{ "worktree": { "ram_overlay": true } }"#),
2191        ]);
2192        assert!(project.config.worktree.ram_overlay);
2193        assert!(project.dropped.is_empty());
2194    }
2195
2196    #[test]
2197    fn project_sandbox_can_add_read_denies_but_not_enable_or_add_writes() {
2198        let result = resolve_config(&[
2199            tier(
2200                "user",
2201                r#"{
2202                  "sandbox": {
2203                    "enabled": true,
2204                    "write_allow": ["/user/write"],
2205                    "read_deny": ["/user/secret"]
2206                  }
2207                }"#,
2208            ),
2209            tier(
2210                "project",
2211                r#"{
2212                  "sandbox": {
2213                    "enabled": false,
2214                    "write_allow": ["/project/write"],
2215                    "read_deny": ["/project/secret", "/user/secret"]
2216                  }
2217                }"#,
2218            ),
2219        ]);
2220
2221        assert!(result.config.sandbox.enabled);
2222        assert_eq!(
2223            result.config.sandbox.write_allow,
2224            vec![PathBuf::from("/user/write")]
2225        );
2226        assert_eq!(
2227            result.config.sandbox.read_deny,
2228            vec![
2229                PathBuf::from("/user/secret"),
2230                PathBuf::from("/project/secret")
2231            ]
2232        );
2233        assert_eq!(
2234            drop_keys(&result),
2235            vec![
2236                "sandbox.enabled".to_string(),
2237                "sandbox.write_allow".to_string()
2238            ]
2239        );
2240    }
2241
2242    #[test]
2243    fn project_can_enable_sandbox_but_never_disable_it() {
2244        // Hardening is one-way: a repo may opt itself into kernel confinement.
2245        let opt_in = resolve_config(&[
2246            tier("user", r#"{}"#),
2247            tier("project", r#"{ "sandbox": { "enabled": true } }"#),
2248        ]);
2249        assert!(opt_in.config.sandbox.enabled);
2250        assert!(
2251            !drop_keys(&opt_in).contains(&"sandbox.enabled".to_string()),
2252            "project enabled:true is an accepted opt-in, not a dropped key"
2253        );
2254
2255        // The weakening direction stays user-only: project false cannot win.
2256        let opt_out = resolve_config(&[
2257            tier("user", r#"{ "sandbox": { "enabled": true } }"#),
2258            tier("project", r#"{ "sandbox": { "enabled": false } }"#),
2259        ]);
2260        assert!(opt_out.config.sandbox.enabled);
2261        assert!(drop_keys(&opt_out).contains(&"sandbox.enabled".to_string()));
2262    }
2263
2264    #[test]
2265    fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
2266        let result = resolve_config(&[
2267            tier(
2268                "user",
2269                r#"{
2270                  "restrict_to_project_root": true,
2271                  "url_fetch_allow_private": true,
2272                  "formatter_timeout_secs": 11,
2273                  "type_checker_timeout_secs": 33,
2274                  "auto_update": true,
2275                  "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
2276                  "semantic": {
2277                    "backend": "openai_compatible",
2278                    "base_url": "https://user.example.test",
2279                    "api_key_env": "USER_KEY",
2280                    "model": "user-model",
2281                    "query_timeout_ms": 900
2282                  },
2283                  "lsp": {
2284                    "servers": {
2285                      "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
2286                    },
2287                    "disabled": ["user-disabled"],
2288                    "versions": { "typescript-language-server": "1.0.0" },
2289                    "auto_install": true,
2290                    "grace_days": 7
2291                  }
2292                }"#,
2293            ),
2294            tier(
2295                "project",
2296                r#"{
2297                  "restrict_to_project_root": false,
2298                  "url_fetch_allow_private": false,
2299                  "formatter_timeout_secs": 22,
2300                  "type_checker_timeout_secs": 44,
2301                  "auto_update": false,
2302                  "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
2303                  "semantic": {
2304                    "backend": "ollama",
2305                    "base_url": "https://project.example.test",
2306                    "api_key_env": "PROJECT_KEY",
2307                    "model": "project-model",
2308                    "timeout_ms": 2222,
2309                    "query_timeout_ms": 2222
2310                  },
2311                  "lsp": {
2312                    "servers": {
2313                      "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
2314                    },
2315                    "disabled": ["project-disabled"],
2316                    "versions": { "evil-lsp": "9.9.9" },
2317                    "auto_install": false,
2318                    "grace_days": 1,
2319                    "python": "ty"
2320                  }
2321                }"#,
2322            ),
2323        ]);
2324
2325        assert!(result.config.restrict_to_project_root);
2326        assert!(result.config.url_fetch_allow_private);
2327        assert_eq!(result.config.formatter_timeout_secs, 11);
2328        assert_eq!(result.config.type_checker_timeout_secs, 33);
2329        assert_eq!(
2330            result.config.semantic.backend,
2331            SemanticBackend::OpenAiCompatible
2332        );
2333        assert_eq!(
2334            result.config.semantic.base_url.as_deref(),
2335            Some("https://user.example.test")
2336        );
2337        assert_eq!(
2338            result.config.semantic.api_key_env.as_deref(),
2339            Some("USER_KEY")
2340        );
2341        assert_eq!(result.config.semantic.model, "project-model");
2342        assert_eq!(result.config.semantic.timeout_ms, 2222);
2343        assert_eq!(result.config.semantic.query_timeout_ms, 900);
2344        assert_eq!(result.config.lsp_servers.len(), 1);
2345        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
2346        assert!(result.config.disabled_lsp.contains("user-disabled"));
2347        assert!(!result.config.disabled_lsp.contains("project-disabled"));
2348        assert!(result.config.disabled_lsp.contains("python"));
2349        assert!(result.config.experimental_lsp_ty);
2350
2351        let keys = drop_keys(&result);
2352        let expected = [
2353            "restrict_to_project_root",
2354            "url_fetch_allow_private",
2355            "formatter_timeout_secs",
2356            "type_checker_timeout_secs",
2357            "auto_update",
2358            "bridge",
2359            "semantic.backend",
2360            "semantic.base_url",
2361            "semantic.api_key_env",
2362            "semantic.query_timeout_ms",
2363            "lsp.servers",
2364            "lsp.versions",
2365            "lsp.auto_install",
2366            "lsp.grace_days",
2367            "lsp.disabled",
2368        ];
2369        for key in expected {
2370            assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
2371        }
2372        assert_eq!(keys.len(), expected.len());
2373        assert!(result
2374            .dropped
2375            .iter()
2376            .all(|dropped| dropped.tier == "project"));
2377    }
2378
2379    #[test]
2380    fn config_resolve_bash_ladder_and_merge_parity() {
2381        let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2382        assert!(true_result.config.experimental_bash_rewrite);
2383        assert!(true_result.config.experimental_bash_compress);
2384        assert!(true_result.config.experimental_bash_background);
2385
2386        let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
2387        assert!(!false_result.config.experimental_bash_rewrite);
2388        assert!(!false_result.config.experimental_bash_compress);
2389        assert!(!false_result.config.experimental_bash_background);
2390
2391        let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
2392        assert!(object_default_result.config.experimental_bash_rewrite);
2393        assert!(object_default_result.config.experimental_bash_compress);
2394        assert!(object_default_result.config.experimental_bash_background);
2395
2396        let object_partial_result =
2397            resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
2398        assert!(object_partial_result.config.experimental_bash_rewrite);
2399        assert!(!object_partial_result.config.experimental_bash_compress);
2400        assert!(object_partial_result.config.experimental_bash_background);
2401
2402        let legacy_result = resolve_config(&[tier(
2403            "user",
2404            r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
2405        )]);
2406        assert!(legacy_result.config.experimental_bash_rewrite);
2407        assert!(!legacy_result.config.experimental_bash_compress);
2408        assert!(!legacy_result.config.experimental_bash_background);
2409
2410        let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
2411        assert!(surface_default_result.config.experimental_bash_rewrite);
2412        assert!(surface_default_result.config.experimental_bash_compress);
2413        assert!(surface_default_result.config.experimental_bash_background);
2414
2415        let minimal_surface_result =
2416            resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
2417        assert!(!minimal_surface_result.config.experimental_bash_rewrite);
2418        assert!(!minimal_surface_result.config.experimental_bash_compress);
2419        assert!(!minimal_surface_result.config.experimental_bash_background);
2420
2421        let merged_result = resolve_config(&[
2422            tier("user", r#"{ "bash": true }"#),
2423            tier("project", r#"{ "bash": { "compress": false } }"#),
2424        ]);
2425        assert!(merged_result.config.experimental_bash_rewrite);
2426        assert!(!merged_result.config.experimental_bash_compress);
2427        assert!(merged_result.config.experimental_bash_background);
2428
2429        let false_then_object_result = resolve_config(&[
2430            tier("user", r#"{ "bash": false }"#),
2431            tier("project", r#"{ "bash": { "compress": true } }"#),
2432        ]);
2433        assert!(!false_then_object_result.config.experimental_bash_rewrite);
2434        assert!(false_then_object_result.config.experimental_bash_compress);
2435        assert!(!false_then_object_result.config.experimental_bash_background);
2436    }
2437
2438    #[test]
2439    fn config_resolve_bash_foreground_wait_clamps_to_floor() {
2440        let Some(raw) = parse_tier(&tier(
2441            "user",
2442            r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
2443        )) else {
2444            panic!("test tier should parse");
2445        };
2446        let bash = resolve_bash_config(&raw);
2447
2448        assert_eq!(
2449            bash.foreground_wait_window_ms,
2450            FOREGROUND_WAIT_WINDOW_MIN_MS
2451        );
2452        assert!(bash.subagent_background);
2453
2454        let result = resolve_config(&[tier(
2455            "user",
2456            r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
2457        )]);
2458        assert_eq!(
2459            result.config.foreground_wait_window_ms,
2460            FOREGROUND_WAIT_WINDOW_MIN_MS
2461        );
2462
2463        // Unset → the default wait-window (matches the plugin's
2464        // FOREGROUND_WAIT_WINDOW_DEFAULT_MS = 15_000). This locks the default,
2465        // not just the floor, so a future edit can't silently shorten the
2466        // server-side promotion window once the plugin orchestrates through Rust.
2467        let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2468        assert_eq!(
2469            defaulted.config.foreground_wait_window_ms,
2470            FOREGROUND_WAIT_WINDOW_DEFAULT_MS
2471        );
2472        assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
2473    }
2474
2475    #[test]
2476    fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
2477        let result = resolve_config(&[tier(
2478            "user",
2479            r#"{
2480              "semantic": { "timeout_ms": 0 },
2481              "search_index": true,
2482              "format_on_edit": false
2483            }"#,
2484        )]);
2485
2486        assert!(result.config.search_index);
2487        assert!(!result.config.format_on_edit);
2488        assert_eq!(result.config.semantic, SemanticBackendConfig::default());
2489        assert!(result.dropped.is_empty());
2490    }
2491
2492    #[test]
2493    fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
2494        let result = resolve_config(&[tier(
2495            "user",
2496            r#"{ "not_a_real_key": true, "search_index": true }"#,
2497        )]);
2498
2499        assert!(result.config.search_index);
2500        assert!(result.dropped.is_empty());
2501    }
2502
2503    #[test]
2504    fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
2505        // Cross-bind escalation regression: a first (trusted) bind sets a
2506        // capability field; a second bind that omits it must NOT inherit it.
2507        // This is the configure-path entry (seeded from a prior config), where
2508        // reset-onto-default — unlike the old overlay — makes the resolved core
2509        // config a pure function of the CURRENT bind's tiers.
2510        let mut config = Config::default();
2511
2512        // Bind 1 (trusted user tier) sets url_fetch_allow_private + a custom LSP
2513        // server + restrict_to_project_root.
2514        let dropped1 = resolve_config_onto(
2515            &[tier(
2516                "user",
2517                r#"{
2518                  "url_fetch_allow_private": true,
2519                  "restrict_to_project_root": true,
2520                  "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
2521                }"#,
2522            )],
2523            &mut config,
2524        );
2525        assert!(dropped1.is_empty());
2526        assert!(config.url_fetch_allow_private);
2527        assert!(config.restrict_to_project_root);
2528        assert_eq!(config.lsp_servers.len(), 1);
2529
2530        // Bind 2 omits all three. With reset semantics they return to DEFAULT —
2531        // the second bind cannot inherit the first bind's capabilities.
2532        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2533        assert!(
2534            !config.url_fetch_allow_private,
2535            "url_fetch_allow_private must reset to default, not inherit prior bind"
2536        );
2537        assert!(
2538            !config.restrict_to_project_root,
2539            "restrict_to_project_root must reset to default"
2540        );
2541        assert!(
2542            config.lsp_servers.is_empty(),
2543            "lsp_servers must reset to default, not inherit prior bind's custom server"
2544        );
2545        assert!(config.search_index, "this bind's own field still applies");
2546    }
2547
2548    #[test]
2549    fn resolve_config_onto_empty_tiers_resets_to_default() {
2550        // The empty-tier path must still reset (it routes through the same
2551        // always-run resolution in handle_configure). A bind with no tiers after
2552        // a privileged bind must drop the privileged config.
2553        let mut config = Config::default();
2554        let _ = resolve_config_onto(
2555            &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
2556            &mut config,
2557        );
2558        assert!(config.url_fetch_allow_private);
2559
2560        let _ = resolve_config_onto(&[], &mut config);
2561        assert!(
2562            !config.url_fetch_allow_private,
2563            "empty-tier bind must reset core config to default"
2564        );
2565    }
2566
2567    #[test]
2568    fn resolve_config_onto_preserves_process_state_fields() {
2569        // Process-state fields (not part of RawAftConfig) are carried across the
2570        // reset so plugin-mode behavior is unchanged (the plugin re-sends them
2571        // via flat configure params right after this call).
2572        let mut config = Config {
2573            storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
2574            lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
2575            bash_permissions: true,
2576            project_root: Some(std::path::PathBuf::from("/tmp/proj")),
2577            ..Default::default()
2578        };
2579
2580        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2581
2582        assert_eq!(
2583            config.storage_dir,
2584            Some(std::path::PathBuf::from("/tmp/aft-store"))
2585        );
2586        assert_eq!(
2587            config.lsp_paths_extra,
2588            vec![std::path::PathBuf::from("/tmp/lsp-bin")]
2589        );
2590        assert!(config.bash_permissions);
2591        assert_eq!(
2592            config.project_root,
2593            Some(std::path::PathBuf::from("/tmp/proj"))
2594        );
2595        assert!(config.search_index);
2596    }
2597
2598    #[test]
2599    fn index_roots_are_user_only_normalized_and_validate_before_resolution() {
2600        let result = resolve_config(&[tier(
2601            "user",
2602            r#"{
2603              "index": {
2604                "roots": [{
2605                  "path": "~/.aft-standing-root",
2606                  "indexes": ["semantic", "callgraph"],
2607                  "future_field": true
2608                }]
2609              }
2610            }"#,
2611        )]);
2612        assert_eq!(result.config.index.roots.len(), 1);
2613        assert_eq!(result.config.index.roots[0].path, "~/.aft-standing-root");
2614        assert_eq!(
2615            result.config.index.roots[0].indexes,
2616            vec![IndexKind::Search, IndexKind::Semantic, IndexKind::Callgraph]
2617        );
2618        assert!(result
2619            .warnings
2620            .iter()
2621            .any(|warning| warning.code == "index_dependency_closure"));
2622        assert!(result
2623            .warnings
2624            .iter()
2625            .any(|warning| warning.code == "unknown_index_root_field"));
2626
2627        for invalid in [
2628            r#"{ "index": { "roots": [{ "path": "relative", "indexes": ["search"] }] } }"#,
2629            r#"{ "index": { "roots": [{ "path": "~/a", "indexes": [] }] } }"#,
2630            r#"{ "index": { "roots": [{ "path": "~/a", "indexes": ["search", "search"] }] } }"#,
2631            r#"{ "index": { "roots": [{ "path": "~/a", "indexes": ["unknown"] }] } }"#,
2632        ] {
2633            let invalid = resolve_config(&[tier("user", invalid)]);
2634            assert!(invalid.config.index.roots.is_empty());
2635            let warning = invalid
2636                .warnings
2637                .iter()
2638                .find(|warning| warning.code == "invalid_index_roots")
2639                .expect("invalid standing roots must be named");
2640            assert!(warning.message.contains("index.roots"));
2641        }
2642    }
2643
2644    #[test]
2645    fn index_roots_project_and_mcp_tiers_are_rejected_at_the_trust_boundary() {
2646        let result = resolve_config(&[
2647            tier(
2648                "user",
2649                r#"{ "index": { "roots": [{ "path": "~/user", "indexes": ["search"] }] } }"#,
2650            ),
2651            tier(
2652                "project",
2653                r#"{ "index": { "roots": [{ "path": "~/project", "indexes": ["semantic"] }] } }"#,
2654            ),
2655            tier(
2656                "mcp:untrusted",
2657                r#"{ "index": { "roots": [{ "path": "~/mcp", "indexes": ["callgraph"] }] } }"#,
2658            ),
2659        ]);
2660        assert_eq!(result.config.index.roots[0].path, "~/user");
2661        assert_eq!(
2662            result
2663                .dropped
2664                .iter()
2665                .filter(|dropped| dropped.key == "index.roots")
2666                .map(|dropped| dropped.tier.as_str())
2667                .collect::<Vec<_>>(),
2668            vec!["project", "mcp:untrusted"]
2669        );
2670    }
2671
2672    #[test]
2673    fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
2674        let result = resolve_config(&[tier(
2675            "user",
2676            r#"{
2677              // line comment
2678              "search_index": true,
2679              "formatter": {
2680                "rust": "rustfmt", /* block comment */
2681              },
2682            }"#,
2683        )]);
2684
2685        assert!(result.config.search_index);
2686        assert_eq!(
2687            result.config.formatter.get("rust").map(String::as_str),
2688            Some("rustfmt")
2689        );
2690    }
2691}