Skip to main content

ssg/plugins/postprocess/agentic_discovery/
agents_txt.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! `agents.txt` emitter — robots.txt-shaped allow/disallow rules per
5//! AI agent identifier.
6//!
7//! Output layout (AC1):
8//!
9//! ```text
10//! User-agent: *
11//! Allow: /
12//! Disallow: /private/
13//! Sitemap: https://example.com/sitemap.xml
14//!
15//! User-agent: GPTBot
16//! Allow: /blog/*
17//! Disallow: /
18//! ```
19//!
20//! The default `User-agent: *` block is overridable via
21//! `[agents.default_rule]`; per-agent overrides land via
22//! `[agents.rules.<id>]`.
23
24use super::{AgentRule, AgentsConfig};
25use crate::error::{PathErrorExt, SsgError};
26use crate::plugin::PluginContext;
27use std::fs;
28
29/// Render and write the `agents.txt` file under `ctx.site_dir`.
30///
31/// # Errors
32///
33/// Returns [`SsgError::Io`] if `agents.txt` cannot be written (e.g.
34/// the site directory is read-only or full).
35///
36/// # Examples
37///
38/// ```
39/// use ssg::plugin::PluginContext;
40/// use ssg::postprocess::agentic_discovery::{AgentsConfig, write_agents_txt};
41/// use std::fs;
42/// let tmp = tempfile::tempdir().unwrap();
43/// let ctx = PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
44/// let cfg = AgentsConfig::default();
45/// write_agents_txt(&ctx, &cfg).unwrap();
46/// let body = fs::read_to_string(tmp.path().join("agents.txt")).unwrap();
47/// assert!(body.contains("User-agent: *"));
48/// ```
49pub 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/// Pure-function renderer kept separate from I/O so unit tests can
66/// assert the exact byte layout without touching the filesystem.
67///
68/// # Examples
69///
70/// ```
71/// use ssg::postprocess::agentic_discovery::{AgentsConfig, render_agents_txt};
72/// let cfg = AgentsConfig::default();
73/// let body = render_agents_txt(&cfg, "https://example.com");
74/// assert!(body.contains("User-agent: *"));
75/// assert!(body.contains("Sitemap: https://example.com/sitemap.xml"));
76/// ```
77#[must_use]
78pub fn render_agents_txt(agents: &AgentsConfig, base_url: &str) -> String {
79    let mut out = String::new();
80
81    // ── Default block (User-agent: *) ───────────────────────────────
82    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        // AC1 defaults — overridable but always present so consumers
87        // get a sensible policy out of the box.
88        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    // ── Per-agent blocks ────────────────────────────────────────────
98    //
99    // HashMap iteration order is non-deterministic; sort by canonical
100    // agent identifier so consumers (and our golden tests) see a
101    // stable byte layout across builds.
102    let mut ids: Vec<&String> = agents.rules.keys().collect();
103    ids.sort();
104    for id in ids {
105        // Empty IDs would render as `User-agent: ` which is a parse
106        // failure for downstream consumers — drop them silently
107        // rather than emitting broken stanzas.
108        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
122/// Render the `Allow:` / `Disallow:` lines for a single rule, in the
123/// order specified by the issue (allow first, disallow second).
124fn 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
137/// Map TOML-friendly lowercase identifiers (`gptbot`, `claudebot`,
138/// `perplexitybot`) to the canonical mixed-case form most agent
139/// runtimes ship under (e.g. `GPTBot`, `ClaudeBot`).
140///
141/// Falls back to a title-case transform for unknown agents so the
142/// output is never just the raw lowercase ID.
143fn 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 fallback: "fooBot" → "Foobot", preserving inner
163        // hyphens. Good enough for unknown agents; site authors who
164        // want exact casing can extend the match arms above.
165        _ => title_case_agent_id(id),
166    }
167}
168
169fn title_case_agent_id(id: &str) -> String {
170    // Title-case each `-`-separated segment so identifiers like
171    // `my-custom-bot` render as `My-Custom-Bot`.
172    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        // AC1: with no overrides we still emit the User-agent: *
210        // stanza plus the sitemap line.
211        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        // If the site has no base URL configured we omit the
222        // Sitemap line rather than emit a broken `Sitemap: /sitemap.xml`.
223        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        // AC2: the gptbot rule must be emitted with the canonical
231        // `GPTBot` casing the real crawler ships under.
232        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        // HashMap iteration is non-deterministic; we sort so the
245        // output is byte-stable across builds. Without sorting, the
246        // golden tests above would flake.
247        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        // Sorted by lowercase key: ccbot, gptbot, perplexitybot.
258        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        // AC2 explicitly mandates "allow/disallow lines in the
268        // correct order" — Allow before Disallow per robots.txt
269        // convention.
270        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        // Site author supplied an explicit default_rule — the baked-in
287        // Allow: / + Disallow: /private/ pair must NOT be emitted.
288        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        // Title-case keeps each `-` segment capitalised so identifiers
304        // like `my-custom-bot` come out readable.
305        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        // Pin the well-known crawler identifiers so reformats and
312        // accidental deletions are caught by the unit suite.
313        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        // An empty or whitespace key would render as `User-agent: `
323        // which is unparseable downstream — drop the stanza.
324        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        // Only the default `*` stanza should be present.
329        assert_eq!(txt.matches("User-agent:").count(), 1);
330    }
331
332    #[test]
333    fn trims_trailing_slash_in_sitemap_url() {
334        // base_url comes from SsgConfig — the renderer is fed an
335        // already-trimmed URL by `write_agents_txt`, but we still
336        // exercise the contract here.
337        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        // Pin the remaining well-known identifiers not covered above.
346        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        // `my--bot` has an empty middle segment; it must round-trip
368        // without panicking and keep the double hyphen.
369        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}