///|
/// RoboPolicy parses robots.txt documents and answers crawler access decisions.
/// It is designed for deterministic CI checks, site audits, and Wasm tools.
pub(all) enum IssueKind {
MissingColon
EmptyUserAgent
EmptyDirective
InvalidCrawlDelay
RuleBeforeUserAgent
UnknownDirective
} derive(Eq, Debug)
///|
pub(all) enum DirectiveKind {
UserAgent
Allow
Disallow
Sitemap
CrawlDelay
Host
Unknown
} derive(Eq, Debug)
///|
pub(all) enum FindingSeverity {
Error
Warning
Info
} derive(Eq, Debug)
///|
pub(all) struct ParseIssue {
kind : IssueKind
line : Int
directive : String
value : String
message : String
} derive(Eq, Debug)
///|
pub(all) struct Rule {
allow : Bool
pattern : String
line : Int
} derive(Eq, Debug)
///|
pub(all) struct AgentGroup {
agents : Array[String]
rules : Array[Rule]
crawl_delay : Int?
line : Int
} derive(Eq, Debug)
///|
pub(all) struct RobotsPolicy {
groups : Array[AgentGroup]
sitemaps : Array[String]
host : String?
issues : Array[ParseIssue]
} derive(Eq, Debug)
///|
pub(all) struct AccessDecision {
allowed : Bool
user_agent : String
path : String
matched_group : String
matched_rule : Rule?
reason : String
} derive(Eq, Debug)
///|
pub(all) struct Finding {
code : String
severity : FindingSeverity
message : String
detail : String
} derive(Eq, Debug)
///|
pub(all) struct AuditReport {
policy : RobotsPolicy
findings : Array[Finding]
} derive(Eq, Debug)
///|
pub fn IssueKind::label(self : IssueKind) -> String {
match self {
MissingColon => "missing-colon"
EmptyUserAgent => "empty-user-agent"
EmptyDirective => "empty-directive"
InvalidCrawlDelay => "invalid-crawl-delay"
RuleBeforeUserAgent => "rule-before-user-agent"
UnknownDirective => "unknown-directive"
}
}
///|
pub fn DirectiveKind::label(self : DirectiveKind) -> String {
match self {
UserAgent => "user-agent"
Allow => "allow"
Disallow => "disallow"
Sitemap => "sitemap"
CrawlDelay => "crawl-delay"
Host => "host"
Unknown => "unknown"
}
}
///|
pub fn FindingSeverity::label(self : FindingSeverity) -> String {
match self {
Error => "error"
Warning => "warning"
Info => "info"
}
}
///|
pub fn parse(raw : StringView) -> RobotsPolicy {
let groups : Array[AgentGroup] = []
let sitemaps : Array[String] = []
let issues : Array[ParseIssue] = []
let mut host : String? = None
let mut current_agents : Array[String] = []
let mut current_rules : Array[Rule] = []
let mut current_delay : Int? = None
let mut current_line = 0
let mut group_has_rules = false
for offset, line in raw.split("\n").iter2() {
let line_number = offset + 1
let cleaned = strip_comment(line).trim()
if cleaned.is_empty() {
continue
}
match cleaned.split_once(":") {
Some((name_view, value_view)) => {
let name = name_view.trim().to_lower()
let value = value_view.trim().to_owned()
if name.is_empty() {
issues.push(
issue(
EmptyDirective,
line_number,
name,
value,
"directive name must not be empty",
),
)
continue
}
match directive_kind(name) {
UserAgent => {
if value.is_empty() {
issues.push(
issue(
EmptyUserAgent,
line_number,
name,
value,
"user-agent value must not be empty",
),
)
continue
}
if group_has_rules && !current_agents.is_empty() {
groups.push({
agents: current_agents,
rules: current_rules,
crawl_delay: current_delay,
line: current_line,
})
current_agents = []
current_rules = []
current_delay = None
group_has_rules = false
}
if current_agents.is_empty() {
current_line = line_number
}
current_agents.push(normalize_agent(value))
}
Allow =>
if current_agents.is_empty() {
issues.push(
issue(
RuleBeforeUserAgent,
line_number,
name,
value,
"allow rule appeared before any user-agent group",
),
)
} else {
current_rules.push({
allow: true,
pattern: value,
line: line_number,
})
group_has_rules = true
}
Disallow =>
if current_agents.is_empty() {
issues.push(
issue(
RuleBeforeUserAgent,
line_number,
name,
value,
"disallow rule appeared before any user-agent group",
),
)
} else if !value.is_empty() {
current_rules.push({
allow: false,
pattern: value,
line: line_number,
})
group_has_rules = true
} else {
group_has_rules = true
}
CrawlDelay =>
if current_agents.is_empty() {
issues.push(
issue(
RuleBeforeUserAgent,
line_number,
name,
value,
"crawl-delay appeared before any user-agent group",
),
)
} else {
match parse_non_negative_int(value) {
Some(delay) => {
current_delay = Some(delay)
group_has_rules = true
}
None =>
issues.push(
issue(
InvalidCrawlDelay,
line_number,
name,
value,
"crawl-delay must be a non-negative integer",
),
)
}
}
Sitemap =>
if value.is_empty() {
issues.push(
issue(
EmptyDirective,
line_number,
name,
value,
"sitemap value must not be empty",
),
)
} else {
sitemaps.push(value)
}
Host => if !value.is_empty() { host = Some(value) }
Unknown =>
issues.push(
issue(
UnknownDirective,
line_number,
name,
value,
"directive is not part of the supported robots.txt subset",
),
)
}
}
None =>
issues.push(
issue(
MissingColon,
line_number,
"",
cleaned.to_owned(),
"robots.txt directives must use 'name: value' syntax",
),
)
}
}
if !current_agents.is_empty() {
groups.push({
agents: current_agents,
rules: current_rules,
crawl_delay: current_delay,
line: current_line,
})
}
{ groups, sitemaps, host, issues }
}
///|
pub fn RobotsPolicy::is_allowed(
self : RobotsPolicy,
user_agent : StringView,
url_or_path : StringView,
) -> Bool {
self.decide(user_agent, url_or_path).allowed
}
///|
pub fn RobotsPolicy::decide(
self : RobotsPolicy,
user_agent : StringView,
url_or_path : StringView,
) -> AccessDecision {
let path = extract_path(url_or_path)
match best_group(self.groups, user_agent) {
Some(group) => {
let rule = best_rule(group.rules, path)
match rule {
Some(r) =>
{
allowed: r.allow,
user_agent: user_agent.to_owned(),
path,
matched_group: group.agents.join(","),
matched_rule: Some(r),
reason: if r.allow {
"allowed by longest matching allow rule"
} else {
"blocked by longest matching disallow rule"
},
}
None =>
{
allowed: true,
user_agent: user_agent.to_owned(),
path,
matched_group: group.agents.join(","),
matched_rule: None,
reason: "no rule matched in selected group",
}
}
}
None =>
{
allowed: true,
user_agent: user_agent.to_owned(),
path,
matched_group: "",
matched_rule: None,
reason: "no matching user-agent group",
}
}
}
///|
pub fn RobotsPolicy::crawl_delay_for(
self : RobotsPolicy,
user_agent : StringView,
) -> Int? {
match best_group(self.groups, user_agent) {
Some(group) => group.crawl_delay
None => None
}
}
///|
pub fn audit(policy : RobotsPolicy) -> AuditReport {
let findings : Array[Finding] = []
for issue in policy.issues {
findings.push({
code: "parse." + issue.kind.label(),
severity: if issue.kind == UnknownDirective {
Info
} else {
Warning
},
message: issue.message,
detail: "line " + issue.line.to_string() + ": " + issue.directive,
})
}
if policy.groups.is_empty() {
findings.push({
code: "policy.no-user-agent",
severity: Error,
message: "robots.txt does not define any user-agent group",
detail: "crawlers will treat the policy as allow-all",
})
}
if policy.sitemaps.is_empty() {
findings.push({
code: "policy.no-sitemap",
severity: Info,
message: "no sitemap directive was found",
detail: "adding sitemap helps crawlers discover canonical URLs",
})
}
for group in policy.groups {
if group.agents.contains("*") && group.rules.is_empty() {
findings.push({
code: "policy.wildcard-empty",
severity: Info,
message: "wildcard group contains no effective allow/disallow rule",
detail: "line " + group.line.to_string(),
})
}
if blocks_everything(group) {
findings.push({
code: "policy.block-all",
severity: Warning,
message: "a user-agent group blocks the entire site",
detail: group.agents.join(","),
})
}
if group.crawl_delay is Some(delay) && delay > 60 {
findings.push({
code: "policy.high-crawl-delay",
severity: Info,
message: "crawl-delay is higher than 60 seconds",
detail: group.agents.join(","),
})
}
}
if findings.is_empty() {
findings.push({
code: "policy.ok",
severity: Info,
message: "robots.txt parsed successfully and no blocking issue was found",
detail: policy.groups.length().to_string() + " group(s)",
})
}
{ policy, findings }
}
///|
pub fn AuditReport::has_finding(self : AuditReport, code : StringView) -> Bool {
let expected = code.to_owned()
self.findings.any(f => f.code == expected)
}
///|
pub fn AuditReport::to_markdown(self : AuditReport) -> String {
let lines : Array[String] = []
lines.push("# RoboPolicy Audit")
lines.push("")
lines.push("- Groups: " + self.policy.groups.length().to_string())
lines.push("- Sitemaps: " + self.policy.sitemaps.length().to_string())
lines.push("- Findings: " + self.findings.length().to_string())
lines.push("")
lines.push("| Severity | Code | Message |")
lines.push("| --- | --- | --- |")
for finding in self.findings {
lines.push(
"| " +
finding.severity.label() +
" | " +
finding.code +
" | " +
finding.message.replace_all(old="|", new="\\|") +
" |",
)
}
lines.join("\n")
}
///|
pub fn AccessDecision::to_line(self : AccessDecision) -> String {
let status = if self.allowed { "ALLOW" } else { "BLOCK" }
status + " " + self.user_agent + " " + self.path + " - " + self.reason
}
///|
pub fn sample_robots() -> String {
[
"User-agent: *", "Disallow: /admin/", "Disallow: /tmp/*.json$", "Allow: /tmp/public.json",
"Crawl-delay: 5", "", "User-agent: ResearchBot", "Disallow: /private/", "Allow: /private/summary.html",
"Sitemap: https://example.test/sitemap.xml",
].join("\n")
}
///|
pub fn sample_audit_markdown() -> String {
audit(parse(sample_robots())).to_markdown()
}
///|
fn directive_kind(name : StringView) -> DirectiveKind {
match name {
"user-agent" => UserAgent
"allow" => Allow
"disallow" => Disallow
"sitemap" => Sitemap
"crawl-delay" => CrawlDelay
"host" => Host
_ => Unknown
}
}
///|
fn strip_comment(line : StringView) -> StringView {
match line.split_once("#") {
Some((left, _)) => left
None => line
}
}
///|
fn normalize_agent(agent : StringView) -> String {
agent.trim().to_lower().to_owned()
}
///|
fn issue(
kind : IssueKind,
line : Int,
directive : StringView,
value : String,
message : String,
) -> ParseIssue {
{ kind, line, directive: directive.to_owned(), value, message }
}
///|
fn parse_non_negative_int(value : StringView) -> Int? {
if !value.is_empty() && value.all(ch => ch >= '0' && ch <= '9') {
try @string.parse_int(value) |> Some catch {
_ => None
}
} else {
None
}
}
///|
fn best_group(
groups : Array[AgentGroup],
user_agent : StringView,
) -> AgentGroup? {
let agent = normalize_agent(user_agent)
let mut best : AgentGroup? = None
let mut best_len = -1
for group in groups {
for token in group.agents {
let score = if token == "*" {
0
} else if agent.contains(token) {
token.length()
} else {
-1
}
if score >= 0 && score > best_len {
best = Some(group)
best_len = score
}
}
}
best
}
///|
fn best_rule(rules : Array[Rule], path : String) -> Rule? {
let mut best : Rule? = None
let mut best_len = -1
for rule in rules {
if rule_matches(rule.pattern, path) {
let length = pattern_weight(rule.pattern)
let replace = length > best_len ||
(length == best_len && rule.allow && best is Some(old) && !old.allow)
if replace {
best = Some(rule)
best_len = length
}
}
}
best
}
///|
fn rule_matches(pattern : String, path : String) -> Bool {
if pattern.is_empty() {
false
} else if pattern.has_suffix("$") {
let body = pattern.strip_suffix("$").unwrap().to_owned()
wildcard_match(body, path, anchored_end=true)
} else {
wildcard_match(pattern, path, anchored_end=false)
}
}
///|
fn wildcard_match(
pattern : String,
path : String,
anchored_end~ : Bool,
) -> Bool {
wildcard_from(pattern, 0, path, 0, anchored_end)
}
///|
fn wildcard_from(
pattern : String,
pi : Int,
path : String,
si : Int,
anchored_end : Bool,
) -> Bool {
if pi >= pattern.length() {
if anchored_end {
si == path.length()
} else {
true
}
} else {
match pattern.get_char(pi) {
Some('*') => {
let mut cursor = si
while cursor <= path.length() {
if wildcard_from(pattern, pi + 1, path, cursor, anchored_end) {
return true
}
cursor = cursor + 1
}
false
}
Some(ch) =>
match path.get_char(si) {
Some(actual) =>
if ch == actual {
wildcard_from(pattern, pi + 1, path, si + 1, anchored_end)
} else {
false
}
None => false
}
None => false
}
}
}
///|
fn pattern_weight(pattern : String) -> Int {
let mut count = 0
for ch in pattern {
if ch != '*' && ch != '$' {
count = count + 1
}
}
count
}
///|
fn blocks_everything(group : AgentGroup) -> Bool {
group.rules.any(rule => !rule.allow && rule.pattern == "/")
}