#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::fmt;
fn tokenize(value: &str) -> Vec<String> {
value
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|t| !t.is_empty())
.map(|t| t.to_ascii_lowercase())
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Rel {
tokens: Vec<String>,
}
impl Rel {
pub fn parse(value: &str) -> Self {
Rel {
tokens: tokenize(value),
}
}
pub fn parse_opt(value: Option<&str>) -> Self {
value.map(Rel::parse).unwrap_or_default()
}
pub fn has(&self, token: &str) -> bool {
let needle = token.to_ascii_lowercase();
self.tokens.iter().any(|t| *t == needle)
}
pub fn tokens(&self) -> &[String] {
&self.tokens
}
pub fn follows(&self) -> bool {
self.withholding_token().is_none()
}
pub fn withholding_token(&self) -> Option<&str> {
self.tokens
.iter()
.find(|t| matches!(t.as_str(), "nofollow" | "ugc" | "sponsored"))
.map(|t| t.as_str())
}
}
impl fmt::Display for Rel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.tokens.join(" "))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RobotsDirectives {
pub noindex: bool,
pub nofollow: bool,
}
impl RobotsDirectives {
pub fn parse(value: &str) -> Self {
let mut out = RobotsDirectives::default();
for token in tokenize(value) {
let name = token.split(':').next().unwrap_or("").to_string();
match name.as_str() {
"noindex" => out.noindex = true,
"nofollow" => out.nofollow = true,
"none" => {
out.noindex = true;
out.nofollow = true;
}
_ => {}
}
}
out
}
pub fn indexable(&self) -> bool {
!self.noindex
}
pub fn merge(self, other: RobotsDirectives) -> Self {
RobotsDirectives {
noindex: self.noindex || other.noindex,
nofollow: self.nofollow || other.nofollow,
}
}
}
impl fmt::Display for RobotsDirectives {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (self.noindex, self.nofollow) {
(false, false) => f.write_str("index, follow"),
(true, false) => f.write_str("noindex, follow"),
(false, true) => f.write_str("index, nofollow"),
(true, true) => f.write_str("noindex, nofollow"),
}
}
}
const VALUED_DIRECTIVES: [&str; 5] = [
"unavailable_after",
"max-snippet",
"max-image-preview",
"max-video-preview",
"notranslate",
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct XRobotsTag {
pub user_agent: Option<String>,
pub directives: RobotsDirectives,
}
impl XRobotsTag {
pub fn parse(value: &str) -> Self {
let first_segment = value.split(',').next().unwrap_or("");
if let Some((head, _)) = first_segment.split_once(':') {
let candidate = head.trim().to_ascii_lowercase();
let is_valued = VALUED_DIRECTIVES.contains(&candidate.as_str());
let is_bare_directive =
matches!(candidate.as_str(), "noindex" | "nofollow" | "none" | "all" | "index" | "follow");
if !candidate.is_empty() && !is_valued && !is_bare_directive {
let rest = &value[head.len() + 1..];
return XRobotsTag {
user_agent: Some(candidate),
directives: RobotsDirectives::parse(rest),
};
}
}
XRobotsTag {
user_agent: None,
directives: RobotsDirectives::parse(value),
}
}
pub fn applies_to(&self, agent: Option<&str>) -> bool {
match (&self.user_agent, agent) {
(None, _) => true,
(Some(_), None) => false,
(Some(scoped), Some(a)) => scoped.eq_ignore_ascii_case(a),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PageDirectives {
pub meta: Option<RobotsDirectives>,
pub headers: Vec<XRobotsTag>,
}
impl PageDirectives {
pub fn new() -> Self {
PageDirectives::default()
}
pub fn with_meta_robots(mut self, content: &str) -> Self {
self.meta = Some(RobotsDirectives::parse(content));
self
}
pub fn with_x_robots_tag(mut self, value: &str) -> Self {
self.headers.push(XRobotsTag::parse(value));
self
}
pub fn effective(&self, agent: Option<&str>) -> RobotsDirectives {
let mut out = self.meta.unwrap_or_default();
for header in self.headers.iter().filter(|h| h.applies_to(agent)) {
out = out.merge(header.directives);
}
out
}
pub fn indexable(&self, agent: Option<&str>) -> bool {
self.effective(agent).indexable()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
RelNofollow,
RelUgc,
RelSponsored,
MetaRobotsNofollow,
XRobotsTagNofollow,
}
impl fmt::Display for Reason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Reason::RelNofollow => "rel=\"nofollow\"",
Reason::RelUgc => "rel=\"ugc\"",
Reason::RelSponsored => "rel=\"sponsored\"",
Reason::MetaRobotsNofollow => "meta robots nofollow",
Reason::XRobotsTagNofollow => "X-Robots-Tag nofollow",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkAudit {
pub rel: Rel,
pub page: RobotsDirectives,
pub reason: Option<Reason>,
pub page_indexable: bool,
}
impl LinkAudit {
pub fn followed(&self) -> bool {
self.reason.is_none()
}
}
impl fmt::Display for LinkAudit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.reason {
None => f.write_str("dofollow"),
Some(r) => write!(f, "nofollow ({r})"),
}
}
}
pub fn audit_link(rel: Option<&str>, page: &PageDirectives, agent: Option<&str>) -> LinkAudit {
let rel = Rel::parse_opt(rel);
let effective = page.effective(agent);
let reason = match rel.withholding_token() {
Some("nofollow") => Some(Reason::RelNofollow),
Some("ugc") => Some(Reason::RelUgc),
Some("sponsored") => Some(Reason::RelSponsored),
_ => {
let meta_nofollow = page.meta.map(|m| m.nofollow).unwrap_or(false);
if meta_nofollow {
Some(Reason::MetaRobotsNofollow)
} else if effective.nofollow {
Some(Reason::XRobotsTagNofollow)
} else {
None
}
}
};
LinkAudit {
rel,
page: effective,
reason,
page_indexable: effective.indexable(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absent_rel_follows() {
let rel = Rel::parse_opt(None);
assert!(rel.follows());
assert!(rel.tokens().is_empty());
}
#[test]
fn noopener_and_noreferrer_still_follow() {
assert!(Rel::parse("noopener noreferrer").follows());
assert!(Rel::parse(" noopener noreferrer ").follows());
assert!(Rel::parse("me").follows());
}
#[test]
fn the_three_withholding_tokens() {
assert_eq!(Rel::parse("nofollow").withholding_token(), Some("nofollow"));
assert_eq!(Rel::parse("ugc").withholding_token(), Some("ugc"));
assert_eq!(
Rel::parse("sponsored").withholding_token(),
Some("sponsored")
);
}
#[test]
fn rel_parsing_is_case_insensitive_and_comma_tolerant() {
let rel = Rel::parse("NOOPENER,NoFollow");
assert!(rel.has("nofollow"));
assert!(rel.has("NOOPENER"));
assert!(!rel.follows());
assert_eq!(rel.to_string(), "noopener nofollow");
}
#[test]
fn nofollow_substrings_are_not_matched() {
assert!(Rel::parse("nofollowers").follows());
assert!(Rel::parse("x-nofollow").follows());
}
#[test]
fn robots_none_means_both() {
let d = RobotsDirectives::parse("none");
assert!(d.noindex && d.nofollow);
assert!(!d.indexable());
assert_eq!(d.to_string(), "noindex, nofollow");
}
#[test]
fn robots_positives_set_nothing() {
let d = RobotsDirectives::parse("index, follow, all");
assert_eq!(d, RobotsDirectives::default());
assert!(d.indexable());
assert_eq!(d.to_string(), "index, follow");
}
#[test]
fn robots_restrictive_token_wins_a_contradiction() {
assert!(RobotsDirectives::parse("index, noindex").noindex);
assert!(RobotsDirectives::parse("follow, nofollow").nofollow);
}
#[test]
fn robots_ignores_valued_directives() {
let d = RobotsDirectives::parse("max-snippet:-1, max-image-preview:large, noindex");
assert!(d.noindex);
assert!(!d.nofollow);
}
#[test]
fn robots_merge_is_most_restrictive() {
let a = RobotsDirectives::parse("noindex");
let b = RobotsDirectives::parse("nofollow");
let merged = a.merge(b);
assert!(merged.noindex && merged.nofollow);
}
#[test]
fn x_robots_tag_user_agent_prefix() {
let tag = XRobotsTag::parse("googlebot: noindex, nofollow");
assert_eq!(tag.user_agent.as_deref(), Some("googlebot"));
assert!(tag.directives.noindex && tag.directives.nofollow);
assert!(tag.applies_to(Some("GoogleBot")));
assert!(!tag.applies_to(Some("bingbot")));
assert!(!tag.applies_to(None));
}
#[test]
fn x_robots_tag_without_prefix_applies_to_everyone() {
let tag = XRobotsTag::parse("nofollow");
assert_eq!(tag.user_agent, None);
assert!(tag.applies_to(None));
assert!(tag.applies_to(Some("anything")));
}
#[test]
fn x_robots_tag_valued_directive_is_not_a_user_agent() {
let tag = XRobotsTag::parse("max-snippet:-1, nofollow");
assert_eq!(tag.user_agent, None);
assert!(tag.directives.nofollow);
let tag = XRobotsTag::parse("unavailable_after: 2030-01-01, noindex");
assert_eq!(tag.user_agent, None);
assert!(tag.directives.noindex);
}
#[test]
fn a_clean_page_and_a_bare_anchor_is_dofollow() {
let page = PageDirectives::new();
let verdict = audit_link(None, &page, None);
assert!(verdict.followed());
assert!(verdict.page_indexable);
assert_eq!(verdict.to_string(), "dofollow");
}
#[test]
fn rel_is_reported_before_page_level_signals() {
let page = PageDirectives::new().with_x_robots_tag("nofollow");
let verdict = audit_link(Some("sponsored"), &page, None);
assert_eq!(verdict.reason, Some(Reason::RelSponsored));
assert_eq!(verdict.to_string(), "nofollow (rel=\"sponsored\")");
}
#[test]
fn header_nofollow_beats_an_innocent_anchor() {
let page = PageDirectives::new().with_x_robots_tag("nofollow");
let verdict = audit_link(Some("noopener"), &page, None);
assert!(!verdict.followed());
assert_eq!(verdict.reason, Some(Reason::XRobotsTagNofollow));
}
#[test]
fn meta_nofollow_is_distinguished_from_header_nofollow() {
let page = PageDirectives::new().with_meta_robots("nofollow");
assert_eq!(
audit_link(None, &page, None).reason,
Some(Reason::MetaRobotsNofollow)
);
}
#[test]
fn noindex_does_not_withhold_the_follow() {
let page = PageDirectives::new().with_meta_robots("noindex, follow");
let verdict = audit_link(None, &page, None);
assert!(verdict.followed());
assert!(!verdict.page_indexable);
assert!(!page.indexable(None));
}
#[test]
fn scoped_headers_only_bind_their_agent() {
let page = PageDirectives::new().with_x_robots_tag("bingbot: nofollow");
assert!(audit_link(None, &page, None).followed());
assert!(audit_link(None, &page, Some("googlebot")).followed());
assert!(!audit_link(None, &page, Some("bingbot")).followed());
}
#[test]
fn multiple_headers_are_merged() {
let page = PageDirectives::new()
.with_x_robots_tag("noindex")
.with_x_robots_tag("googlebot: nofollow");
let all = page.effective(None);
assert!(all.noindex && !all.nofollow);
let google = page.effective(Some("googlebot"));
assert!(google.noindex && google.nofollow);
}
#[test]
fn meta_and_header_combine() {
let page = PageDirectives::new()
.with_meta_robots("noindex")
.with_x_robots_tag("nofollow");
let verdict = audit_link(None, &page, None);
assert!(!verdict.followed());
assert!(!verdict.page_indexable);
assert_eq!(verdict.page.to_string(), "noindex, nofollow");
}
#[test]
fn reasons_render_as_an_auditor_would_quote_them() {
assert_eq!(Reason::RelUgc.to_string(), "rel=\"ugc\"");
assert_eq!(
Reason::XRobotsTagNofollow.to_string(),
"X-Robots-Tag nofollow"
);
}
}