use super::{AgentRule, AgentsConfig};
use crate::error::{PathErrorExt, SsgError};
use crate::plugin::PluginContext;
use std::fs;
pub fn write_agents_txt(
ctx: &PluginContext,
agents: &AgentsConfig,
) -> Result<(), SsgError> {
let base_url = ctx
.config
.as_ref()
.map(|c| c.base_url.trim_end_matches('/').to_string())
.unwrap_or_default();
let body = render_agents_txt(agents, &base_url);
let path = ctx.site_dir.join("agents.txt");
fs::write(&path, body).with_path(&path)?;
Ok(())
}
#[must_use]
pub fn render_agents_txt(agents: &AgentsConfig, base_url: &str) -> String {
let mut out = String::new();
out.push_str("User-agent: *\n");
if let Some(ref default_rule) = agents.default_rule {
render_rule_body(default_rule, &mut out);
} else {
out.push_str("Allow: /\n");
out.push_str("Disallow: /private/\n");
}
if !base_url.is_empty() {
out.push_str("Sitemap: ");
out.push_str(base_url);
out.push_str("/sitemap.xml\n");
}
let mut ids: Vec<&String> = agents.rules.keys().collect();
ids.sort();
for id in ids {
if id.trim().is_empty() {
continue;
}
let rule = &agents.rules[id];
out.push('\n');
out.push_str("User-agent: ");
out.push_str(&canonicalise_agent_id(id));
out.push('\n');
render_rule_body(rule, &mut out);
}
out
}
fn render_rule_body(rule: &AgentRule, out: &mut String) {
for path in &rule.allow {
out.push_str("Allow: ");
out.push_str(path);
out.push('\n');
}
for path in &rule.disallow {
out.push_str("Disallow: ");
out.push_str(path);
out.push('\n');
}
}
fn canonicalise_agent_id(id: &str) -> String {
match id.to_ascii_lowercase().as_str() {
"gptbot" => "GPTBot".to_string(),
"chatgpt-user" => "ChatGPT-User".to_string(),
"oai-searchbot" => "OAI-SearchBot".to_string(),
"anthropic-ai" => "Anthropic-AI".to_string(),
"claudebot" => "ClaudeBot".to_string(),
"claude-web" => "Claude-Web".to_string(),
"perplexitybot" => "PerplexityBot".to_string(),
"google-extended" => "Google-Extended".to_string(),
"googleother" => "GoogleOther".to_string(),
"bingbot" => "Bingbot".to_string(),
"ccbot" => "CCBot".to_string(),
"applebot-extended" => "Applebot-Extended".to_string(),
"facebookbot" => "FacebookBot".to_string(),
"meta-externalagent" => "Meta-ExternalAgent".to_string(),
"amazonbot" => "Amazonbot".to_string(),
"diffbot" => "Diffbot".to_string(),
"cohere-ai" => "Cohere-AI".to_string(),
_ => title_case_agent_id(id),
}
}
fn title_case_agent_id(id: &str) -> String {
id.split('-')
.map(|seg| {
let mut chars = seg.chars();
match chars.next() {
Some(first) => {
let mut s = String::new();
for c in first.to_uppercase() {
s.push(c);
}
for c in chars {
for lower in c.to_lowercase() {
s.push(lower);
}
}
s
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::collections::HashMap;
fn rule(allow: &[&str], disallow: &[&str]) -> AgentRule {
AgentRule {
allow: allow.iter().map(|s| (*s).to_string()).collect(),
disallow: disallow.iter().map(|s| (*s).to_string()).collect(),
}
}
#[test]
fn renders_default_block_when_no_overrides() {
let cfg = AgentsConfig::default();
let txt = render_agents_txt(&cfg, "https://example.com");
assert!(txt.contains("User-agent: *\n"));
assert!(txt.contains("Allow: /\n"));
assert!(txt.contains("Disallow: /private/\n"));
assert!(txt.contains("Sitemap: https://example.com/sitemap.xml\n"));
}
#[test]
fn omits_sitemap_when_base_url_empty() {
let cfg = AgentsConfig::default();
let txt = render_agents_txt(&cfg, "");
assert!(!txt.contains("Sitemap:"));
}
#[test]
fn renders_per_agent_rule_in_canonical_case() {
let mut cfg = AgentsConfig::default();
let _ = cfg
.rules
.insert("gptbot".to_string(), rule(&["/blog/*"], &["/"]));
let txt = render_agents_txt(&cfg, "https://example.com");
assert!(txt.contains("User-agent: GPTBot\n"));
assert!(txt.contains("Allow: /blog/*\n"));
assert!(txt.contains("Disallow: /\n"));
}
#[test]
fn per_agent_rules_render_in_sorted_order() {
let mut rules: HashMap<String, AgentRule> = HashMap::new();
let _ = rules.insert("perplexitybot".to_string(), rule(&[], &["/"]));
let _ = rules.insert("gptbot".to_string(), rule(&["/blog/*"], &[]));
let _ = rules.insert("ccbot".to_string(), rule(&[], &["/"]));
let cfg = AgentsConfig {
agents_txt: true,
rules,
..AgentsConfig::default()
};
let txt = render_agents_txt(&cfg, "https://x.example");
let ccb = txt.find("User-agent: CCBot").unwrap();
let gpt = txt.find("User-agent: GPTBot").unwrap();
let pxy = txt.find("User-agent: PerplexityBot").unwrap();
assert!(ccb < gpt, "ccbot stanza must precede gptbot");
assert!(gpt < pxy, "gptbot stanza must precede perplexitybot");
}
#[test]
fn allow_lines_precede_disallow_lines() {
let mut cfg = AgentsConfig::default();
let _ = cfg
.rules
.insert("gptbot".to_string(), rule(&["/a", "/b"], &["/c", "/d"]));
let txt = render_agents_txt(&cfg, "https://x.example");
let a_pos = txt.find("Allow: /a").unwrap();
let b_pos = txt.find("Allow: /b").unwrap();
let c_pos = txt.find("Disallow: /c").unwrap();
let d_pos = txt.find("Disallow: /d").unwrap();
assert!(a_pos < b_pos);
assert!(b_pos < c_pos);
assert!(c_pos < d_pos);
}
#[test]
fn default_rule_override_replaces_defaults() {
let cfg = AgentsConfig {
default_rule: Some(rule(&["/public/*"], &["/admin/*"])),
..AgentsConfig::default()
};
let txt = render_agents_txt(&cfg, "https://x.example");
assert!(txt.contains("Allow: /public/*"));
assert!(txt.contains("Disallow: /admin/*"));
assert!(
!txt.contains("Disallow: /private/"),
"baked-in default must not leak through when an override exists"
);
}
#[test]
fn canonicalise_unknown_agent_falls_back_to_title_case() {
assert_eq!(canonicalise_agent_id("my-custom-bot"), "My-Custom-Bot");
assert_eq!(canonicalise_agent_id("foobot"), "Foobot");
}
#[test]
fn known_agents_match_canonical_case() {
assert_eq!(canonicalise_agent_id("gptbot"), "GPTBot");
assert_eq!(canonicalise_agent_id("claudebot"), "ClaudeBot");
assert_eq!(canonicalise_agent_id("anthropic-ai"), "Anthropic-AI");
assert_eq!(canonicalise_agent_id("perplexitybot"), "PerplexityBot");
assert_eq!(canonicalise_agent_id("google-extended"), "Google-Extended");
}
#[test]
fn empty_agent_id_is_skipped() {
let mut cfg = AgentsConfig::default();
let _ = cfg.rules.insert(String::new(), rule(&[], &["/"]));
let _ = cfg.rules.insert(" ".to_string(), rule(&[], &["/"]));
let txt = render_agents_txt(&cfg, "https://x.example");
assert_eq!(txt.matches("User-agent:").count(), 1);
}
#[test]
fn trims_trailing_slash_in_sitemap_url() {
let cfg = AgentsConfig::default();
let txt = render_agents_txt(&cfg, "https://example.com");
assert!(txt.contains("Sitemap: https://example.com/sitemap.xml"));
assert!(!txt.contains("//sitemap.xml"));
}
}