1use serde::{Deserialize, Serialize};
64use std::path::{Path, PathBuf};
65
66use std::sync::Arc;
67
68use crate::provider::ProviderRegistry;
69use crate::solver::{
70 AudioCaptchaSolver, BehavioralCaptchaSolver, CaptchaSolverChain, ChainConfig,
71 CloudflareInterstitialSolver, MathCaptchaSolver, MultiStepCaptchaSolver, OcrCaptchaSolver,
72 PowCaptchaSolver, SliderCaptchaSolver, SolveConfig, ThirdPartyCaptchaSolver,
73 ThirdPartyService, TokenCache, VlmCaptchaSolver, WaitForTokenSolver,
74};
75
76const DEFAULT_CACHE_TTL_SECONDS: u64 = 60;
77
78const ENV_CONFIG_PATH: &str = "CAPTCHAFORGE_CONFIG";
79
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83#[serde(default, deny_unknown_fields)]
84pub struct Config {
85 pub per_solver_timeout_ms: Option<u64>,
86 pub screenshot_on_failure: Option<bool>,
87 pub verify_outcome: Option<bool>,
92 pub cache: CacheConfig,
93 pub vlm: VlmConfig,
94 pub third_party: ThirdPartyConfig,
95 pub solve: SolveOverrides,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99#[serde(default, deny_unknown_fields)]
100pub struct CacheConfig {
101 pub ttl_seconds: Option<u64>,
102}
103
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105#[serde(default, deny_unknown_fields)]
106pub struct VlmConfig {
107 pub endpoint: Option<String>,
108 pub model: Option<String>,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112#[serde(default, deny_unknown_fields)]
113pub struct ThirdPartyConfig {
114 pub service: Option<String>,
116 pub base_url: Option<String>,
118 pub api_key: Option<String>,
121 pub poll_interval_ms: Option<u64>,
122 pub max_polls: Option<u32>,
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
126#[serde(default, deny_unknown_fields)]
127pub struct SolveOverrides {
128 pub checkbox_poll_interval_ms: Option<u64>,
129 pub checkbox_max_attempts: Option<u32>,
130 pub token_poll_interval_ms: Option<u64>,
131 pub token_max_attempts: Option<u32>,
132 pub audio_button_delay_ms: Option<u64>,
133 pub audio_submit_delay_ms: Option<u64>,
134 pub vlm_http_timeout_ms: Option<u64>,
135 pub client_http_timeout_ms: Option<u64>,
136}
137
138impl Config {
139 pub fn discover() -> anyhow::Result<Self> {
143 for path in Self::search_paths() {
144 if path.is_file() {
145 return Self::load_from_path(&path);
146 }
147 }
148 Ok(Self::default())
149 }
150
151 pub fn search_paths() -> Vec<PathBuf> {
154 Self::search_paths_with_env(|n| std::env::var(n).ok())
155 }
156
157 pub(crate) fn search_paths_with_env<F>(env: F) -> Vec<PathBuf>
160 where
161 F: Fn(&str) -> Option<String>,
162 {
163 let mut out = Vec::new();
164 if let Some(p) = env(ENV_CONFIG_PATH).filter(|s| !s.is_empty()) {
165 out.push(PathBuf::from(p));
166 }
167 out.push(PathBuf::from(".captchaforge.toml"));
168 out.push(PathBuf::from("captchaforge.toml"));
169 let xdg = env("XDG_CONFIG_HOME")
170 .filter(|s| !s.is_empty())
171 .map(PathBuf::from)
172 .or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")));
173 if let Some(base) = xdg {
174 out.push(base.join("captchaforge").join("config.toml"));
175 }
176 out
177 }
178
179 pub fn load_from_path(path: impl AsRef<Path>) -> anyhow::Result<Self> {
187 let path = path.as_ref();
188 let body = std::fs::read_to_string(path)
189 .map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
190 let cfg = Self::from_toml_str(&body)
191 .map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
192 cfg.warn_on_inline_secrets(path);
193 Ok(cfg)
194 }
195
196 pub fn warn_on_inline_secrets(&self, source: &Path) {
202 if self
203 .third_party
204 .api_key
205 .as_deref()
206 .is_some_and(|k| !k.is_empty())
207 {
208 tracing::warn!(
209 config = %source.display(),
210 "captchaforge: third_party.api_key is set inline — \
211 prefer the CAPTCHAFORGE_THIRDPARTY_API_KEY env var \
212 (or a secrets store) so the key doesn't get committed \
213 to git alongside the config file"
214 );
215 }
216 }
217
218 pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
220 toml::from_str(s)
221 }
222
223 pub fn chain_config(&self) -> ChainConfig {
226 let mut c = ChainConfig::default();
227 if let Some(v) = self.per_solver_timeout_ms {
228 c.per_solver_timeout_ms = v;
229 }
230 if let Some(v) = self.screenshot_on_failure {
231 c.screenshot_on_failure = v;
232 }
233 if let Some(v) = self.verify_outcome {
234 c.verify_outcome = v;
235 }
236 c
237 }
238
239 pub fn solve_config(&self) -> SolveConfig {
242 let mut c = SolveConfig::default();
243 let s = &self.solve;
244 if let Some(v) = s.checkbox_poll_interval_ms {
245 c.checkbox_poll_interval_ms = v;
246 }
247 if let Some(v) = s.checkbox_max_attempts {
248 c.checkbox_max_attempts = v;
249 }
250 if let Some(v) = s.token_poll_interval_ms {
251 c.token_poll_interval_ms = v;
252 }
253 if let Some(v) = s.token_max_attempts {
254 c.token_max_attempts = v;
255 }
256 if let Some(v) = s.audio_button_delay_ms {
257 c.audio_button_delay_ms = v;
258 }
259 if let Some(v) = s.audio_submit_delay_ms {
260 c.audio_submit_delay_ms = v;
261 }
262 if let Some(v) = s.vlm_http_timeout_ms {
263 c.vlm_http_timeout_ms = v;
264 }
265 if let Some(v) = s.client_http_timeout_ms {
266 c.client_http_timeout_ms = v;
267 }
268 c
269 }
270
271 pub fn build_token_cache(&self) -> TokenCache {
273 let ttl = self.cache.ttl_seconds.unwrap_or(DEFAULT_CACHE_TTL_SECONDS);
274 TokenCache::with_ttl(std::time::Duration::from_secs(ttl))
275 }
276
277 pub fn build_vlm_solver(&self) -> VlmCaptchaSolver {
280 let mut s = VlmCaptchaSolver::new();
281 if let Some(ep) = &self.vlm.endpoint {
282 s = s.with_endpoint(ep.clone());
283 }
284 if let Some(m) = &self.vlm.model {
285 s = s.with_model(m.clone());
286 }
287 s.with_config(self.solve_config())
288 }
289
290 pub fn build_third_party_solver(&self) -> Option<ThirdPartyCaptchaSolver> {
295 let tp = &self.third_party;
296 if tp.service.is_none()
297 && tp.base_url.is_none()
298 && tp.api_key.is_none()
299 && tp.poll_interval_ms.is_none()
300 && tp.max_polls.is_none()
301 {
302 return None;
303 }
304
305 let service = match tp.service.as_deref().unwrap_or("two_captcha") {
306 "two_captcha" => ThirdPartyService::TwoCaptcha,
307 "cap_monster" => ThirdPartyService::CapMonster,
308 "cap_solver" => ThirdPartyService::CapSolver,
309 "custom" => ThirdPartyService::Custom {
310 base_url: tp.base_url.clone().unwrap_or_default(),
311 },
312 other => ThirdPartyService::Custom {
313 base_url: other.to_string(),
314 },
315 };
316 let mut s = match service {
317 ThirdPartyService::TwoCaptcha => ThirdPartyCaptchaSolver::two_captcha(),
318 ThirdPartyService::CapMonster => ThirdPartyCaptchaSolver::cap_monster(),
319 ThirdPartyService::CapSolver => ThirdPartyCaptchaSolver::cap_solver(),
320 ThirdPartyService::Custom { base_url } => {
321 ThirdPartyCaptchaSolver::custom_endpoint(base_url)
322 }
323 };
324 if let Some(key) = tp.api_key.as_deref().filter(|k| !k.is_empty()) {
325 s = s.with_api_key(key);
326 }
327 if let Some(v) = tp.poll_interval_ms {
328 s = s.with_poll_interval_ms(v);
329 }
330 if let Some(v) = tp.max_polls {
331 s = s.with_max_polls(v);
332 }
333 Some(s)
334 }
335
336 pub fn build_chain(&self) -> anyhow::Result<CaptchaSolverChain> {
345 let registry = Arc::new(ProviderRegistry::with_built_in_rules()?);
346 let mut chain = CaptchaSolverChain::empty()
347 .with_config(self.chain_config())
348 .with_provider_registry(registry)
349 .with_token_cache(Arc::new(self.build_token_cache()));
350 chain.add_solver(WaitForTokenSolver::new().with_max_wait_ms(10_000));
357 chain.add_solver(CloudflareInterstitialSolver::new());
358 chain.add_solver(MathCaptchaSolver::new());
359 chain.add_solver(PowCaptchaSolver::new());
360 chain.add_solver(SliderCaptchaSolver::new());
361 chain.add_solver(MultiStepCaptchaSolver::new());
364 chain.add_solver(BehavioralCaptchaSolver::new());
365 chain.add_solver(self.build_vlm_solver());
366 chain.add_solver(OcrCaptchaSolver::new());
370 chain.add_solver(AudioCaptchaSolver::new());
371 if let Some(tp) = self.build_third_party_solver() {
375 chain.add_solver(tp);
376 } else {
377 chain.add_solver(ThirdPartyCaptchaSolver::two_captcha());
378 }
379 Ok(chain)
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn empty_toml_round_trips_to_defaults() {
389 let c = Config::from_toml_str("").unwrap();
390 assert!(c.per_solver_timeout_ms.is_none());
391 assert!(c.cache.ttl_seconds.is_none());
392 assert!(c.vlm.endpoint.is_none());
393 assert!(c.third_party.service.is_none());
394 }
395
396 #[test]
397 fn typo_in_top_level_key_is_a_parse_error() {
398 let r = Config::from_toml_str("per_sovler_timeout_ms = 100");
402 assert!(r.is_err(), "deny_unknown_fields should reject typos");
403 }
404
405 #[test]
406 fn full_toml_parses_and_materialises() {
407 let toml = r#"
408per_solver_timeout_ms = 60000
409screenshot_on_failure = false
410
411[cache]
412ttl_seconds = 120
413
414[vlm]
415endpoint = "http://gpu:11434"
416model = "qwen3-vl:7b"
417
418[third_party]
419service = "cap_monster"
420api_key = "xyz"
421poll_interval_ms = 7000
422max_polls = 12
423
424[solve]
425checkbox_max_attempts = 25
426vlm_http_timeout_ms = 30000
427"#;
428 let c = Config::from_toml_str(toml).unwrap();
429 assert_eq!(c.per_solver_timeout_ms, Some(60000));
430 assert_eq!(c.screenshot_on_failure, Some(false));
431 assert_eq!(c.cache.ttl_seconds, Some(120));
432 assert_eq!(c.vlm.endpoint.as_deref(), Some("http://gpu:11434"));
433 assert_eq!(c.vlm.model.as_deref(), Some("qwen3-vl:7b"));
434 assert_eq!(c.third_party.service.as_deref(), Some("cap_monster"));
435 assert_eq!(c.third_party.api_key.as_deref(), Some("xyz"));
436 assert_eq!(c.solve.checkbox_max_attempts, Some(25));
437 assert_eq!(c.solve.vlm_http_timeout_ms, Some(30000));
438
439 let cc = c.chain_config();
440 assert_eq!(cc.per_solver_timeout_ms, 60000);
441 assert!(!cc.screenshot_on_failure);
442
443 let sc = c.solve_config();
444 assert_eq!(sc.checkbox_max_attempts, 25);
445 assert_eq!(sc.vlm_http_timeout_ms, 30000);
446 assert_eq!(
448 sc.token_max_attempts,
449 SolveConfig::default().token_max_attempts
450 );
451 }
452
453 #[test]
454 fn build_vlm_solver_applies_endpoint_and_model() {
455 let c = Config::from_toml_str(
456 r#"
457[vlm]
458endpoint = "http://x:11434"
459model = "m:latest"
460"#,
461 )
462 .unwrap();
463 let s = c.build_vlm_solver();
464 assert_eq!(s.endpoint, "http://x:11434");
465 assert_eq!(s.model, "m:latest");
466 }
467
468 #[test]
469 fn build_third_party_solver_is_none_when_section_empty() {
470 let c = Config::default();
471 assert!(c.build_third_party_solver().is_none());
472 }
473
474 #[test]
475 fn build_third_party_solver_dispatches_service_variants() {
476 for (toml, expected_base) in [
477 (
478 r#"[third_party]
479service = "two_captcha"
480"#,
481 "https://2captcha.com",
482 ),
483 (
484 r#"[third_party]
485service = "cap_monster"
486"#,
487 "https://api.capmonster.cloud",
488 ),
489 (
490 r#"[third_party]
491service = "cap_solver"
492"#,
493 "https://api.capsolver.com",
494 ),
495 (
496 r#"[third_party]
497service = "custom"
498base_url = "https://my.gw"
499"#,
500 "https://my.gw",
501 ),
502 ] {
503 let c = Config::from_toml_str(toml).unwrap();
504 let s = c
505 .build_third_party_solver()
506 .expect("config has third_party set; should build a solver");
507 assert_eq!(s.service.base_url(), expected_base);
508 }
509 }
510
511 #[test]
512 fn build_third_party_solver_applies_api_key_and_polling() {
513 let c = Config::from_toml_str(
514 r#"
515[third_party]
516service = "two_captcha"
517api_key = "k123"
518poll_interval_ms = 9000
519max_polls = 7
520"#,
521 )
522 .unwrap();
523 let s = c.build_third_party_solver().unwrap();
524 assert_eq!(s.api_key.as_deref(), Some("k123"));
525 assert_eq!(s.poll_interval_ms, 9000);
526 assert_eq!(s.max_polls, 7);
527 assert!(s.has_api_key());
528 }
529
530 #[test]
531 fn build_third_party_solver_skips_empty_api_key() {
532 let c = Config::from_toml_str(
533 r#"
534[third_party]
535service = "two_captcha"
536api_key = ""
537"#,
538 )
539 .unwrap();
540 let s = c.build_third_party_solver().unwrap();
541 assert_eq!(s.api_key.as_deref().unwrap_or(""), "");
544 }
545
546 #[test]
547 fn build_token_cache_honours_configured_ttl() {
548 let c = Config::from_toml_str(
549 r#"[cache]
550ttl_seconds = 5
551"#,
552 )
553 .unwrap();
554 let cache = c.build_token_cache();
555 assert_eq!(cache.ttl(), std::time::Duration::from_secs(5));
556 }
557
558 #[test]
559 fn build_token_cache_defaults_to_60_seconds() {
560 let c = Config::default();
561 let cache = c.build_token_cache();
562 assert_eq!(cache.ttl(), std::time::Duration::from_secs(60));
563 }
564
565 #[test]
566 fn search_paths_includes_explicit_env_first() {
567 let paths = Config::search_paths_with_env(|name| match name {
568 "CAPTCHAFORGE_CONFIG" => Some("/tmp/explicit.toml".into()),
569 "HOME" => Some("/home/user".into()),
570 _ => None,
571 });
572 assert!(paths
573 .first()
574 .unwrap()
575 .to_string_lossy()
576 .contains("explicit"));
577 }
578
579 #[test]
580 fn search_paths_skips_empty_explicit_env() {
581 let paths = Config::search_paths_with_env(|name| match name {
582 "CAPTCHAFORGE_CONFIG" => Some(String::new()),
583 "HOME" => Some("/home/user".into()),
584 _ => None,
585 });
586 assert_eq!(paths.first().unwrap(), &PathBuf::from(".captchaforge.toml"));
588 }
589
590 #[test]
591 fn search_paths_appends_xdg_config_home() {
592 let paths = Config::search_paths_with_env(|name| match name {
593 "XDG_CONFIG_HOME" => Some("/x".into()),
594 _ => None,
595 });
596 let last = paths.last().unwrap();
597 assert!(
598 last.to_string_lossy()
599 .contains("/x/captchaforge/config.toml"),
600 "expected XDG path; got {:?}",
601 last
602 );
603 }
604
605 #[test]
606 fn search_paths_falls_back_to_home_dotconfig() {
607 let paths = Config::search_paths_with_env(|name| match name {
608 "HOME" => Some("/home/u".into()),
609 _ => None,
610 });
611 let last = paths.last().unwrap();
612 assert!(
613 last.to_string_lossy()
614 .contains("/home/u/.config/captchaforge/config.toml"),
615 "expected HOME-based fallback; got {:?}",
616 last
617 );
618 }
619
620 #[test]
621 fn load_from_path_round_trips_disk_file() {
622 let dir = tempfile::tempdir().unwrap();
623 let path = dir.path().join("captchaforge.toml");
624 std::fs::write(&path, "per_solver_timeout_ms = 12345\n").unwrap();
625 let c = Config::load_from_path(&path).unwrap();
626 assert_eq!(c.per_solver_timeout_ms, Some(12345));
627 }
628
629 #[test]
630 fn load_from_path_errors_on_missing_file() {
631 assert!(Config::load_from_path("/no/such/captchaforge.toml").is_err());
632 }
633
634 #[test]
635 fn build_chain_default_config_has_nine_solvers() {
636 let c = Config::default();
637 let chain = c.build_chain().unwrap();
638 assert_eq!(chain.solvers.len(), 11);
641 }
642
643 #[test]
644 fn build_chain_honors_per_solver_timeout_from_toml() {
645 let c = Config::from_toml_str("per_solver_timeout_ms = 7777").unwrap();
646 let chain = c.build_chain().unwrap();
647 assert_eq!(chain.config.per_solver_timeout_ms, 7777);
648 }
649
650 #[test]
651 fn warn_on_inline_secrets_is_method_callable_idempotent() {
652 let empty = Config::default();
656 empty.warn_on_inline_secrets(std::path::Path::new("test.toml"));
657
658 let with_key = Config::from_toml_str(
659 r#"[third_party]
660api_key = "secret"
661"#,
662 )
663 .unwrap();
664 with_key.warn_on_inline_secrets(std::path::Path::new("test.toml"));
665 }
666
667 #[test]
668 fn build_chain_uses_configured_third_party_when_section_present() {
669 let c = Config::from_toml_str(
670 r#"
671[third_party]
672service = "cap_solver"
673api_key = "k"
674"#,
675 )
676 .unwrap();
677 let chain = c.build_chain().unwrap();
678 assert_eq!(chain.solvers.len(), 11);
679 assert_eq!(
681 chain.solvers[chain.solvers.len() - 1].name(),
682 "ThirdPartyCaptchaSolver"
683 );
684 }
685}