Skip to main content

captchaforge/
config.rs

1//! Tier-A operational configuration loaded from `.captchaforge.toml`.
2//!
3//! Per the CLAUDE.md two-tier configurability rule, captchaforge has
4//! two distinct config layers:
5//!
6//! - **Tier A — operational config (this module)**: timeouts,
7//!   concurrency, cache TTL, VLM endpoint/model, third-party service
8//!   selection. CLI flags + TOML defaults; CLI wins. Compiled
9//!   defaults → `captchaforge.toml` → CLI flags.
10//! - **Tier B — community knowledge (`detect::rules`)**: vendor
11//!   detection rules, never CLI-flagged. Lives in TOML data files.
12//!
13//! This module owns Tier A. The shape mirrors the runtime types so
14//! callers materialise a config once and apply it to chain / solver /
15//! cache constructors.
16//!
17//! # Search path
18//!
19//! [`Config::discover`] looks in this order, returning the first hit:
20//! 1. `$CAPTCHAFORGE_CONFIG` (explicit path; if unset is skipped)
21//! 2. `./.captchaforge.toml` (project-local)
22//! 3. `./captchaforge.toml`
23//! 4. `$XDG_CONFIG_HOME/captchaforge/config.toml`
24//!    (fallback `~/.config/captchaforge/config.toml`)
25//!
26//! Missing-file is NOT an error from `discover` — it returns
27//! [`Config::default`]. Use [`Config::load_from_path`] when you want
28//! the strict "must exist" behaviour.
29//!
30//! # Example file
31//!
32//! ```toml
33//! # Per-solver timeout for the chain. Default 180_000.
34//! per_solver_timeout_ms = 60000
35//! # Whether to capture a screenshot on full chain failure. Default true.
36//! screenshot_on_failure = true
37//!
38//! [cache]
39//! # TTL in seconds for cached solved tokens. Default 60.
40//! ttl_seconds = 90
41//!
42//! [vlm]
43//! # Override Ollama endpoint. Beats the CAPTCHAFORGE_VLM_ENDPOINT env var.
44//! endpoint = "http://gpu-host:11434"
45//! model    = "qwen3-vl:30b"
46//!
47//! [third_party]
48//! # Pick one: "two_captcha" | "cap_monster" | "cap_solver" | "custom"
49//! service  = "two_captcha"
50//! # Required when service = "custom".
51//! base_url = "https://my-gateway.example/2captcha"
52//! # API key (or leave blank and use CAPTCHAFORGE_THIRDPARTY_API_KEY env var).
53//! api_key  = ""
54//! poll_interval_ms = 5000
55//! max_polls = 30
56//!
57//! [solve]
58//! # All SolveConfig fields are overridable individually.
59//! checkbox_max_attempts = 20
60//! token_max_attempts    = 24
61//! ```
62
63use 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/// Top-level Tier-A config. All fields are optional; absent fields
81/// fall back to compiled defaults.
82#[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    /// Override `ChainConfig::verify_outcome`. Default `None` ⇒
88    /// chain default (`true`). Set to `false` to skip the
89    /// before/after page snapshot — useful for synthetic fixtures
90    /// where a green-checkmark token is the only signal.
91    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    /// One of `two_captcha`, `cap_monster`, `cap_solver`, `custom`.
115    pub service: Option<String>,
116    /// Required when `service = "custom"`. Ignored otherwise.
117    pub base_url: Option<String>,
118    /// API key. Leave None to fall back to the
119    /// `CAPTCHAFORGE_THIRDPARTY_API_KEY` env var.
120    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    /// Walk the search path and return the first config found, or
140    /// `Config::default()` if none exists. Errors only on a malformed
141    /// TOML in a found file.
142    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    /// The ordered candidate paths Self::discover walks. Public so
152    /// CLIs can show users where the file is expected to live.
153    pub fn search_paths() -> Vec<PathBuf> {
154        Self::search_paths_with_env(|n| std::env::var(n).ok())
155    }
156
157    /// Like [`Self::search_paths`] but with an injectable env lookup
158    /// so unit tests don't have to mutate process env.
159    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    /// Read + parse a TOML file at `path`. Errors propagate so callers
180    /// can distinguish "no config" from "bad config."
181    ///
182    /// Emits a `tracing::warn!` if the loaded file commits a
183    /// third-party API key inline — those keys belong in
184    /// `CAPTCHAFORGE_THIRDPARTY_API_KEY` (env var) or a secrets
185    /// store, not in a file that gets committed alongside source.
186    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    /// Print a `tracing::warn!` for any plaintext secret present in a
197    /// loaded config file. Called by [`Self::load_from_path`].
198    /// Currently flags `[third_party] api_key`; kept as a separate
199    /// method so future secrets gain warnings here without touching
200    /// the load-path itself.
201    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    /// Parse from a TOML string (no I/O — useful in tests).
219    pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
220        toml::from_str(s)
221    }
222
223    /// Materialise a [`ChainConfig`] from this config, defaulting any
224    /// absent field.
225    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    /// Materialise a [`SolveConfig`] from this config, defaulting any
240    /// absent field.
241    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    /// Build a [`TokenCache`] honouring the configured TTL (default 60s).
272    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    /// Build a [`VlmCaptchaSolver`] with TOML overrides applied. TOML
278    /// values beat env vars (which beat defaults).
279    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    /// Build a [`ThirdPartyCaptchaSolver`] from the config. Returns
291    /// `None` when the [`ThirdPartyConfig`] is fully empty (no
292    /// service selected, no key, no overrides) — a signal that the
293    /// caller should leave third-party out of the chain entirely.
294    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    /// Build a fully-wired [`CaptchaSolverChain`] from this config:
337    /// chain config + token cache + provider registry (with bundled
338    /// rules) + Behavioral + VLM (configured) + Audio + third-party
339    /// (only when the `[third_party]` section is non-empty OR the
340    /// API key env var is set).
341    ///
342    /// Single entry point used by [`crate::auto_solve`] and the CLI's
343    /// `solve` subcommand so they share the same chain shape.
344    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        // 10s wait window — long enough for real vendor JS
351        // (Cloudflare Turnstile, hCaptcha, reCAPTCHA) to load over
352        // the network and issue a passive token, but short enough
353        // that mock fixtures with sub-3s setTimeout publishers don't
354        // bottleneck the chain. The chain's per-solver timeout is
355        // typically 60s; this is well within budget.
356        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        // Multi-step orchestrator runs before behavioral so wizard
362        // captchas (math → grid → text) get the multi-shot path.
363        chain.add_solver(MultiStepCaptchaSolver::new());
364        chain.add_solver(BehavioralCaptchaSolver::new());
365        chain.add_solver(self.build_vlm_solver());
366        // OCR fallback (tesseract). Solver is inert when tesseract is
367        // not on PATH — `supports()` returns false — so adding it
368        // unconditionally is free for users without tesseract.
369        chain.add_solver(OcrCaptchaSolver::new());
370        chain.add_solver(AudioCaptchaSolver::new());
371        // Third-party: prefer the explicit config; fall back to the
372        // env-var path so a bare `CAPTCHAFORGE_THIRDPARTY_API_KEY`
373        // export still gets the solver wired in.
374        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        // deny_unknown_fields catches typos like `per_sovler_timeout_ms`
399        // so users don't lose hours wondering why their override is
400        // ignored.
401        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        // Untouched fields keep defaults.
447        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        // Empty api_key in the TOML doesn't override the env-var
542        // fallback path (which we mock as "no key set" here).
543        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        // First path should be the project-local file, not the empty env.
587        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        // Eleven solvers: wait_for_token + cf_interstitial + math + pow +
639        // slider + multi_step + behavioral + vlm + ocr + audio + third-party.
640        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        // The warning is observable only via tracing; here we just
653        // assert the method exists, accepts a path, and doesn't panic
654        // on either an empty or populated api_key.
655        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        // Last solver is the configured third-party (cap_solver here).
680        assert_eq!(
681            chain.solvers[chain.solvers.len() - 1].name(),
682            "ThirdPartyCaptchaSolver"
683        );
684    }
685}