use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::provider::ProviderRegistry;
use crate::solver::{
AudioCaptchaSolver, BehavioralCaptchaSolver, CaptchaSolverChain, ChainConfig,
MathCaptchaSolver, PowCaptchaSolver, SliderCaptchaSolver, SolveConfig, ThirdPartyCaptchaSolver,
ThirdPartyService, TokenCache, VlmCaptchaSolver, WaitForTokenSolver,
};
const DEFAULT_CACHE_TTL_SECONDS: u64 = 60;
const ENV_CONFIG_PATH: &str = "CAPTCHAFORGE_CONFIG";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub per_solver_timeout_ms: Option<u64>,
pub screenshot_on_failure: Option<bool>,
pub cache: CacheConfig,
pub vlm: VlmConfig,
pub third_party: ThirdPartyConfig,
pub solve: SolveOverrides,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CacheConfig {
pub ttl_seconds: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VlmConfig {
pub endpoint: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ThirdPartyConfig {
pub service: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub poll_interval_ms: Option<u64>,
pub max_polls: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SolveOverrides {
pub checkbox_poll_interval_ms: Option<u64>,
pub checkbox_max_attempts: Option<u32>,
pub token_poll_interval_ms: Option<u64>,
pub token_max_attempts: Option<u32>,
pub audio_button_delay_ms: Option<u64>,
pub audio_submit_delay_ms: Option<u64>,
pub vlm_http_timeout_ms: Option<u64>,
pub client_http_timeout_ms: Option<u64>,
}
impl Config {
pub fn discover() -> anyhow::Result<Self> {
for path in Self::search_paths() {
if path.is_file() {
return Self::load_from_path(&path);
}
}
Ok(Self::default())
}
pub fn search_paths() -> Vec<PathBuf> {
Self::search_paths_with_env(|n| std::env::var(n).ok())
}
pub(crate) fn search_paths_with_env<F>(env: F) -> Vec<PathBuf>
where
F: Fn(&str) -> Option<String>,
{
let mut out = Vec::new();
if let Some(p) = env(ENV_CONFIG_PATH).filter(|s| !s.is_empty()) {
out.push(PathBuf::from(p));
}
out.push(PathBuf::from(".captchaforge.toml"));
out.push(PathBuf::from("captchaforge.toml"));
let xdg = env("XDG_CONFIG_HOME")
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")));
if let Some(base) = xdg {
out.push(base.join("captchaforge").join("config.toml"));
}
out
}
pub fn load_from_path(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let path = path.as_ref();
let body = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
let cfg = Self::from_toml_str(&body)
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
cfg.warn_on_inline_secrets(path);
Ok(cfg)
}
pub fn warn_on_inline_secrets(&self, source: &Path) {
if self
.third_party
.api_key
.as_deref()
.is_some_and(|k| !k.is_empty())
{
tracing::warn!(
config = %source.display(),
"captchaforge: third_party.api_key is set inline — \
prefer the CAPTCHAFORGE_THIRDPARTY_API_KEY env var \
(or a secrets store) so the key doesn't get committed \
to git alongside the config file"
);
}
}
pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
pub fn chain_config(&self) -> ChainConfig {
let mut c = ChainConfig::default();
if let Some(v) = self.per_solver_timeout_ms {
c.per_solver_timeout_ms = v;
}
if let Some(v) = self.screenshot_on_failure {
c.screenshot_on_failure = v;
}
c
}
pub fn solve_config(&self) -> SolveConfig {
let mut c = SolveConfig::default();
let s = &self.solve;
if let Some(v) = s.checkbox_poll_interval_ms {
c.checkbox_poll_interval_ms = v;
}
if let Some(v) = s.checkbox_max_attempts {
c.checkbox_max_attempts = v;
}
if let Some(v) = s.token_poll_interval_ms {
c.token_poll_interval_ms = v;
}
if let Some(v) = s.token_max_attempts {
c.token_max_attempts = v;
}
if let Some(v) = s.audio_button_delay_ms {
c.audio_button_delay_ms = v;
}
if let Some(v) = s.audio_submit_delay_ms {
c.audio_submit_delay_ms = v;
}
if let Some(v) = s.vlm_http_timeout_ms {
c.vlm_http_timeout_ms = v;
}
if let Some(v) = s.client_http_timeout_ms {
c.client_http_timeout_ms = v;
}
c
}
pub fn build_token_cache(&self) -> TokenCache {
let ttl = self.cache.ttl_seconds.unwrap_or(DEFAULT_CACHE_TTL_SECONDS);
TokenCache::with_ttl(std::time::Duration::from_secs(ttl))
}
pub fn build_vlm_solver(&self) -> VlmCaptchaSolver {
let mut s = VlmCaptchaSolver::new();
if let Some(ep) = &self.vlm.endpoint {
s = s.with_endpoint(ep.clone());
}
if let Some(m) = &self.vlm.model {
s = s.with_model(m.clone());
}
s.with_config(self.solve_config())
}
pub fn build_third_party_solver(&self) -> Option<ThirdPartyCaptchaSolver> {
let tp = &self.third_party;
if tp.service.is_none()
&& tp.base_url.is_none()
&& tp.api_key.is_none()
&& tp.poll_interval_ms.is_none()
&& tp.max_polls.is_none()
{
return None;
}
let service = match tp.service.as_deref().unwrap_or("two_captcha") {
"two_captcha" => ThirdPartyService::TwoCaptcha,
"cap_monster" => ThirdPartyService::CapMonster,
"cap_solver" => ThirdPartyService::CapSolver,
"custom" => ThirdPartyService::Custom {
base_url: tp.base_url.clone().unwrap_or_default(),
},
other => ThirdPartyService::Custom {
base_url: other.to_string(),
},
};
let mut s = match service {
ThirdPartyService::TwoCaptcha => ThirdPartyCaptchaSolver::two_captcha(),
ThirdPartyService::CapMonster => ThirdPartyCaptchaSolver::cap_monster(),
ThirdPartyService::CapSolver => ThirdPartyCaptchaSolver::cap_solver(),
ThirdPartyService::Custom { base_url } => {
ThirdPartyCaptchaSolver::custom_endpoint(base_url)
}
};
if let Some(key) = tp.api_key.as_deref().filter(|k| !k.is_empty()) {
s = s.with_api_key(key);
}
if let Some(v) = tp.poll_interval_ms {
s = s.with_poll_interval_ms(v);
}
if let Some(v) = tp.max_polls {
s = s.with_max_polls(v);
}
Some(s)
}
pub fn build_chain(&self) -> anyhow::Result<CaptchaSolverChain> {
let registry = Arc::new(ProviderRegistry::with_built_in_rules()?);
let mut chain = CaptchaSolverChain::empty()
.with_config(self.chain_config())
.with_provider_registry(registry)
.with_token_cache(Arc::new(self.build_token_cache()));
chain.add_solver(WaitForTokenSolver::new().with_max_wait_ms(3_000));
chain.add_solver(MathCaptchaSolver::new());
chain.add_solver(PowCaptchaSolver::new());
chain.add_solver(SliderCaptchaSolver::new());
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(self.build_vlm_solver());
chain.add_solver(AudioCaptchaSolver::new());
if let Some(tp) = self.build_third_party_solver() {
chain.add_solver(tp);
} else {
chain.add_solver(ThirdPartyCaptchaSolver::two_captcha());
}
Ok(chain)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_toml_round_trips_to_defaults() {
let c = Config::from_toml_str("").unwrap();
assert!(c.per_solver_timeout_ms.is_none());
assert!(c.cache.ttl_seconds.is_none());
assert!(c.vlm.endpoint.is_none());
assert!(c.third_party.service.is_none());
}
#[test]
fn typo_in_top_level_key_is_a_parse_error() {
let r = Config::from_toml_str("per_sovler_timeout_ms = 100");
assert!(r.is_err(), "deny_unknown_fields should reject typos");
}
#[test]
fn full_toml_parses_and_materialises() {
let toml = r#"
per_solver_timeout_ms = 60000
screenshot_on_failure = false
[cache]
ttl_seconds = 120
[vlm]
endpoint = "http://gpu:11434"
model = "qwen3-vl:7b"
[third_party]
service = "cap_monster"
api_key = "xyz"
poll_interval_ms = 7000
max_polls = 12
[solve]
checkbox_max_attempts = 25
vlm_http_timeout_ms = 30000
"#;
let c = Config::from_toml_str(toml).unwrap();
assert_eq!(c.per_solver_timeout_ms, Some(60000));
assert_eq!(c.screenshot_on_failure, Some(false));
assert_eq!(c.cache.ttl_seconds, Some(120));
assert_eq!(c.vlm.endpoint.as_deref(), Some("http://gpu:11434"));
assert_eq!(c.vlm.model.as_deref(), Some("qwen3-vl:7b"));
assert_eq!(c.third_party.service.as_deref(), Some("cap_monster"));
assert_eq!(c.third_party.api_key.as_deref(), Some("xyz"));
assert_eq!(c.solve.checkbox_max_attempts, Some(25));
assert_eq!(c.solve.vlm_http_timeout_ms, Some(30000));
let cc = c.chain_config();
assert_eq!(cc.per_solver_timeout_ms, 60000);
assert!(!cc.screenshot_on_failure);
let sc = c.solve_config();
assert_eq!(sc.checkbox_max_attempts, 25);
assert_eq!(sc.vlm_http_timeout_ms, 30000);
assert_eq!(
sc.token_max_attempts,
SolveConfig::default().token_max_attempts
);
}
#[test]
fn build_vlm_solver_applies_endpoint_and_model() {
let c = Config::from_toml_str(
r#"
[vlm]
endpoint = "http://x:11434"
model = "m:latest"
"#,
)
.unwrap();
let s = c.build_vlm_solver();
assert_eq!(s.endpoint, "http://x:11434");
assert_eq!(s.model, "m:latest");
}
#[test]
fn build_third_party_solver_is_none_when_section_empty() {
let c = Config::default();
assert!(c.build_third_party_solver().is_none());
}
#[test]
fn build_third_party_solver_dispatches_service_variants() {
for (toml, expected_base) in [
(
r#"[third_party]
service = "two_captcha"
"#,
"https://2captcha.com",
),
(
r#"[third_party]
service = "cap_monster"
"#,
"https://api.capmonster.cloud",
),
(
r#"[third_party]
service = "cap_solver"
"#,
"https://api.capsolver.com",
),
(
r#"[third_party]
service = "custom"
base_url = "https://my.gw"
"#,
"https://my.gw",
),
] {
let c = Config::from_toml_str(toml).unwrap();
let s = c
.build_third_party_solver()
.expect("config has third_party set; should build a solver");
assert_eq!(s.service.base_url(), expected_base);
}
}
#[test]
fn build_third_party_solver_applies_api_key_and_polling() {
let c = Config::from_toml_str(
r#"
[third_party]
service = "two_captcha"
api_key = "k123"
poll_interval_ms = 9000
max_polls = 7
"#,
)
.unwrap();
let s = c.build_third_party_solver().unwrap();
assert_eq!(s.api_key.as_deref(), Some("k123"));
assert_eq!(s.poll_interval_ms, 9000);
assert_eq!(s.max_polls, 7);
assert!(s.has_api_key());
}
#[test]
fn build_third_party_solver_skips_empty_api_key() {
let c = Config::from_toml_str(
r#"
[third_party]
service = "two_captcha"
api_key = ""
"#,
)
.unwrap();
let s = c.build_third_party_solver().unwrap();
assert_eq!(s.api_key.as_deref().unwrap_or(""), "");
}
#[test]
fn build_token_cache_honours_configured_ttl() {
let c = Config::from_toml_str(
r#"[cache]
ttl_seconds = 5
"#,
)
.unwrap();
let cache = c.build_token_cache();
assert_eq!(cache.ttl(), std::time::Duration::from_secs(5));
}
#[test]
fn build_token_cache_defaults_to_60_seconds() {
let c = Config::default();
let cache = c.build_token_cache();
assert_eq!(cache.ttl(), std::time::Duration::from_secs(60));
}
#[test]
fn search_paths_includes_explicit_env_first() {
let paths = Config::search_paths_with_env(|name| match name {
"CAPTCHAFORGE_CONFIG" => Some("/tmp/explicit.toml".into()),
"HOME" => Some("/home/user".into()),
_ => None,
});
assert!(paths
.first()
.unwrap()
.to_string_lossy()
.contains("explicit"));
}
#[test]
fn search_paths_skips_empty_explicit_env() {
let paths = Config::search_paths_with_env(|name| match name {
"CAPTCHAFORGE_CONFIG" => Some(String::new()),
"HOME" => Some("/home/user".into()),
_ => None,
});
assert_eq!(paths.first().unwrap(), &PathBuf::from(".captchaforge.toml"));
}
#[test]
fn search_paths_appends_xdg_config_home() {
let paths = Config::search_paths_with_env(|name| match name {
"XDG_CONFIG_HOME" => Some("/x".into()),
_ => None,
});
let last = paths.last().unwrap();
assert!(
last.to_string_lossy()
.contains("/x/captchaforge/config.toml"),
"expected XDG path; got {:?}",
last
);
}
#[test]
fn search_paths_falls_back_to_home_dotconfig() {
let paths = Config::search_paths_with_env(|name| match name {
"HOME" => Some("/home/u".into()),
_ => None,
});
let last = paths.last().unwrap();
assert!(
last.to_string_lossy()
.contains("/home/u/.config/captchaforge/config.toml"),
"expected HOME-based fallback; got {:?}",
last
);
}
#[test]
fn load_from_path_round_trips_disk_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("captchaforge.toml");
std::fs::write(&path, "per_solver_timeout_ms = 12345\n").unwrap();
let c = Config::load_from_path(&path).unwrap();
assert_eq!(c.per_solver_timeout_ms, Some(12345));
}
#[test]
fn load_from_path_errors_on_missing_file() {
assert!(Config::load_from_path("/no/such/captchaforge.toml").is_err());
}
#[test]
fn build_chain_default_config_has_eight_solvers() {
let c = Config::default();
let chain = c.build_chain().unwrap();
assert_eq!(chain.solvers.len(), 8);
}
#[test]
fn build_chain_honors_per_solver_timeout_from_toml() {
let c = Config::from_toml_str("per_solver_timeout_ms = 7777").unwrap();
let chain = c.build_chain().unwrap();
assert_eq!(chain.config.per_solver_timeout_ms, 7777);
}
#[test]
fn warn_on_inline_secrets_is_method_callable_idempotent() {
let empty = Config::default();
empty.warn_on_inline_secrets(std::path::Path::new("test.toml"));
let with_key = Config::from_toml_str(
r#"[third_party]
api_key = "secret"
"#,
)
.unwrap();
with_key.warn_on_inline_secrets(std::path::Path::new("test.toml"));
}
#[test]
fn build_chain_uses_configured_third_party_when_section_present() {
let c = Config::from_toml_str(
r#"
[third_party]
service = "cap_solver"
api_key = "k"
"#,
)
.unwrap();
let chain = c.build_chain().unwrap();
assert_eq!(chain.solvers.len(), 8);
assert_eq!(chain.solvers[7].name(), "ThirdPartyCaptchaSolver");
}
}