use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use texting_robots::Robot;
use url::Url;
use crate::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Purpose {
Search,
AiInput,
AiTrain,
}
impl Purpose {
pub fn as_str(self) -> &'static str {
match self {
Self::Search => "search",
Self::AiInput => "ai-input",
Self::AiTrain => "ai-train",
}
}
pub fn all() -> [Purpose; 3] {
[Self::Search, Self::AiInput, Self::AiTrain]
}
}
impl std::str::FromStr for Purpose {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"search" => Ok(Self::Search),
"ai-input" | "ai_input" => Ok(Self::AiInput),
"ai-train" | "ai_train" => Ok(Self::AiTrain),
other => Err(format!("unknown crawl purpose: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentSignal {
pub search: bool,
pub ai_input: bool,
pub ai_train: bool,
}
impl Default for ContentSignal {
fn default() -> Self {
Self {
search: true,
ai_input: true,
ai_train: true,
}
}
}
impl ContentSignal {
pub fn permits(self, purpose: Purpose) -> bool {
match purpose {
Purpose::Search => self.search,
Purpose::AiInput => self.ai_input,
Purpose::AiTrain => self.ai_train,
}
}
pub fn fully_denies(self, declared: &[Purpose]) -> bool {
!declared.is_empty() && declared.iter().all(|p| !self.permits(*p))
}
}
pub fn parse_content_signal(body: &str, user_agent: &str) -> ContentSignal {
let ua_lc = user_agent.to_ascii_lowercase();
let mut current_uas: Vec<String> = Vec::new();
let mut last_was_agent = false;
let mut best: (u8, ContentSignal) = (0, ContentSignal::default());
for raw_line in body.lines() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let Some((field, value)) = line.split_once(':') else {
continue;
};
let field_lc = field.trim().to_ascii_lowercase();
let value = value.trim();
match field_lc.as_str() {
"user-agent" => {
if !last_was_agent {
current_uas.clear();
}
current_uas.push(value.to_ascii_lowercase());
last_was_agent = true;
}
"content-signal" => {
last_was_agent = false;
let sig = parse_signal_value(value);
for ua in ¤t_uas {
let spec = if ua == &ua_lc {
2
} else if ua == "*" {
1
} else {
0
};
if spec > best.0 {
best = (spec, sig);
}
}
}
_ => {
last_was_agent = false;
}
}
}
best.1
}
fn parse_signal_value(value: &str) -> ContentSignal {
let mut sig = ContentSignal::default();
for piece in value.split(',') {
let piece = piece.trim();
if piece.is_empty() {
continue;
}
let (name, verdict) = match piece.split_once('=') {
Some((n, v)) => (n.trim().to_ascii_lowercase(), v.trim().to_ascii_lowercase()),
None => (piece.to_ascii_lowercase(), String::from("yes")),
};
let allowed = matches!(verdict.as_str(), "yes" | "y" | "true" | "1" | "allow");
match name.as_str() {
"search" => sig.search = allowed,
"ai-input" | "ai_input" => sig.ai_input = allowed,
"ai-train" | "ai_train" => sig.ai_train = allowed,
_ => {}
}
}
sig
}
pub struct RobotsCache {
ttl: Duration,
inner: DashMap<String, (Instant, Arc<Option<Robot>>, ContentSignal)>,
}
impl RobotsCache {
pub fn new(ttl: Duration) -> Self {
Self {
ttl,
inner: DashMap::new(),
}
}
pub fn check(&self, url: &Url, user_agent: &str) -> Option<bool> {
let host = url.host_str()?;
let entry = self.inner.get(host)?;
let (inserted, robot, _signal) = entry.value();
if inserted.elapsed() > self.ttl {
return None;
}
robot
.as_ref()
.as_ref()
.map(|r| r.allowed(url.as_str()))
.or(Some(true))
.map(|allowed| {
let _ = user_agent;
allowed
})
}
pub fn content_signal(&self, host: &str) -> Option<ContentSignal> {
let entry = self.inner.get(host)?;
let (inserted, _, signal) = entry.value();
if inserted.elapsed() > self.ttl {
return None;
}
Some(*signal)
}
pub fn store(&self, host: &str, txt: Option<&str>, user_agent: &str) -> Result<()> {
let robot = txt.and_then(|t| Robot::new(user_agent, t.as_bytes()).ok());
let signal = txt
.map(|t| parse_content_signal(t, user_agent))
.unwrap_or_default();
self.inner
.insert(host.to_string(), (Instant::now(), Arc::new(robot), signal));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_permissive() {
let s = ContentSignal::default();
for p in Purpose::all() {
assert!(s.permits(p));
}
assert!(!s.fully_denies(&Purpose::all()));
}
#[test]
fn parse_permissive_body() {
let body = "User-agent: *\nDisallow:\n";
let s = parse_content_signal(body, "crawlex");
assert_eq!(s, ContentSignal::default());
}
#[test]
fn parse_fully_deny() {
let body = "User-agent: *\nContent-Signal: search=no, ai-input=no, ai-train=no\n";
let s = parse_content_signal(body, "crawlex");
assert!(!s.search && !s.ai_input && !s.ai_train);
assert!(s.fully_denies(&Purpose::all()));
}
#[test]
fn parse_mixed_denies_ai_train_only() {
let body =
"User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=no\nDisallow:\n";
let s = parse_content_signal(body, "crawlex");
assert!(s.permits(Purpose::Search));
assert!(s.permits(Purpose::AiInput));
assert!(!s.permits(Purpose::AiTrain));
assert!(!s.fully_denies(&Purpose::all()));
assert!(s.fully_denies(&[Purpose::AiTrain]));
}
#[test]
fn ua_specific_overrides_wildcard() {
let body = "User-agent: *\nContent-Signal: search=no\n\
User-agent: crawlex\nContent-Signal: search=yes\n";
let s = parse_content_signal(body, "crawlex");
assert!(s.permits(Purpose::Search));
let other = parse_content_signal(body, "otherbot");
assert!(!other.permits(Purpose::Search));
}
#[test]
fn stacked_user_agents_share_signal() {
let body = "User-agent: alpha\nUser-agent: beta\nContent-Signal: ai-train=no\n";
let a = parse_content_signal(body, "alpha");
let b = parse_content_signal(body, "beta");
assert!(!a.permits(Purpose::AiTrain));
assert!(!b.permits(Purpose::AiTrain));
}
#[test]
fn purpose_from_str_roundtrip() {
for p in Purpose::all() {
let parsed: Purpose = p.as_str().parse().unwrap();
assert_eq!(parsed, p);
}
assert!("nope".parse::<Purpose>().is_err());
}
#[test]
fn cache_stores_and_returns_signal() {
let cache = RobotsCache::new(Duration::from_secs(60));
let body = "User-agent: *\nContent-Signal: search=no, ai-input=no, ai-train=no\n";
cache.store("example.com", Some(body), "crawlex").unwrap();
let sig = cache.content_signal("example.com").expect("stored");
assert!(sig.fully_denies(&Purpose::all()));
}
#[test]
fn cache_missing_host_returns_none() {
let cache = RobotsCache::new(Duration::from_secs(60));
assert!(cache.content_signal("nope.example").is_none());
}
}