ssg/plugins/postprocess/agentic_discovery/
agents_txt.rs1use super::{AgentRule, AgentsConfig};
25use crate::error::{PathErrorExt, SsgError};
26use crate::plugin::PluginContext;
27use std::fs;
28
29pub fn write_agents_txt(
50 ctx: &PluginContext,
51 agents: &AgentsConfig,
52) -> Result<(), SsgError> {
53 let base_url = ctx
54 .config
55 .as_ref()
56 .map(|c| c.base_url.trim_end_matches('/').to_string())
57 .unwrap_or_default();
58
59 let body = render_agents_txt(agents, &base_url);
60 let path = ctx.site_dir.join("agents.txt");
61 fs::write(&path, body).with_path(&path)?;
62 Ok(())
63}
64
65#[must_use]
78pub fn render_agents_txt(agents: &AgentsConfig, base_url: &str) -> String {
79 let mut out = String::new();
80
81 out.push_str("User-agent: *\n");
83 if let Some(ref default_rule) = agents.default_rule {
84 render_rule_body(default_rule, &mut out);
85 } else {
86 out.push_str("Allow: /\n");
89 out.push_str("Disallow: /private/\n");
90 }
91 if !base_url.is_empty() {
92 out.push_str("Sitemap: ");
93 out.push_str(base_url);
94 out.push_str("/sitemap.xml\n");
95 }
96
97 let mut ids: Vec<&String> = agents.rules.keys().collect();
103 ids.sort();
104 for id in ids {
105 if id.trim().is_empty() {
109 continue;
110 }
111 let rule = &agents.rules[id];
112 out.push('\n');
113 out.push_str("User-agent: ");
114 out.push_str(&canonicalise_agent_id(id));
115 out.push('\n');
116 render_rule_body(rule, &mut out);
117 }
118
119 out
120}
121
122fn render_rule_body(rule: &AgentRule, out: &mut String) {
125 for path in &rule.allow {
126 out.push_str("Allow: ");
127 out.push_str(path);
128 out.push('\n');
129 }
130 for path in &rule.disallow {
131 out.push_str("Disallow: ");
132 out.push_str(path);
133 out.push('\n');
134 }
135}
136
137fn canonicalise_agent_id(id: &str) -> String {
144 match id.to_ascii_lowercase().as_str() {
145 "gptbot" => "GPTBot".to_string(),
146 "chatgpt-user" => "ChatGPT-User".to_string(),
147 "oai-searchbot" => "OAI-SearchBot".to_string(),
148 "anthropic-ai" => "Anthropic-AI".to_string(),
149 "claudebot" => "ClaudeBot".to_string(),
150 "claude-web" => "Claude-Web".to_string(),
151 "perplexitybot" => "PerplexityBot".to_string(),
152 "google-extended" => "Google-Extended".to_string(),
153 "googleother" => "GoogleOther".to_string(),
154 "bingbot" => "Bingbot".to_string(),
155 "ccbot" => "CCBot".to_string(),
156 "applebot-extended" => "Applebot-Extended".to_string(),
157 "facebookbot" => "FacebookBot".to_string(),
158 "meta-externalagent" => "Meta-ExternalAgent".to_string(),
159 "amazonbot" => "Amazonbot".to_string(),
160 "diffbot" => "Diffbot".to_string(),
161 "cohere-ai" => "Cohere-AI".to_string(),
162 _ => title_case_agent_id(id),
166 }
167}
168
169fn title_case_agent_id(id: &str) -> String {
170 id.split('-')
173 .map(|seg| {
174 let mut chars = seg.chars();
175 match chars.next() {
176 Some(first) => {
177 let mut s = String::new();
178 for c in first.to_uppercase() {
179 s.push(c);
180 }
181 for c in chars {
182 for lower in c.to_lowercase() {
183 s.push(lower);
184 }
185 }
186 s
187 }
188 None => String::new(),
189 }
190 })
191 .collect::<Vec<_>>()
192 .join("-")
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use std::collections::HashMap;
199
200 fn rule(allow: &[&str], disallow: &[&str]) -> AgentRule {
201 AgentRule {
202 allow: allow.iter().map(|s| (*s).to_string()).collect(),
203 disallow: disallow.iter().map(|s| (*s).to_string()).collect(),
204 }
205 }
206
207 #[test]
208 fn renders_default_block_when_no_overrides() {
209 let cfg = AgentsConfig::default();
212 let txt = render_agents_txt(&cfg, "https://example.com");
213 assert!(txt.contains("User-agent: *\n"));
214 assert!(txt.contains("Allow: /\n"));
215 assert!(txt.contains("Disallow: /private/\n"));
216 assert!(txt.contains("Sitemap: https://example.com/sitemap.xml\n"));
217 }
218
219 #[test]
220 fn omits_sitemap_when_base_url_empty() {
221 let cfg = AgentsConfig::default();
224 let txt = render_agents_txt(&cfg, "");
225 assert!(!txt.contains("Sitemap:"));
226 }
227
228 #[test]
229 fn renders_per_agent_rule_in_canonical_case() {
230 let mut cfg = AgentsConfig::default();
233 let _ = cfg
234 .rules
235 .insert("gptbot".to_string(), rule(&["/blog/*"], &["/"]));
236 let txt = render_agents_txt(&cfg, "https://example.com");
237 assert!(txt.contains("User-agent: GPTBot\n"));
238 assert!(txt.contains("Allow: /blog/*\n"));
239 assert!(txt.contains("Disallow: /\n"));
240 }
241
242 #[test]
243 fn per_agent_rules_render_in_sorted_order() {
244 let mut rules: HashMap<String, AgentRule> = HashMap::new();
248 let _ = rules.insert("perplexitybot".to_string(), rule(&[], &["/"]));
249 let _ = rules.insert("gptbot".to_string(), rule(&["/blog/*"], &[]));
250 let _ = rules.insert("ccbot".to_string(), rule(&[], &["/"]));
251 let cfg = AgentsConfig {
252 agents_txt: true,
253 rules,
254 ..AgentsConfig::default()
255 };
256 let txt = render_agents_txt(&cfg, "https://x.example");
257 let ccb = txt.find("User-agent: CCBot").unwrap();
259 let gpt = txt.find("User-agent: GPTBot").unwrap();
260 let pxy = txt.find("User-agent: PerplexityBot").unwrap();
261 assert!(ccb < gpt, "ccbot stanza must precede gptbot");
262 assert!(gpt < pxy, "gptbot stanza must precede perplexitybot");
263 }
264
265 #[test]
266 fn allow_lines_precede_disallow_lines() {
267 let mut cfg = AgentsConfig::default();
271 let _ = cfg
272 .rules
273 .insert("gptbot".to_string(), rule(&["/a", "/b"], &["/c", "/d"]));
274 let txt = render_agents_txt(&cfg, "https://x.example");
275 let a_pos = txt.find("Allow: /a").unwrap();
276 let b_pos = txt.find("Allow: /b").unwrap();
277 let c_pos = txt.find("Disallow: /c").unwrap();
278 let d_pos = txt.find("Disallow: /d").unwrap();
279 assert!(a_pos < b_pos);
280 assert!(b_pos < c_pos);
281 assert!(c_pos < d_pos);
282 }
283
284 #[test]
285 fn default_rule_override_replaces_defaults() {
286 let cfg = AgentsConfig {
289 default_rule: Some(rule(&["/public/*"], &["/admin/*"])),
290 ..AgentsConfig::default()
291 };
292 let txt = render_agents_txt(&cfg, "https://x.example");
293 assert!(txt.contains("Allow: /public/*"));
294 assert!(txt.contains("Disallow: /admin/*"));
295 assert!(
296 !txt.contains("Disallow: /private/"),
297 "baked-in default must not leak through when an override exists"
298 );
299 }
300
301 #[test]
302 fn canonicalise_unknown_agent_falls_back_to_title_case() {
303 assert_eq!(canonicalise_agent_id("my-custom-bot"), "My-Custom-Bot");
306 assert_eq!(canonicalise_agent_id("foobot"), "Foobot");
307 }
308
309 #[test]
310 fn known_agents_match_canonical_case() {
311 assert_eq!(canonicalise_agent_id("gptbot"), "GPTBot");
314 assert_eq!(canonicalise_agent_id("claudebot"), "ClaudeBot");
315 assert_eq!(canonicalise_agent_id("anthropic-ai"), "Anthropic-AI");
316 assert_eq!(canonicalise_agent_id("perplexitybot"), "PerplexityBot");
317 assert_eq!(canonicalise_agent_id("google-extended"), "Google-Extended");
318 }
319
320 #[test]
321 fn empty_agent_id_is_skipped() {
322 let mut cfg = AgentsConfig::default();
325 let _ = cfg.rules.insert(String::new(), rule(&[], &["/"]));
326 let _ = cfg.rules.insert(" ".to_string(), rule(&[], &["/"]));
327 let txt = render_agents_txt(&cfg, "https://x.example");
328 assert_eq!(txt.matches("User-agent:").count(), 1);
330 }
331
332 #[test]
333 fn trims_trailing_slash_in_sitemap_url() {
334 let cfg = AgentsConfig::default();
338 let txt = render_agents_txt(&cfg, "https://example.com");
339 assert!(txt.contains("Sitemap: https://example.com/sitemap.xml"));
340 assert!(!txt.contains("//sitemap.xml"));
341 }
342
343 #[test]
344 fn all_known_agent_ids_map_to_canonical_case() {
345 let expected = [
347 ("chatgpt-user", "ChatGPT-User"),
348 ("oai-searchbot", "OAI-SearchBot"),
349 ("claude-web", "Claude-Web"),
350 ("googleother", "GoogleOther"),
351 ("bingbot", "Bingbot"),
352 ("ccbot", "CCBot"),
353 ("applebot-extended", "Applebot-Extended"),
354 ("facebookbot", "FacebookBot"),
355 ("meta-externalagent", "Meta-ExternalAgent"),
356 ("amazonbot", "Amazonbot"),
357 ("diffbot", "Diffbot"),
358 ("cohere-ai", "Cohere-AI"),
359 ];
360 for (id, canonical) in expected {
361 assert_eq!(canonicalise_agent_id(id), canonical, "agent {id}");
362 }
363 }
364
365 #[test]
366 fn title_case_handles_empty_segments() {
367 assert_eq!(canonicalise_agent_id("my--bot"), "My--Bot");
370 }
371
372 #[test]
373 fn write_agents_txt_errors_when_target_is_a_directory() {
374 use crate::plugin::PluginContext;
375 let tmp = tempfile::tempdir().unwrap();
376 fs::create_dir_all(tmp.path().join("agents.txt")).unwrap();
377 let ctx =
378 PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
379 let cfg = AgentsConfig::default();
380 let err = write_agents_txt(&ctx, &cfg).unwrap_err();
381 assert!(format!("{err}").contains("agents.txt"));
382 }
383}