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
27pub 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 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 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 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
173pub 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 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 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 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 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 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
512fn 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 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 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) = §ion.default_model {
620 models.push(default_model.clone());
621 }
622 if let Some(section_models) = §ion.models {
623 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
676pub(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 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
767fn 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
824pub fn user_config_path() -> Option<PathBuf> {
830 resolve_user_config_path(&LoadConfigOptions::default())
831}
832
833fn resolve_user_config_path(opts: &LoadConfigOptions) -> Option<PathBuf> {
838 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;