use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::Mutex;
use tracing::warn;
use super::request::Request;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BackendKind {
Http,
Render,
Stealth,
}
#[derive(Debug, Default, Clone)]
pub struct CookieJar {
inner: Arc<Mutex<HashMap<String, String>>>,
}
impl CookieJar {
pub fn new() -> Self {
Self::default()
}
pub fn set(&self, name: impl Into<String>, value: impl Into<String>) {
self.inner.lock().insert(name.into(), value.into());
}
pub fn get(&self, name: &str) -> Option<String> {
self.inner.lock().get(name).cloned()
}
pub fn len(&self) -> usize {
self.inner.lock().len()
}
}
#[derive(Debug, Clone)]
pub struct SessionEntry {
pub id: String,
pub backend: BackendKind,
pub jar: CookieJar,
}
pub struct SessionManager {
entries: Mutex<HashMap<String, SessionEntry>>,
default_backend: BackendKind,
}
#[derive(Debug, Clone)]
pub struct RouteDecision {
pub backend: BackendKind,
pub jar: Option<CookieJar>,
pub fallback: bool,
}
impl SessionManager {
pub fn new(default_backend: BackendKind) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
default_backend,
}
}
pub fn default_backend(&self) -> BackendKind {
self.default_backend
}
pub fn register(&self, id: impl Into<String>, backend: BackendKind) -> SessionEntry {
let id = id.into();
let mut map = self.entries.lock();
map.entry(id.clone())
.or_insert_with(|| SessionEntry {
id: id.clone(),
backend,
jar: CookieJar::new(),
})
.clone()
}
pub fn get(&self, id: &str) -> Option<SessionEntry> {
self.entries.lock().get(id).cloned()
}
pub fn contains(&self, id: &str) -> bool {
self.entries.lock().contains_key(id)
}
pub fn len(&self) -> usize {
self.entries.lock().len()
}
pub fn remove(&self, id: &str) -> Option<SessionEntry> {
self.entries.lock().remove(id)
}
pub fn list(&self) -> Vec<SessionEntry> {
self.entries.lock().values().cloned().collect()
}
pub fn route(&self, req: &Request) -> RouteDecision {
match req.session_id.as_deref() {
None => RouteDecision {
backend: self.default_backend,
jar: None,
fallback: false,
},
Some(id) => {
if let Some(entry) = self.get(id) {
RouteDecision {
backend: entry.backend,
jar: Some(entry.jar),
fallback: false,
}
} else {
warn!(
target: "scraping::session",
session_id = %id,
default_backend = ?self.default_backend,
"unknown session_id — falling back to default backend"
);
RouteDecision {
backend: self.default_backend,
jar: None,
fallback: true,
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_creates_isolated_entry() {
let mgr = SessionManager::new(BackendKind::Http);
let a = mgr.register("a", BackendKind::Stealth);
assert_eq!(a.id, "a");
assert_eq!(a.backend, BackendKind::Stealth);
assert!(mgr.contains("a"));
}
#[test]
fn register_is_idempotent() {
let mgr = SessionManager::new(BackendKind::Http);
let a = mgr.register("dup", BackendKind::Render);
a.jar.set("k", "v1");
let b = mgr.register("dup", BackendKind::Stealth); assert_eq!(b.backend, BackendKind::Render);
assert_eq!(b.jar.get("k").as_deref(), Some("v1"));
assert_eq!(mgr.len(), 1);
}
#[test]
fn cookies_isolated_between_same_backend_sessions() {
let mgr = SessionManager::new(BackendKind::Http);
let a = mgr.register("sess-a", BackendKind::Stealth);
let b = mgr.register("sess-b", BackendKind::Stealth);
a.jar.set("session", "AAA");
b.jar.set("session", "BBB");
assert_eq!(a.jar.get("session").as_deref(), Some("AAA"));
assert_eq!(b.jar.get("session").as_deref(), Some("BBB"));
assert_eq!(a.jar.len(), 1);
assert_eq!(b.jar.len(), 1);
}
#[test]
fn jar_clone_shares_state_within_same_session() {
let mgr = SessionManager::new(BackendKind::Http);
let entry = mgr.register("s", BackendKind::Http);
let req = Request::new("https://x.test/").with_session("s");
let route = mgr.route(&req);
route.jar.as_ref().unwrap().set("k", "v");
assert_eq!(entry.jar.get("k").as_deref(), Some("v"));
}
#[test]
fn route_default_when_no_session_id() {
let mgr = SessionManager::new(BackendKind::Render);
let req = Request::new("https://x.test/");
let route = mgr.route(&req);
assert_eq!(route.backend, BackendKind::Render);
assert!(route.jar.is_none());
assert!(!route.fallback);
}
#[test]
fn route_known_session_uses_its_backend() {
let mgr = SessionManager::new(BackendKind::Http);
mgr.register("s1", BackendKind::Stealth);
let req = Request::new("https://x.test/").with_session("s1");
let route = mgr.route(&req);
assert_eq!(route.backend, BackendKind::Stealth);
assert!(route.jar.is_some());
assert!(!route.fallback);
}
#[test]
fn route_unknown_session_falls_back_to_default() {
let mgr = SessionManager::new(BackendKind::Http);
let req = Request::new("https://x.test/").with_session("ghost");
let route = mgr.route(&req);
assert_eq!(route.backend, BackendKind::Http);
assert!(route.jar.is_none());
assert!(route.fallback);
assert!(!mgr.contains("ghost"));
}
}