Skip to main content

defect_config/
loader.rs

1use std::env;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use defect_agent::error::BoxError;
7use defect_agent::session::{BasePromptConfig, PromptConfig, TurnConfig, TurnRequestLimit};
8use toml::Value as TomlValue;
9
10use crate::hooks::{LayerHooks, merge_layer_hooks, parse_layer_hooks};
11use crate::mcp::resolve_mcp_config;
12use crate::overrides::{build_cli_layer, merge_toml_values};
13use crate::types::{
14    BasePromptConfigFile, BashToolConfig, CapabilitiesConfig, CliConfig, ConfigError,
15    ConfigLayerEntry, ConfigLayerStack, ConfigSource, ConfigToml, ConfigWarning,
16    DEFAULT_ANTHROPIC_MODEL, DEFAULT_BASH_MAX_TIMEOUT_MS, DEFAULT_BASH_OUTPUT_MAX_BYTES,
17    DEFAULT_BASH_TIMEOUT_MS, DEFAULT_DEEPSEEK_MODEL, DEFAULT_ECHO_MODEL, DEFAULT_FS_READ_LIMIT,
18    DEFAULT_FS_READ_MAX_LIMIT, DEFAULT_OPENAI_MODEL, EffectiveConfig, FetchToolConfig,
19    FsToolConfig, HooksConfig, HttpClientConfig, HttpProxyConfig, HttpProxySettings,
20    LangfuseConfig, LoadConfigOptions, LoadedConfig, OtlpTracingConfig, PROJECT_CONFIG_RELATIVE,
21    PROJECT_LOCAL_CONFIG_RELATIVE, PromptConfigFile, ProviderCapabilityOverrides,
22    ProviderConfigFile, ProviderConfigs, ProviderKind, ProviderSection, RequestLimitMode,
23    SandboxConfig, SandboxMode, SearchToolConfig, ToolsConfig, TracingConfig, USER_CONFIG_RELATIVE,
24};
25use defect_agent::session::{BackgroundProgressConfig, WebSearchCapabilityConfig};
26
27/// Loads and merges the effective configuration for `defect`.
28///
29/// Precedence is: `default < user < project < project-local < CLI`.
30///
31/// # Errors
32///
33/// Returns [`ConfigError`] when the user config path cannot be resolved, any config file
34/// fails to read from disk, TOML parsing fails, or the merged configuration cannot be
35/// deserialized into a strongly-typed structure.
36pub fn load_config(opts: LoadConfigOptions) -> Result<LoadedConfig, ConfigError> {
37    let cwd = canonicalize_or_original(&opts.cwd);
38    let user_path = resolve_user_config_path(&opts);
39    let repo_root = find_repo_root(&cwd);
40    let project_path = repo_root
41        .as_ref()
42        .map(|root| root.join(PROJECT_CONFIG_RELATIVE));
43    let project_local_path = repo_root
44        .as_ref()
45        .map(|root| root.join(PROJECT_LOCAL_CONFIG_RELATIVE));
46
47    let mut layers = Vec::new();
48    let mut warnings = Vec::new();
49
50    let defaults = TomlValue::Table(Default::default());
51    layers.push(ConfigLayerEntry {
52        source: ConfigSource::Defaults,
53        path: None,
54        raw_toml: None,
55        value: defaults.clone(),
56    });
57
58    let mut merged = defaults;
59    let mut base_prompt: Option<BasePromptConfigFile> = None;
60    // Hooks cannot use "merge then decode" — array merge semantics are append+dedupe; see
61    // the comment at the top of `crates/config/src/hooks.rs`. Extract each layer
62    // separately, then call `merge_layer_hooks` at the end.
63    let mut hook_layers: Vec<LayerHooks> = Vec::new();
64
65    if let Some((user_layer, layer_warnings)) =
66        load_optional_layer_opt(ConfigSource::User, user_path)?
67    {
68        warnings.extend(layer_warnings);
69        if let Some(candidate) = extract_base_prompt(&user_layer.value, user_layer.path.as_ref()) {
70            base_prompt = Some(candidate);
71        }
72        if let Some(path) = user_layer.path.clone() {
73            hook_layers.push(parse_layer_hooks(
74                path,
75                ConfigSource::User,
76                &user_layer.value,
77            )?);
78        }
79        merge_toml_values(&mut merged, &user_layer.value);
80        layers.push(user_layer);
81    }
82
83    if let Some((project_layer, layer_warnings)) =
84        load_optional_layer_opt(ConfigSource::Project, project_path)?
85    {
86        warnings.extend(layer_warnings);
87        if let Some(candidate) =
88            extract_base_prompt(&project_layer.value, project_layer.path.as_ref())
89        {
90            base_prompt = Some(candidate);
91        }
92        if let Some(path) = project_layer.path.clone() {
93            hook_layers.push(parse_layer_hooks(
94                path,
95                ConfigSource::Project,
96                &project_layer.value,
97            )?);
98        }
99        merge_toml_values(&mut merged, &project_layer.value);
100        layers.push(project_layer);
101    }
102
103    if let Some((project_local_layer, layer_warnings)) =
104        load_optional_layer_opt(ConfigSource::ProjectLocal, project_local_path)?
105    {
106        warnings.extend(layer_warnings);
107        if let Some(candidate) = extract_base_prompt(
108            &project_local_layer.value,
109            project_local_layer.path.as_ref(),
110        ) {
111            base_prompt = Some(candidate);
112        }
113        if let Some(path) = project_local_layer.path.clone() {
114            hook_layers.push(parse_layer_hooks(
115                path,
116                ConfigSource::ProjectLocal,
117                &project_local_layer.value,
118            )?);
119        }
120        merge_toml_values(&mut merged, &project_local_layer.value);
121        layers.push(project_local_layer);
122    }
123
124    if let Some(cli_layer) = build_cli_layer(&opts.cli)? {
125        if let Some(candidate) = extract_base_prompt(&cli_layer.value, cli_layer.path.as_ref()) {
126            base_prompt = Some(candidate);
127        }
128        // CLI overrides use dotted-key syntax, which cannot express `[[hooks.*]]` arrays
129        // — hooks cannot be constructed from the command line. `parse_layer_hooks` is not
130        // called here to avoid implying that the CLI layer could contain hooks.
131        merge_toml_values(&mut merged, &cli_layer.value);
132        layers.push(cli_layer);
133    }
134
135    let parsed: ConfigToml = merged
136        .clone()
137        .try_into()
138        .map_err(|err| ConfigError::Invalid {
139            path: PathBuf::from("<merged>"),
140            message: err.to_string(),
141        })?;
142    let hooks = merge_layer_hooks(hook_layers);
143    let mut effective = build_effective_config(
144        Path::new("<merged>"),
145        parsed,
146        base_prompt.unwrap_or_default(),
147        hooks,
148    )?;
149
150    // Repo-root `.mcp.json` (Claude Code/Cursor standard): "define = enable". Merged
151    // after the TOML-derived McpConfig so TOML `[mcp]` wins on name collisions. It is a
152    // project-level source, so it is honored whenever a repo root exists (including under
153    // `--local`, which only skips the user/global layer).
154    let mcp_json_warnings =
155        crate::mcp_json::merge_repo_mcp_json(repo_root.as_deref(), &mut effective.mcp).map_err(
156            |message| ConfigError::Invalid {
157                path: repo_root
158                    .as_ref()
159                    .map(|root| root.join(crate::mcp_json::MCP_JSON_RELATIVE))
160                    .unwrap_or_else(|| PathBuf::from(".mcp.json")),
161                message,
162            },
163        )?;
164    warnings.extend(mcp_json_warnings);
165
166    Ok(LoadedConfig {
167        layers: ConfigLayerStack { layers },
168        effective,
169        warnings,
170    })
171}
172
173/// Reads `cwd/.env` compatibly, only filling in missing environment variables.
174///
175/// # Errors
176///
177/// Returns [`ConfigError::Io`] if the `.env` file exists but cannot be read.
178pub fn load_dotenv_compat(cwd: &Path) -> Result<(), ConfigError> {
179    let path = cwd.join(".env");
180    let raw = match fs::read_to_string(&path) {
181        Ok(raw) => raw,
182        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
183        Err(err) => {
184            return Err(ConfigError::Io {
185                path,
186                source: BoxError::new(err),
187            });
188        }
189    };
190
191    let existing = raw_env_keys();
192    for (key, value) in dotenv_updates_from_str(&raw, &existing) {
193        // SAFETY: called before any concurrent tasks are spawned; this only sets env vars
194        // during process startup.
195        unsafe {
196            env::set_var(key, value);
197        }
198    }
199    Ok(())
200}
201
202fn build_effective_config(
203    path: &Path,
204    config: ConfigToml,
205    base_prompt: BasePromptConfigFile,
206    hooks: HooksConfig,
207) -> Result<EffectiveConfig, ConfigError> {
208    // The final selection of `base_prompt` is done in `load_config()`; here we only keep
209    // the typed decode constraint on the schema and explicitly consume the fields to
210    // avoid them falling out of sync with the raw-layer parsing.
211    let _ = config.base_prompt.file.as_deref();
212    let _ = config.base_prompt.text.as_deref();
213    let provider = config.default.provider.unwrap_or_default();
214    let provider_config = raw_provider_config(&config.providers, &provider);
215    if matches!(provider, ProviderKind::Custom(_)) && provider_config.is_none() {
216        return Err(ConfigError::Invalid {
217            path: path.to_path_buf(),
218            message: format!(
219                "default.provider `{provider}` has no matching [providers.{provider}] section"
220            ),
221        });
222    }
223    let provider_model = provider_default_model(&provider, provider_config);
224    // The allowed_models allowlist only needs the id; the display name is taken
225    // separately in the entry_models path.
226    let provider_allowed_models: Option<Vec<String>> = provider_config.and_then(|cfg| {
227        cfg.models
228            .as_ref()
229            .map(|models| models.iter().map(|m| m.id().to_string()).collect())
230    });
231    let model = match config.default.model.or(provider_model) {
232        Some(model) => model,
233        None => {
234            return Err(ConfigError::Invalid {
235                path: path.to_path_buf(),
236                message: format!(
237                    "default.model or providers.{provider}.default_model is required for provider `{provider}`"
238                ),
239            });
240        }
241    };
242    let allowed_models = merged_allowed_models(
243        provider_allowed_models,
244        configured_provider_models(&config.providers),
245        &model,
246    );
247
248    let prompt = PromptConfigFile {
249        file: config.prompt.file.unwrap_or_else(|| "AGENTS.md".to_owned()),
250        text: config.prompt.text,
251        provider_overlays: config
252            .prompt
253            .providers
254            .unwrap_or_default()
255            .into_iter()
256            .filter_map(|(provider, overlay)| overlay.text.map(|text| (provider, text)))
257            .collect(),
258        model_overlays: config.prompt.models.unwrap_or_default(),
259    };
260
261    let mut turn = TurnConfig {
262        // `ProviderKind::as_str()` returns the runtime vendor string (matching
263        // `ProviderInfo.vendor` set for each provider in `cli/providers.rs`),
264        // so it selects the correct provider half.
265        provider: provider.as_str().to_string(),
266        model: model.clone(),
267        allowed_models,
268        base_prompt: BasePromptConfig {
269            file: base_prompt.file.clone(),
270            text: base_prompt.text.clone(),
271        },
272        prompt: PromptConfig {
273            file: prompt.file.clone(),
274            text: prompt.text.clone(),
275            provider_overlays: prompt.provider_overlays.clone(),
276            model_overlays: prompt.model_overlays.clone(),
277        },
278        ..TurnConfig::default()
279    };
280    turn.system_prompt = config.turn.system_prompt;
281    if let Some(request_limit) = resolve_request_limit(
282        path,
283        config.turn.request_limit,
284        config.turn.request_limit_mode,
285    )? {
286        turn.request_limit = request_limit;
287    }
288    if let Some(compact_threshold_tokens) = config.turn.compact_threshold_tokens {
289        turn.compact_threshold_tokens = Some(compact_threshold_tokens);
290    }
291    if let Some(compact_ratio) = config.turn.compact_ratio {
292        turn.compact_ratio = Some(compact_ratio);
293    }
294    if let Some(background_compact_enabled) = config.turn.background_compact_enabled {
295        turn.background_compact_enabled = background_compact_enabled;
296    }
297    if let Some(compact_soft_ratio) = config.turn.compact_soft_ratio {
298        turn.compact_soft_ratio = Some(compact_soft_ratio);
299    }
300    if let Some(microcompact_enabled) = config.turn.microcompact_enabled {
301        turn.microcompact_enabled = microcompact_enabled;
302    }
303    if let Some(microcompact_ratio) = config.turn.microcompact_ratio {
304        turn.microcompact_ratio = Some(microcompact_ratio);
305    }
306    if let Some(max_llm_retries) = config.turn.max_llm_retries {
307        turn.max_llm_retries = max_llm_retries;
308    }
309    if let Some(max_concurrent_tools) = config.turn.max_concurrent_tools {
310        turn.max_concurrent_tools = max_concurrent_tools;
311    }
312    if let Some(max_hook_continues) = config.turn.max_hook_continues {
313        turn.max_hook_continues = max_hook_continues;
314    }
315    if let Some(subagent_max_depth) = config.turn.subagent_max_depth {
316        turn.subagent_max_depth = subagent_max_depth;
317    }
318    validate_compact_ratios(path, &turn)?;
319    if let Some(sampling) = config.turn.sampling {
320        // Merge each present field onto the default; absent fields keep the provider
321        // fallback (notably `max_tokens = None` → protocol-layer default). `thinking` /
322        // `stop_sequences` / `reasoning_effort` are not configured here.
323        if let Some(max_tokens) = sampling.max_tokens {
324            turn.sampling.max_tokens = Some(max_tokens);
325        }
326        if let Some(temperature) = sampling.temperature {
327            turn.sampling.temperature = Some(temperature);
328        }
329        if let Some(top_p) = sampling.top_p {
330            turn.sampling.top_p = Some(top_p);
331        }
332        if let Some(top_k) = sampling.top_k {
333            turn.sampling.top_k = Some(top_k);
334        }
335    }
336
337    let capabilities = CapabilitiesConfig::with_web_search(WebSearchCapabilityConfig::new(
338        config
339            .capabilities
340            .web_search
341            .as_ref()
342            .and_then(|s| s.mode)
343            .unwrap_or_default(),
344    ));
345    let fetch_default = FetchToolConfig::default();
346    let fetch = config
347        .tools
348        .fetch
349        .map(|cfg| FetchToolConfig {
350            enabled: cfg.enabled.unwrap_or(fetch_default.enabled),
351            default_timeout_secs: cfg
352                .default_timeout_secs
353                .unwrap_or(fetch_default.default_timeout_secs),
354            max_timeout_secs: cfg
355                .max_timeout_secs
356                .unwrap_or(fetch_default.max_timeout_secs),
357            max_response_bytes: cfg
358                .max_response_bytes
359                .unwrap_or(fetch_default.max_response_bytes),
360            default_format: cfg.default_format.unwrap_or(fetch_default.default_format),
361            html_to_markdown: cfg
362                .html_to_markdown
363                .unwrap_or(fetch_default.html_to_markdown),
364            follow_redirects: cfg
365                .follow_redirects
366                .unwrap_or(fetch_default.follow_redirects),
367        })
368        .unwrap_or(fetch_default);
369
370    let search_default = SearchToolConfig::default();
371    let search = config
372        .tools
373        .search
374        .map(|cfg| SearchToolConfig {
375            enabled: cfg.enabled.unwrap_or(search_default.enabled),
376            default_head_limit: cfg
377                .default_head_limit
378                .unwrap_or(search_default.default_head_limit),
379            max_head_limit: cfg.max_head_limit.unwrap_or(search_default.max_head_limit),
380            max_file_size_bytes: cfg
381                .max_file_size_bytes
382                .unwrap_or(search_default.max_file_size_bytes),
383            max_result_bytes: cfg
384                .max_result_bytes
385                .unwrap_or(search_default.max_result_bytes),
386            max_walk_files: cfg.max_walk_files.unwrap_or(search_default.max_walk_files),
387            respect_gitignore_default: cfg
388                .respect_gitignore_default
389                .unwrap_or(search_default.respect_gitignore_default),
390        })
391        .unwrap_or(search_default);
392
393    let background_default = BackgroundProgressConfig::default();
394    let background = config
395        .tools
396        .background
397        .map(|cfg| BackgroundProgressConfig {
398            default_recent_blocks: cfg
399                .default_recent_blocks
400                .unwrap_or(background_default.default_recent_blocks),
401            block_text_limit: cfg
402                .block_text_limit
403                .unwrap_or(background_default.block_text_limit),
404            finished_tasks_cap: cfg
405                .finished_tasks_cap
406                .unwrap_or(background_default.finished_tasks_cap),
407        })
408        .unwrap_or(background_default);
409
410    Ok(EffectiveConfig {
411        cli: CliConfig { provider, model },
412        turn,
413        base_prompt,
414        prompt,
415        capabilities,
416        providers: ProviderConfigs {
417            anthropic: config
418                .providers
419                .anthropic
420                .map(provider_config_file)
421                .unwrap_or_default(),
422            openai: config
423                .providers
424                .openai
425                .map(provider_config_file)
426                .unwrap_or_default(),
427            deepseek: config
428                .providers
429                .deepseek
430                .map(provider_config_file)
431                .unwrap_or_default(),
432            litellm: config
433                .providers
434                .litellm
435                .map(provider_config_file)
436                .unwrap_or_default(),
437            custom: config
438                .providers
439                .custom
440                .into_iter()
441                .map(|(name, cfg)| (name, provider_config_file(cfg)))
442                .collect(),
443        },
444        tools: ToolsConfig {
445            bash: config
446                .tools
447                .bash
448                .map(|cfg| BashToolConfig {
449                    default_timeout_ms: cfg.default_timeout_ms.unwrap_or(DEFAULT_BASH_TIMEOUT_MS),
450                    max_timeout_ms: cfg.max_timeout_ms.unwrap_or(DEFAULT_BASH_MAX_TIMEOUT_MS),
451                    output_max_bytes: cfg
452                        .output_max_bytes
453                        .unwrap_or(DEFAULT_BASH_OUTPUT_MAX_BYTES),
454                })
455                .unwrap_or_default(),
456            fs: config
457                .tools
458                .fs
459                .map(|cfg| FsToolConfig {
460                    read_default_limit: cfg.read_default_limit.unwrap_or(DEFAULT_FS_READ_LIMIT),
461                    read_max_limit: cfg.read_max_limit.unwrap_or(DEFAULT_FS_READ_MAX_LIMIT),
462                })
463                .unwrap_or_default(),
464            fetch,
465            search,
466            background,
467        },
468        sandbox: SandboxConfig {
469            mode: config.sandbox.mode.unwrap_or(SandboxMode::AskWrites),
470        },
471        tracing: TracingConfig {
472            filter: config.tracing.filter,
473            format: config.tracing.format.unwrap_or_default(),
474            otlp: config.tracing.otlp.map(|otlp| OtlpTracingConfig {
475                endpoint: otlp.endpoint,
476            }),
477            langfuse: config.tracing.langfuse.map(|lf| LangfuseConfig {
478                enabled: lf.enabled.unwrap_or(false),
479                host: lf.host,
480                public_key: lf.public_key,
481                secret_key: lf.secret_key,
482                flush_interval_ms: lf.flush_interval_ms,
483                max_batch: lf.max_batch,
484            }),
485        },
486        mcp: resolve_mcp_config(path, config.mcp).map_err(|message| ConfigError::Invalid {
487            path: path.to_path_buf(),
488            message,
489        })?,
490        http: HttpClientConfig {
491            total_timeout_ms: config.http.total_timeout_ms,
492            transport_retries: config.http.transport_retries,
493            initial_backoff_ms: config.http.initial_backoff_ms,
494            user_agent: config.http.user_agent,
495            proxy: config
496                .http
497                .proxy
498                .map(|cfg| HttpProxyConfig {
499                    mode: cfg.mode.unwrap_or_default(),
500                    explicit: HttpProxySettings {
501                        http_proxy: cfg.http_proxy,
502                        https_proxy: cfg.https_proxy,
503                        no_proxy: cfg.no_proxy.unwrap_or_default(),
504                    },
505                })
506                .unwrap_or_default(),
507        },
508        hooks,
509    })
510}
511
512/// Validate the three-tier compaction watermark ratios: each ratio must be in `(0, 1]`,
513/// and `micro ≤ soft < hard` (only enforced between tiers that are actually set). Any
514/// inversion or out-of-bounds value is a hard [`ConfigError::Invalid`] fail — no silent
515/// correction, to avoid "running with awkward watermarks".
516fn validate_compact_ratios(path: &Path, turn: &TurnConfig) -> Result<(), ConfigError> {
517    let invalid = |message: String| ConfigError::Invalid {
518        path: path.to_path_buf(),
519        message,
520    };
521    // Validate the domain of each ratio.
522    for (name, ratio) in [
523        ("microcompact_ratio", turn.microcompact_ratio),
524        ("compact_soft_ratio", turn.compact_soft_ratio),
525        ("compact_ratio", turn.compact_ratio),
526    ] {
527        if let Some(r) = ratio
528            && !(r > 0.0 && r <= 1.0)
529        {
530            return Err(invalid(format!("[turn].{name} must be in (0, 1], got {r}")));
531        }
532    }
533    // Only enforce ordering constraints for enabled tiers: micro ≤ soft < hard.
534    let micro = turn
535        .microcompact_enabled
536        .then_some(turn.microcompact_ratio)
537        .flatten();
538    let soft = turn
539        .background_compact_enabled
540        .then_some(turn.compact_soft_ratio)
541        .flatten();
542    let hard = turn.compact_ratio;
543    if let (Some(soft), Some(hard)) = (soft, hard)
544        && soft >= hard
545    {
546        return Err(invalid(format!(
547            "[turn].compact_soft_ratio ({soft}) must be < compact_ratio ({hard}); \
548             the soft watermark must be strictly below the hard one to leave room for background compaction"
549        )));
550    }
551    if let (Some(micro), Some(soft)) = (micro, soft)
552        && micro > soft
553    {
554        return Err(invalid(format!(
555            "[turn].microcompact_ratio ({micro}) must be ≤ compact_soft_ratio ({soft})"
556        )));
557    }
558    if let (Some(micro), Some(hard)) = (micro, hard)
559        && micro >= hard
560    {
561        return Err(invalid(format!(
562            "[turn].microcompact_ratio ({micro}) must be < compact_ratio ({hard})"
563        )));
564    }
565    Ok(())
566}
567
568fn raw_provider_config<'a>(
569    providers: &'a crate::types::ProvidersSection,
570    provider: &ProviderKind,
571) -> Option<&'a ProviderSection> {
572    match provider {
573        ProviderKind::Defect => None,
574        ProviderKind::Anthropic => providers.anthropic.as_ref(),
575        ProviderKind::Openai => providers.openai.as_ref(),
576        ProviderKind::Deepseek => providers.deepseek.as_ref(),
577        ProviderKind::Litellm => providers.litellm.as_ref(),
578        ProviderKind::Custom(name) => providers.custom.get(name),
579    }
580}
581
582fn merged_allowed_models(
583    provider_allowed_models: Option<Vec<String>>,
584    configured_models: Vec<String>,
585    current_model: &str,
586) -> Option<Vec<String>> {
587    let mut models = provider_allowed_models.unwrap_or_default();
588    append_unique_models(&mut models, configured_models);
589    if models.is_empty() {
590        return None;
591    }
592    if !models.iter().any(|model| model == current_model) {
593        models.insert(0, current_model.to_string());
594    }
595    Some(models)
596}
597
598fn configured_provider_models(providers: &crate::types::ProvidersSection) -> Vec<String> {
599    let mut models = Vec::new();
600    for section in [
601        providers.anthropic.as_ref(),
602        providers.openai.as_ref(),
603        providers.deepseek.as_ref(),
604        providers.litellm.as_ref(),
605    ]
606    .into_iter()
607    .flatten()
608    {
609        append_unique_models(&mut models, provider_declared_models(section));
610    }
611    for section in providers.custom.values() {
612        append_unique_models(&mut models, provider_declared_models(section));
613    }
614    models
615}
616
617fn provider_declared_models(section: &ProviderSection) -> Vec<String> {
618    let mut models = Vec::new();
619    if let Some(default_model) = &section.default_model {
620        models.push(default_model.clone());
621    }
622    if let Some(section_models) = &section.models {
623        // The allowed_models allowlist only cares about the id — discard the display
624        // name.
625        append_unique_models(
626            &mut models,
627            section_models.iter().map(|m| m.id().to_string()).collect(),
628        );
629    }
630    models
631}
632
633fn append_unique_models(target: &mut Vec<String>, source: Vec<String>) {
634    for model in source {
635        if !target.iter().any(|existing| existing == &model) {
636            target.push(model);
637        }
638    }
639}
640
641fn provider_default_model(
642    provider: &ProviderKind,
643    config: Option<&ProviderSection>,
644) -> Option<String> {
645    if let Some(default_model) = config.and_then(|cfg| cfg.default_model.clone()) {
646        return Some(default_model);
647    }
648    match provider {
649        ProviderKind::Defect => Some(DEFAULT_ECHO_MODEL.to_string()),
650        ProviderKind::Anthropic => Some(DEFAULT_ANTHROPIC_MODEL.to_string()),
651        ProviderKind::Openai => Some(DEFAULT_OPENAI_MODEL.to_string()),
652        ProviderKind::Deepseek => Some(DEFAULT_DEEPSEEK_MODEL.to_string()),
653        ProviderKind::Litellm => None,
654        ProviderKind::Custom(_) => None,
655    }
656}
657
658fn provider_config_file(cfg: ProviderSection) -> ProviderConfigFile {
659    ProviderConfigFile {
660        protocol: cfg.protocol,
661        base_url: cfg.base_url,
662        default_model: cfg.default_model,
663        models: cfg.models,
664        display_name: cfg.display_name,
665        api_key_env: cfg.api_key_env,
666        organization: cfg.organization,
667        project: cfg.project,
668        aws: cfg.aws,
669        headers: cfg.headers.unwrap_or_default(),
670        auth_header: cfg.auth_header,
671        capabilities: provider_capability_overrides(cfg.capabilities.as_ref()),
672        reasoning_effort: cfg.reasoning_effort,
673    }
674}
675
676/// Combine the numeric `request_limit` and the optional `request_limit_mode` into a
677/// [`TurnRequestLimit`].
678///
679/// - Neither set ⇒ `None` (keep the `TurnConfig` default).
680/// - Number only ⇒ `Adaptive { initial: N }` (back-compatible with the historical bare
681///   `request_limit = N` behavior).
682/// - `mode = "unbounded"` ⇒ `Unbounded` (the number, if any, is ignored).
683/// - `mode = "fixed" | "adaptive"` ⇒ requires a number (errors if absent — a fixed or
684///   adaptive cap without `N` is meaningless).
685pub(crate) fn resolve_request_limit(
686    path: &Path,
687    limit: Option<u32>,
688    mode: Option<RequestLimitMode>,
689) -> Result<Option<TurnRequestLimit>, ConfigError> {
690    let require_n = |mode_name: &str| -> Result<u32, ConfigError> {
691        limit.ok_or_else(|| ConfigError::Invalid {
692            path: path.to_path_buf(),
693            message: format!(
694                "[turn] request_limit_mode = \"{mode_name}\" requires `request_limit = N`"
695            ),
696        })
697    };
698    match (mode, limit) {
699        (None, None) => Ok(None),
700        (None, Some(initial)) => Ok(Some(TurnRequestLimit::Adaptive {
701            initial,
702            expand_on_progress: true,
703        })),
704        (Some(RequestLimitMode::Unbounded), _) => Ok(Some(TurnRequestLimit::Unbounded)),
705        (Some(RequestLimitMode::Fixed), _) => {
706            Ok(Some(TurnRequestLimit::Fixed(require_n("fixed")?)))
707        }
708        (Some(RequestLimitMode::Adaptive), _) => Ok(Some(TurnRequestLimit::Adaptive {
709            initial: require_n("adaptive")?,
710            expand_on_progress: true,
711        })),
712    }
713}
714
715fn provider_capability_overrides(
716    section: Option<&crate::types::ProviderCapabilitiesSection>,
717) -> ProviderCapabilityOverrides {
718    let Some(section) = section else {
719        return ProviderCapabilityOverrides::default();
720    };
721    ProviderCapabilityOverrides::with_web_search(
722        section
723            .web_search
724            .as_ref()
725            .and_then(|s| s.mode)
726            .map(WebSearchCapabilityConfig::new),
727    )
728}
729
730fn load_optional_layer_opt(
731    source: ConfigSource,
732    path: Option<PathBuf>,
733) -> Result<Option<(ConfigLayerEntry, Vec<ConfigWarning>)>, ConfigError> {
734    let Some(path) = path else {
735        return Ok(None);
736    };
737    let raw = match fs::read_to_string(&path) {
738        Ok(raw) => raw,
739        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
740        Err(err) => {
741            return Err(ConfigError::Io {
742                path,
743                source: BoxError::new(err),
744            });
745        }
746    };
747    let value: TomlValue = raw.parse::<TomlValue>().map_err(|err| ConfigError::Parse {
748        path: path.clone(),
749        source: BoxError::new(err),
750    })?;
751    // Unknown key validation runs per-layer here: `deny_unknown_fields` errors from serde
752    // during decoding include the file path for that layer (decoding after merging would
753    // only report `<merged>`). See Config merge behavior.
754    reject_unknown_keys(&path, &value)?;
755    let warnings = Vec::new();
756    Ok(Some((
757        ConfigLayerEntry {
758            source,
759            path: Some(path),
760            raw_toml: Some(raw),
761            value,
762        },
763        warnings,
764    )))
765}
766
767/// Performs layer-by-layer typed-decode validation: when an unknown key is encountered,
768/// serde immediately errors, which is converted to [`ConfigError::Invalid`] with the file
769/// path for that layer. The `[hooks]` section is absorbed by `ConfigToml::hooks` to allow
770/// unknown fields through, as its own parser handles schema validation.
771fn reject_unknown_keys(path: &Path, value: &TomlValue) -> Result<(), ConfigError> {
772    value
773        .clone()
774        .try_into::<ConfigToml>()
775        .map(|_| ())
776        .map_err(|err| ConfigError::Invalid {
777            path: path.to_path_buf(),
778            message: err.to_string(),
779        })
780}
781
782pub(crate) fn dotenv_updates_from_str(
783    raw: &str,
784    existing_keys: &[impl AsRef<str>],
785) -> Vec<(String, String)> {
786    raw.lines()
787        .filter_map(|line| parse_dotenv_line(line.trim()))
788        .filter(|(key, _)| {
789            !existing_keys
790                .iter()
791                .any(|existing| existing.as_ref() == key.as_str())
792        })
793        .collect()
794}
795
796fn raw_env_keys() -> Vec<String> {
797    env::vars_os()
798        .filter_map(|(key, _)| key.into_string().ok())
799        .collect()
800}
801
802fn parse_dotenv_line(line: &str) -> Option<(String, String)> {
803    if line.is_empty() || line.starts_with('#') {
804        return None;
805    }
806    let (key, value) = line.split_once('=')?;
807    let key = key.trim();
808    if key.is_empty() {
809        return None;
810    }
811    Some((key.to_string(), strip_quotes(value.trim()).to_string()))
812}
813
814fn strip_quotes(s: &str) -> &str {
815    let bytes = s.as_bytes();
816    if let [first @ (b'"' | b'\''), .., last] = bytes
817        && first == last
818    {
819        return &s[1..s.len() - 1];
820    }
821    s
822}
823
824/// Resolve the user-level (global) `config.toml` path using the same XDG/HOME priority
825/// as the loader: `$XDG_CONFIG_HOME/defect/config.toml`, else `$HOME/.config/...`.
826///
827/// Returns `None` when neither `XDG_CONFIG_HOME` nor `HOME` is set. Used by `defect init`
828/// to write the global config to the same location the loader reads from.
829pub fn user_config_path() -> Option<PathBuf> {
830    resolve_user_config_path(&LoadConfigOptions::default())
831}
832
833/// Resolve the user-level `config.toml` path. Shares the same priority logic as
834/// `profiles`'s `resolve_user_agents_dir` and `skills`; returns `None` when not found —
835/// if the user has no XDG or HOME set, the user-level configuration is simply absent and
836/// should not prevent the program from starting.
837fn resolve_user_config_path(opts: &LoadConfigOptions) -> Option<PathBuf> {
838    // `--local`: ignore global/user-level config — sandbox only recognizes the project
839    // root `.defect/`.
840    if opts.local {
841        return None;
842    }
843    if let Some(xdg) = &opts.xdg_config_home {
844        return Some(xdg.join(USER_CONFIG_RELATIVE));
845    }
846    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
847        return Some(PathBuf::from(xdg).join(USER_CONFIG_RELATIVE));
848    }
849    if let Some(home) = &opts.home_dir {
850        return Some(home.join(".config/defect/config.toml"));
851    }
852    if let Ok(home) = env::var("HOME") {
853        return Some(PathBuf::from(home).join(".config/defect/config.toml"));
854    }
855
856    None
857}
858
859pub fn find_repo_root(cwd: &Path) -> Option<PathBuf> {
860    for dir in cwd.ancestors() {
861        let git_dir = dir.join(".git");
862        if git_dir.exists() {
863            return Some(dir.to_path_buf());
864        }
865    }
866    None
867}
868
869fn canonicalize_or_original(path: &Path) -> PathBuf {
870    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
871}
872
873fn extract_base_prompt(
874    config: &TomlValue,
875    source_path: Option<&PathBuf>,
876) -> Option<BasePromptConfigFile> {
877    let base = config.get("base_prompt")?.as_table()?;
878    let file = base
879        .get("file")
880        .and_then(TomlValue::as_str)
881        .map(PathBuf::from);
882    let text = base
883        .get("text")
884        .and_then(TomlValue::as_str)
885        .map(str::to_owned);
886    if file.is_none() && text.is_none() {
887        None
888    } else {
889        let file = file.map(|path| match source_path {
890            Some(path_root) if path.is_relative() => {
891                path_root.parent().unwrap_or(path_root).join(path)
892            }
893            _ => path,
894        });
895        Some(BasePromptConfigFile { file, text })
896    }
897}
898
899#[cfg(test)]
900mod tests;