Skip to main content

antibot_rs/
debug_replay.rs

1//! Optional debug/replay sink: writes solved HTML + cookie metadata to disk so
2//! callers can inspect / diff what the solver actually returned.
3
4use crate::types::Solution;
5use serde::Serialize;
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use tracing::warn;
10
11#[derive(Debug, Clone)]
12pub struct DebugConfig {
13    /// Directory under which artifacts are written. Created on first solve.
14    pub directory: PathBuf,
15    /// If `true`, also dump the request URL/method as JSON next to the HTML.
16    pub include_metadata: bool,
17}
18
19impl DebugConfig {
20    pub fn new(directory: impl Into<PathBuf>) -> Self {
21        Self {
22            directory: directory.into(),
23            include_metadata: true,
24        }
25    }
26}
27
28#[derive(Clone)]
29pub(crate) struct DebugSink {
30    config: DebugConfig,
31    counter: Arc<AtomicU64>,
32}
33
34impl DebugSink {
35    pub fn new(config: DebugConfig) -> Self {
36        Self {
37            config,
38            counter: Arc::new(AtomicU64::new(0)),
39        }
40    }
41
42    /// Best-effort write. Errors are logged at WARN and swallowed — debug
43    /// replay must never break the solve path.
44    pub async fn write(&self, url: &str, solution: &Solution) {
45        if let Err(e) = self.write_inner(url, solution).await {
46            warn!("debug sink write failed: {}", e);
47        }
48    }
49
50    async fn write_inner(&self, url: &str, solution: &Solution) -> std::io::Result<()> {
51        tokio::fs::create_dir_all(&self.config.directory).await?;
52
53        let n = self.counter.fetch_add(1, Ordering::Relaxed);
54        let stem = format!("{:06}_{}", n, slug_from_url(url));
55        let html_path = self.config.directory.join(format!("{}.html", stem));
56        let meta_path = self.config.directory.join(format!("{}.json", stem));
57
58        if let Some(html) = &solution.response {
59            tokio::fs::write(&html_path, html.as_bytes()).await?;
60        }
61
62        if self.config.include_metadata {
63            let meta = ReplayMetadata {
64                url: url.to_string(),
65                status: solution.status,
66                user_agent: solution.user_agent.clone(),
67                cookies: solution
68                    .cookies
69                    .iter()
70                    .map(|c| ReplayCookie {
71                        name: c.name.clone(),
72                        value: c.value.clone(),
73                        domain: c.domain.clone(),
74                        path: c.path.clone(),
75                    })
76                    .collect(),
77                source: source_label(solution),
78                solved_at: chrono_unix(&solution.solved_at),
79            };
80            let json = serde_json::to_vec_pretty(&meta).unwrap_or_default();
81            tokio::fs::write(&meta_path, json).await?;
82        }
83
84        Ok(())
85    }
86
87}
88
89#[derive(Serialize)]
90struct ReplayMetadata {
91    url: String,
92    status: u16,
93    user_agent: String,
94    cookies: Vec<ReplayCookie>,
95    source: &'static str,
96    solved_at: u64,
97}
98
99#[derive(Serialize)]
100struct ReplayCookie {
101    name: String,
102    value: String,
103    domain: String,
104    path: String,
105}
106
107fn source_label(solution: &Solution) -> &'static str {
108    match solution.source {
109        crate::types::SolutionSource::Fresh => "fresh",
110        crate::types::SolutionSource::Cached { .. } => "cached",
111    }
112}
113
114fn chrono_unix(t: &std::time::SystemTime) -> u64 {
115    t.duration_since(std::time::UNIX_EPOCH)
116        .map(|d| d.as_secs())
117        .unwrap_or(0)
118}
119
120fn slug_from_url(url: &str) -> String {
121    let mut s = String::with_capacity(url.len().min(80));
122    for c in url.chars().take(80) {
123        if c.is_ascii_alphanumeric() {
124            s.push(c);
125        } else {
126            s.push('_');
127        }
128    }
129    if s.is_empty() {
130        "page".to_string()
131    } else {
132        s
133    }
134}