Skip to main content

fast_robots/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2//! Fast, zero-copy parsing and matching for `robots.txt` files.
3//!
4//! `fast-robots` parses the standardized `User-agent`, `Allow`, and
5//! `Disallow` records used by crawlers, then evaluates paths using the RFC 9309
6//! matching rules: exact user-agent groups are preferred over `*`, the longest
7//! matching rule wins, and `Allow` wins ties.
8//!
9//! Parsed values borrow from the original input, so parsing avoids copying rule
10//! strings, user agents, and extension metadata. Keep the input string or byte
11//! buffer alive for as long as the returned [`RobotsTxt`] is used.
12//!
13//! # Quick Start
14//!
15//! ```
16//! use fast_robots::RobotsTxt;
17//!
18//! let robots = RobotsTxt::parse(
19//!     "User-agent: *\n\
20//!      Disallow: /private/\n\
21//!      Allow: /private/public/\n",
22//! );
23//!
24//! assert!(!robots.is_allowed("ExampleBot", "/private/file.html"));
25//! assert!(robots.is_allowed("ExampleBot", "/private/public/file.html"));
26//! ```
27//!
28//! # Fallible Byte Parsing
29//!
30//! Use the byte APIs when reading directly from files or HTTP responses. They
31//! reject invalid UTF-8 and inputs larger than [`DEFAULT_MAX_BYTES`] by default.
32//!
33//! ```
34//! # fn main() -> Result<(), fast_robots::ParseError> {
35//! use fast_robots::RobotsTxt;
36//!
37//! let robots = RobotsTxt::parse_bytes(b"User-agent: *\nDisallow: /tmp\n")?;
38//! assert!(!robots.is_allowed("ExampleBot", "/tmp/cache"));
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! # Diagnostics
44//!
45//! The parser is tolerant by default and ignores malformed lines it can recover
46//! from. Use diagnostics when you want validator-style warnings alongside the
47//! parsed rules.
48//!
49//! ```rust
50//! use fast_robots::{ParseWarningKind, RobotsTxt};
51//!
52//! let report = RobotsTxt::parse_with_diagnostics(
53//!     "Disallow: /\nMissing separator\nUser-agent: *\nDisallow: /private\n",
54//! );
55//!
56//! assert!(matches!(
57//!     report.warnings[0].kind,
58//!     ParseWarningKind::RuleBeforeUserAgent { .. }
59//! ));
60//! assert!(matches!(
61//!     report.warnings[1].kind,
62//!     ParseWarningKind::MissingSeparator { .. }
63//! ));
64//! assert!(!report.robots.is_allowed("ExampleBot", "/private"));
65//! ```
66//!
67//! # Extension Metadata
68//!
69//! With the default `extensions` feature, non-core directives such as `Sitemap`
70//! and `Crawl-delay` are preserved as metadata. Extension metadata never changes
71//! [`RobotsTxt::is_allowed`] decisions.
72//!
73//! ```rust
74//! # #[cfg(feature = "extensions")]
75//! # {
76//! use fast_robots::RobotsTxt;
77//!
78//! let robots = RobotsTxt::parse(
79//!     "Sitemap: https://example.com/sitemap.xml\n\
80//!      User-agent: SlowBot\n\
81//!      Crawl-delay: 5\n\
82//!      Disallow: /slow/\n",
83//! );
84//!
85//! assert_eq!(robots.extensions.sitemaps, ["https://example.com/sitemap.xml"]);
86//! assert_eq!(robots.extensions.crawl_delays[0].agents, ["SlowBot"]);
87//! assert!(!robots.is_allowed("SlowBot", "/slow/page.html"));
88//! # }
89//! ```
90
91use std::{collections::HashMap, str};
92
93use memchr::{memchr, memmem};
94use thiserror::Error;
95
96/// Default maximum accepted input size for fallible parsing APIs.
97///
98/// This matches the 500 KiB minimum fetch limit specified by RFC 9309 and is
99/// used by [`ParseOptions::default`]. Set [`ParseOptions::max_bytes`] to `None`
100/// to disable the limit.
101pub const DEFAULT_MAX_BYTES: usize = 512 * 1024;
102
103/// Errors returned by fallible parsing APIs.
104///
105/// Soft syntax issues, such as missing separators, are not hard errors because
106/// crawlers are expected to recover from malformed `robots.txt` files where
107/// possible. Use [`RobotsTxt::parse_with_diagnostics`] or
108/// [`RobotsTxt::parse_bytes_with_diagnostics`] to collect those warnings.
109#[derive(Debug, Error)]
110pub enum ParseError {
111    /// The input bytes were not valid UTF-8.
112    #[error("robots.txt is not valid UTF-8")]
113    Utf8(#[from] str::Utf8Error),
114
115    /// The input length exceeded [`ParseOptions::max_bytes`].
116    #[error("robots.txt is too large: {len} bytes exceeds limit of {max} bytes")]
117    TooLarge {
118        /// Actual input length in bytes.
119        len: usize,
120        /// Configured maximum input length in bytes.
121        max: usize,
122    },
123}
124
125/// Options shared by fallible parsing APIs.
126///
127/// # Examples
128///
129/// ```
130/// # fn main() -> Result<(), fast_robots::ParseError> {
131/// use fast_robots::{ParseOptions, RobotsTxt};
132///
133/// let robots = RobotsTxt::parse_with_options(
134///     "User-agent: *\nDisallow: /private\n",
135///     ParseOptions { max_bytes: Some(1024) },
136/// )?;
137///
138/// assert!(!robots.is_allowed("ExampleBot", "/private"));
139/// # Ok(())
140/// # }
141/// ```
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct ParseOptions {
144    /// Maximum accepted input size in bytes.
145    ///
146    /// `Some(DEFAULT_MAX_BYTES)` is used by default. Set to `None` to disable
147    /// size checks for trusted inputs.
148    pub max_bytes: Option<usize>,
149}
150
151impl Default for ParseOptions {
152    fn default() -> Self {
153        Self {
154            max_bytes: Some(DEFAULT_MAX_BYTES),
155        }
156    }
157}
158
159/// Parsed rules plus any diagnostics collected during parsing.
160///
161/// Returned by diagnostics APIs. The parser output remains available even when
162/// warnings were emitted.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ParseReport<'a> {
165    /// Parsed `robots.txt` rules and extension metadata.
166    pub robots: RobotsTxt<'a>,
167    /// Recoverable parse warnings in source order.
168    pub warnings: Vec<ParseWarning<'a>>,
169}
170
171/// A recoverable parse issue with its one-based line number.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ParseWarning<'a> {
174    /// One-based line number where the warning was found.
175    pub line: usize,
176    /// Warning category and borrowed source data, when relevant.
177    pub kind: ParseWarningKind<'a>,
178}
179
180/// Recoverable parse warning categories.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum ParseWarningKind<'a> {
183    /// A non-empty, non-comment line did not contain a `:` separator.
184    MissingSeparator {
185        /// Trimmed line contents.
186        line: &'a str,
187    },
188    /// A directive had a `:` separator but no key before it.
189    EmptyDirectiveKey,
190    /// A `User-agent` directive had an empty value.
191    EmptyUserAgent,
192    /// An `Allow` or `Disallow` directive appeared before any `User-agent`.
193    RuleBeforeUserAgent {
194        /// Directive key that appeared before a group was started.
195        key: &'a str,
196    },
197}
198
199/// Parsed `robots.txt` data.
200///
201/// Values inside this type borrow from the original input. Use
202/// [`RobotsTxt::is_allowed`] for access checks and inspect [`RobotsTxt::groups`]
203/// when you need the parsed rule structure.
204///
205/// # Examples
206///
207/// ```
208/// use fast_robots::{RobotsTxt, RuleKind};
209///
210/// let robots = RobotsTxt::parse("User-agent: *\nDisallow: /admin\n");
211///
212/// assert_eq!(robots.groups[0].agents, ["*"]);
213/// assert_eq!(robots.groups[0].rules[0].kind, RuleKind::Disallow);
214/// assert_eq!(robots.groups[0].rules[0].pattern, "/admin");
215/// ```
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct RobotsTxt<'a> {
218    /// Standard access-control groups in source order.
219    pub groups: Vec<Group<'a>>,
220    /// Non-core metadata collected when the `extensions` feature is enabled.
221    #[cfg(feature = "extensions")]
222    #[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
223    pub extensions: Extensions<'a>,
224}
225
226/// A `robots.txt` group containing one or more user agents and their rules.
227///
228/// Consecutive `User-agent` records before the first rule belong to the same
229/// group. A later `User-agent` starts a new group after any `Allow` or
230/// `Disallow` record has been seen.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct Group<'a> {
233    /// User-agent product tokens covered by this group.
234    pub agents: Vec<&'a str>,
235    /// Access-control rules associated with [`Group::agents`].
236    pub rules: Vec<Rule<'a>>,
237}
238
239/// A single `Allow` or `Disallow` rule.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct Rule<'a> {
242    /// Whether this rule allows or disallows matching paths.
243    pub kind: RuleKind,
244    /// Path pattern borrowed from the directive value.
245    ///
246    /// Patterns may contain `*` wildcards and a trailing `$` end anchor.
247    pub pattern: &'a str,
248}
249
250/// Access-control directive kind.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum RuleKind {
253    /// An `Allow` directive.
254    Allow,
255    /// A `Disallow` directive.
256    Disallow,
257}
258
259/// Precompiled matcher for repeated access checks against one [`RobotsTxt`].
260///
261/// Build this with [`RobotsTxt::matcher`] when checking many paths against the
262/// same parsed file. Construction allocates user-agent, prefix, exact-match,
263/// and wildcard-prefix indexes, so [`RobotsTxt::is_allowed`] remains the
264/// lower-overhead option for one-off checks.
265#[derive(Debug, Clone)]
266pub struct RobotsMatcher<'a> {
267    agents: HashMap<String, Vec<usize>>,
268    fallback: Vec<usize>,
269    compiled: Vec<CompiledGroup<'a>>,
270}
271
272#[derive(Debug, Clone)]
273struct CompiledGroup<'a> {
274    prefix_rules: RuleTrie,
275    exact_rules: HashMap<&'a str, RuleKind>,
276    wildcard_rules: Vec<WildcardRule<'a>>,
277    wildcard_prefixes: WildcardRuleTrie,
278}
279
280#[derive(Debug, Clone)]
281struct RuleTrie {
282    nodes: Vec<RuleTrieNode>,
283}
284
285#[derive(Debug, Clone, Default)]
286struct RuleTrieNode {
287    edges: Vec<(u8, usize)>,
288    decision: Option<(usize, RuleKind)>,
289}
290
291#[derive(Debug, Clone)]
292struct WildcardRuleTrie {
293    nodes: Vec<WildcardRuleTrieNode>,
294}
295
296#[derive(Debug, Clone, Default)]
297struct WildcardRuleTrieNode {
298    edges: Vec<(u8, usize)>,
299    rule_indexes: Vec<usize>,
300}
301
302#[derive(Debug, Clone)]
303struct WildcardRule<'a> {
304    kind: RuleKind,
305    first_len: usize,
306    segments: Vec<&'a [u8]>,
307    anchored: bool,
308    ends_with_star: bool,
309    specificity: usize,
310}
311
312/// Feature-gated metadata for common non-standard directives.
313///
314/// These values are collected for callers that need them, but they do not affect
315/// access decisions returned by [`RobotsTxt::is_allowed`].
316///
317/// # Examples
318///
319/// ```
320/// use fast_robots::RobotsTxt;
321///
322/// let robots = RobotsTxt::parse(
323///     "Sitemap: https://example.com/sitemap.xml\n\
324///      Host: example.com\n\
325///      User-agent: *\n\
326///      Crawl-delay: 10\n",
327/// );
328///
329/// assert_eq!(robots.extensions.sitemaps, ["https://example.com/sitemap.xml"]);
330/// assert_eq!(robots.extensions.hosts, ["example.com"]);
331/// assert_eq!(robots.extensions.crawl_delays[0].value, "10");
332/// ```
333#[cfg(feature = "extensions")]
334#[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
335#[derive(Debug, Clone, PartialEq, Eq, Default)]
336pub struct Extensions<'a> {
337    /// `Sitemap` directive values.
338    pub sitemaps: Vec<&'a str>,
339    /// `Crawl-delay` directive values, including the current group agents.
340    pub crawl_delays: Vec<CrawlDelay<'a>>,
341    /// `Host` directive values.
342    pub hosts: Vec<&'a str>,
343    /// `Clean-param` directive values.
344    pub clean_params: Vec<CleanParam<'a>>,
345    /// Unknown non-core directives preserved as key/value pairs.
346    pub other: Vec<Directive<'a>>,
347}
348
349/// A `Crawl-delay` directive and the group agents active when it appeared.
350#[cfg(feature = "extensions")]
351#[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct CrawlDelay<'a> {
354    /// Current group agents at the point where the directive appeared.
355    ///
356    /// This is empty when `Crawl-delay` appears before any `User-agent`.
357    pub agents: Vec<&'a str>,
358    /// Raw `Crawl-delay` value.
359    pub value: &'a str,
360}
361
362/// A `Clean-param` directive value.
363#[cfg(feature = "extensions")]
364#[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub struct CleanParam<'a> {
367    /// Raw `Clean-param` value.
368    pub value: &'a str,
369}
370
371/// A non-core directive preserved as a raw key/value pair.
372#[cfg(feature = "extensions")]
373#[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub struct Directive<'a> {
376    /// Directive key as written before the `:` separator, after ASCII trim.
377    pub key: &'a str,
378    /// Directive value as written after the `:` separator, after ASCII trim.
379    pub value: &'a str,
380}
381
382impl<'a> RobotsTxt<'a> {
383    /// Parses a UTF-8 `robots.txt` string into access rules.
384    ///
385    /// This is tolerant and infallible: malformed lines are ignored where the
386    /// parser can recover. Use [`RobotsTxt::parse_with_diagnostics`] to collect
387    /// warnings, or [`RobotsTxt::parse_with_options`] to enforce a size limit.
388    ///
389    /// # Examples
390    ///
391    /// ```
392    /// use fast_robots::RobotsTxt;
393    ///
394    /// let robots = RobotsTxt::parse("User-agent: *\nDisallow: /private\n");
395    ///
396    /// assert!(!robots.is_allowed("ExampleBot", "/private/file.html"));
397    /// assert!(robots.is_allowed("ExampleBot", "/public/file.html"));
398    /// ```
399    #[must_use]
400    pub fn parse(input: &'a str) -> Self {
401        parse_inner(input, false).robots
402    }
403
404    /// Parses UTF-8 bytes into access rules using [`ParseOptions::default`].
405    ///
406    /// Returns [`ParseError::Utf8`] for invalid UTF-8 and
407    /// [`ParseError::TooLarge`] when the input is larger than
408    /// [`DEFAULT_MAX_BYTES`].
409    ///
410    /// # Errors
411    ///
412    /// Returns an error when `input` is not valid UTF-8 or exceeds the default
413    /// size limit.
414    ///
415    /// # Examples
416    ///
417    /// ```
418    /// # fn main() -> Result<(), fast_robots::ParseError> {
419    /// use fast_robots::RobotsTxt;
420    ///
421    /// let robots = RobotsTxt::parse_bytes(b"User-agent: *\nDisallow: /tmp\n")?;
422    /// assert!(!robots.is_allowed("ExampleBot", "/tmp/cache"));
423    /// # Ok(())
424    /// # }
425    /// ```
426    pub fn parse_bytes(input: &'a [u8]) -> Result<Self, ParseError> {
427        Self::parse_bytes_with_options(input, ParseOptions::default())
428    }
429
430    /// Parses UTF-8 bytes into access rules with explicit options.
431    ///
432    /// Use this when reading raw bytes and you need a custom size limit.
433    ///
434    /// # Errors
435    ///
436    /// Returns an error when `input` is not valid UTF-8 or exceeds the configured
437    /// size limit.
438    ///
439    /// # Examples
440    ///
441    /// ```
442    /// # fn main() -> Result<(), fast_robots::ParseError> {
443    /// use fast_robots::{ParseOptions, RobotsTxt};
444    ///
445    /// let robots = RobotsTxt::parse_bytes_with_options(
446    ///     b"User-agent: *\nDisallow: /cache\n",
447    ///     ParseOptions { max_bytes: Some(1024) },
448    /// )?;
449    ///
450    /// assert!(!robots.is_allowed("ExampleBot", "/cache/file"));
451    /// # Ok(())
452    /// # }
453    /// ```
454    pub fn parse_bytes_with_options(
455        input: &'a [u8],
456        options: ParseOptions,
457    ) -> Result<Self, ParseError> {
458        check_size(input.len(), options)?;
459        let input = str::from_utf8(input)?;
460        Ok(Self::parse(input))
461    }
462
463    /// Parses a UTF-8 string into access rules with explicit options.
464    ///
465    /// This is useful when the input is already a `str` but should still be
466    /// checked against a maximum size.
467    ///
468    /// # Errors
469    ///
470    /// Returns an error when `input` exceeds the configured size limit.
471    ///
472    /// # Examples
473    ///
474    /// ```
475    /// # fn main() -> Result<(), fast_robots::ParseError> {
476    /// use fast_robots::{ParseOptions, RobotsTxt};
477    ///
478    /// let robots = RobotsTxt::parse_with_options(
479    ///     "User-agent: *\nDisallow: /private\n",
480    ///     ParseOptions { max_bytes: Some(1024) },
481    /// )?;
482    ///
483    /// assert!(!robots.is_allowed("ExampleBot", "/private"));
484    /// # Ok(())
485    /// # }
486    /// ```
487    pub fn parse_with_options(input: &'a str, options: ParseOptions) -> Result<Self, ParseError> {
488        check_size(input.len(), options)?;
489        Ok(Self::parse(input))
490    }
491
492    /// Parses a UTF-8 string and records recoverable syntax warnings.
493    ///
494    /// Diagnostics do not change parser recovery behavior; they only expose the
495    /// issues that tolerant parsing skipped.
496    ///
497    /// # Examples
498    ///
499    /// ```
500    /// use fast_robots::{ParseWarningKind, RobotsTxt};
501    ///
502    /// let report = RobotsTxt::parse_with_diagnostics(
503    ///     "Disallow: /\nMissing separator\nUser-agent: *\nDisallow: /private\n",
504    /// );
505    ///
506    /// assert_eq!(report.warnings.len(), 2);
507    /// assert!(matches!(
508    ///     report.warnings[0].kind,
509    ///     ParseWarningKind::RuleBeforeUserAgent { .. }
510    /// ));
511    /// assert!(!report.robots.is_allowed("ExampleBot", "/private"));
512    /// ```
513    #[must_use]
514    pub fn parse_with_diagnostics(input: &'a str) -> ParseReport<'a> {
515        parse_inner(input, true)
516    }
517
518    /// Parses a UTF-8 string with diagnostics and explicit options.
519    ///
520    /// # Errors
521    ///
522    /// Returns an error when `input` exceeds the configured size limit.
523    ///
524    /// # Examples
525    ///
526    /// ```
527    /// # fn main() -> Result<(), fast_robots::ParseError> {
528    /// use fast_robots::{ParseOptions, RobotsTxt};
529    ///
530    /// let report = RobotsTxt::parse_with_diagnostics_options(
531    ///     "User-agent: *\nDisallow: /private\n",
532    ///     ParseOptions { max_bytes: Some(1024) },
533    /// )?;
534    ///
535    /// assert!(report.warnings.is_empty());
536    /// assert!(!report.robots.is_allowed("ExampleBot", "/private"));
537    /// # Ok(())
538    /// # }
539    /// ```
540    pub fn parse_with_diagnostics_options(
541        input: &'a str,
542        options: ParseOptions,
543    ) -> Result<ParseReport<'a>, ParseError> {
544        check_size(input.len(), options)?;
545        Ok(parse_inner(input, true))
546    }
547
548    /// Parses UTF-8 bytes and records recoverable syntax warnings.
549    ///
550    /// Uses [`ParseOptions::default`] for size checking.
551    ///
552    /// # Errors
553    ///
554    /// Returns an error when `input` is not valid UTF-8 or exceeds the default
555    /// size limit.
556    ///
557    /// # Examples
558    ///
559    /// ```
560    /// # fn main() -> Result<(), fast_robots::ParseError> {
561    /// use fast_robots::RobotsTxt;
562    ///
563    /// let report = RobotsTxt::parse_bytes_with_diagnostics(
564    ///     b"User-agent: *\nDisallow: /private\n",
565    /// )?;
566    ///
567    /// assert!(report.warnings.is_empty());
568    /// assert!(!report.robots.is_allowed("ExampleBot", "/private"));
569    /// # Ok(())
570    /// # }
571    /// ```
572    pub fn parse_bytes_with_diagnostics(input: &'a [u8]) -> Result<ParseReport<'a>, ParseError> {
573        Self::parse_bytes_with_diagnostics_options(input, ParseOptions::default())
574    }
575
576    /// Parses UTF-8 bytes with diagnostics and explicit options.
577    ///
578    /// # Errors
579    ///
580    /// Returns an error when `input` is not valid UTF-8 or exceeds the configured
581    /// size limit.
582    ///
583    /// # Examples
584    ///
585    /// ```
586    /// # fn main() -> Result<(), fast_robots::ParseError> {
587    /// use fast_robots::{ParseOptions, RobotsTxt};
588    ///
589    /// let report = RobotsTxt::parse_bytes_with_diagnostics_options(
590    ///     b"User-agent: *\nDisallow: /private\n",
591    ///     ParseOptions { max_bytes: Some(1024) },
592    /// )?;
593    ///
594    /// assert!(report.warnings.is_empty());
595    /// # Ok(())
596    /// # }
597    /// ```
598    pub fn parse_bytes_with_diagnostics_options(
599        input: &'a [u8],
600        options: ParseOptions,
601    ) -> Result<ParseReport<'a>, ParseError> {
602        check_size(input.len(), options)?;
603        let input = str::from_utf8(input)?;
604        Ok(parse_inner(input, true))
605    }
606
607    /// Builds an indexed matcher for repeated access checks.
608    ///
609    /// The returned matcher borrows this parsed file, indexes user-agent groups
610    /// and rule prefixes, and pre-splits wildcard patterns. Use it when checking
611    /// many URLs against the same `robots.txt`; for one-off checks,
612    /// [`RobotsTxt::is_allowed`] avoids the upfront allocation cost.
613    ///
614    /// # Examples
615    ///
616    /// ```
617    /// use fast_robots::RobotsTxt;
618    ///
619    /// let robots = RobotsTxt::parse("User-agent: *\nDisallow: /private\n");
620    /// let matcher = robots.matcher();
621    ///
622    /// assert!(!matcher.is_allowed("ExampleBot", "/private/file"));
623    /// assert!(matcher.is_allowed("ExampleBot", "/public/file"));
624    /// ```
625    #[must_use]
626    pub fn matcher(&'a self) -> RobotsMatcher<'a> {
627        RobotsMatcher::new(self)
628    }
629
630    /// Returns whether `user_agent` may crawl `path`.
631    ///
632    /// The matcher implements the core RFC 9309 access semantics used by this
633    /// crate: exact user-agent groups are considered before the `*` fallback,
634    /// matching exact groups are merged, the longest matching pattern wins, and
635    /// `Allow` wins ties. `/robots.txt` is always allowed.
636    ///
637    /// `path` should be the URL path and optional query string, not a full URL.
638    ///
639    /// # Examples
640    ///
641    /// ```
642    /// use fast_robots::RobotsTxt;
643    ///
644    /// let robots = RobotsTxt::parse(
645    ///     "User-agent: *\n\
646    ///      Disallow: /private\n\
647    ///      Allow: /private/public\n",
648    /// );
649    ///
650    /// assert!(!robots.is_allowed("ExampleBot", "/private/file"));
651    /// assert!(robots.is_allowed("ExampleBot", "/private/public/file"));
652    /// assert!(robots.is_allowed("ExampleBot", "/robots.txt"));
653    /// ```
654    #[must_use]
655    pub fn is_allowed(&self, user_agent: &str, path: &str) -> bool {
656        if path == "/robots.txt" {
657            return true;
658        }
659
660        let mut exact_match = false;
661        let mut best: Option<(usize, RuleKind)> = None;
662
663        for group in &self.groups {
664            if group
665                .agents
666                .iter()
667                .any(|agent| *agent != "*" && agent.eq_ignore_ascii_case(user_agent))
668            {
669                exact_match = true;
670                apply_group_rules(group, path, &mut best);
671            }
672        }
673
674        if !exact_match {
675            for group in &self.groups {
676                if group.agents.contains(&"*") {
677                    apply_group_rules(group, path, &mut best);
678                }
679            }
680        }
681
682        rule_decision(best)
683    }
684}
685
686impl<'a> RobotsMatcher<'a> {
687    fn new(robots: &'a RobotsTxt<'a>) -> Self {
688        let groups = robots.groups.as_slice();
689        let mut agents: HashMap<String, Vec<usize>> = HashMap::new();
690        let mut fallback = vec![];
691        let mut compiled = Vec::with_capacity(groups.len());
692
693        for (group_index, group) in groups.iter().enumerate() {
694            for agent in &group.agents {
695                if *agent == "*" {
696                    fallback.push(group_index);
697                } else {
698                    let indexes = agents.entry(agent.to_ascii_lowercase()).or_default();
699                    if !indexes.contains(&group_index) {
700                        indexes.push(group_index);
701                    }
702                }
703            }
704
705            compiled.push(CompiledGroup::new(group));
706        }
707
708        Self {
709            agents,
710            fallback,
711            compiled,
712        }
713    }
714
715    /// Returns whether `user_agent` may crawl `path` using the prebuilt index.
716    ///
717    /// This has the same access semantics as [`RobotsTxt::is_allowed`], including
718    /// exact user-agent precedence over `*`, merged exact groups, longest-match
719    /// rule selection, `Allow` tie wins, and implicit allowance for `/robots.txt`.
720    #[must_use]
721    pub fn is_allowed(&self, user_agent: &str, path: &str) -> bool {
722        if path == "/robots.txt" {
723            return true;
724        }
725
726        let mut best: Option<(usize, RuleKind)> = None;
727        let agent = user_agent.to_ascii_lowercase();
728
729        if let Some(group_indexes) = self.agents.get(&agent) {
730            self.apply_group_indexes(group_indexes, path, &mut best);
731        } else {
732            self.apply_group_indexes(&self.fallback, path, &mut best);
733        }
734
735        rule_decision(best)
736    }
737
738    fn apply_group_indexes(
739        &self,
740        group_indexes: &[usize],
741        path: &str,
742        best: &mut Option<(usize, RuleKind)>,
743    ) {
744        for &group_index in group_indexes {
745            self.compiled[group_index].apply_rules(path, best);
746        }
747    }
748}
749
750impl<'a> CompiledGroup<'a> {
751    fn new(group: &Group<'a>) -> Self {
752        let mut compiled = Self {
753            prefix_rules: RuleTrie::new(),
754            exact_rules: HashMap::new(),
755            wildcard_rules: vec![],
756            wildcard_prefixes: WildcardRuleTrie::new(),
757        };
758
759        for rule in &group.rules {
760            compiled.push_rule(rule);
761        }
762
763        compiled
764    }
765
766    fn push_rule(&mut self, rule: &Rule<'a>) {
767        if rule.pattern.is_empty() {
768            return;
769        }
770
771        let (pattern, anchored) = strip_end_anchor(rule.pattern);
772        let pattern_bytes = pattern.as_bytes();
773
774        if let Some(first_wildcard) = memchr(b'*', pattern_bytes) {
775            let rule_index = self.wildcard_rules.len();
776            self.wildcard_prefixes
777                .insert(&pattern_bytes[..first_wildcard], rule_index);
778            self.wildcard_rules.push(WildcardRule::new(
779                rule.kind,
780                pattern,
781                anchored,
782                first_wildcard,
783            ));
784        } else if anchored {
785            let decision = self.exact_rules.entry(pattern).or_insert(rule.kind);
786            if rule.kind == RuleKind::Allow {
787                *decision = RuleKind::Allow;
788            }
789        } else {
790            self.prefix_rules
791                .insert(pattern_bytes, pattern.len(), rule.kind);
792        }
793    }
794
795    fn apply_rules(&self, path: &str, best: &mut Option<(usize, RuleKind)>) {
796        let path_bytes = path.as_bytes();
797
798        self.prefix_rules.apply_matches(path_bytes, best);
799
800        if let Some(&kind) = self.exact_rules.get(path) {
801            apply_rule_decision(path.len(), kind, best);
802        }
803
804        self.wildcard_prefixes
805            .apply_matches(path_bytes, &self.wildcard_rules, best);
806    }
807}
808
809impl RuleTrie {
810    fn new() -> Self {
811        Self {
812            nodes: vec![RuleTrieNode::default()],
813        }
814    }
815
816    fn insert(&mut self, pattern: &[u8], specificity: usize, kind: RuleKind) {
817        let mut node_index = 0;
818        for &byte in pattern {
819            node_index = self.child_or_insert(node_index, byte);
820        }
821
822        apply_rule_decision(specificity, kind, &mut self.nodes[node_index].decision);
823    }
824
825    fn apply_matches(&self, path: &[u8], best: &mut Option<(usize, RuleKind)>) {
826        let mut node_index = 0;
827
828        for &byte in path {
829            let Some(next_index) = self.child(node_index, byte) else {
830                return;
831            };
832            node_index = next_index;
833
834            if let Some((specificity, kind)) = self.nodes[node_index].decision {
835                apply_rule_decision(specificity, kind, best);
836            }
837        }
838    }
839
840    fn child(&self, node_index: usize, byte: u8) -> Option<usize> {
841        self.nodes[node_index]
842            .edges
843            .iter()
844            .find_map(|&(edge_byte, child_index)| (edge_byte == byte).then_some(child_index))
845    }
846
847    fn child_or_insert(&mut self, node_index: usize, byte: u8) -> usize {
848        if let Some(child_index) = self.child(node_index, byte) {
849            return child_index;
850        }
851
852        let child_index = self.nodes.len();
853        self.nodes.push(RuleTrieNode::default());
854        self.nodes[node_index].edges.push((byte, child_index));
855        child_index
856    }
857}
858
859impl WildcardRuleTrie {
860    fn new() -> Self {
861        Self {
862            nodes: vec![WildcardRuleTrieNode::default()],
863        }
864    }
865
866    fn insert(&mut self, literal_prefix: &[u8], rule_index: usize) {
867        let mut node_index = 0;
868        for &byte in literal_prefix {
869            node_index = self.child_or_insert(node_index, byte);
870        }
871
872        self.nodes[node_index].rule_indexes.push(rule_index);
873    }
874
875    fn apply_matches(
876        &self,
877        path: &[u8],
878        rules: &[WildcardRule<'_>],
879        best: &mut Option<(usize, RuleKind)>,
880    ) {
881        let mut node_index = 0;
882        self.apply_node_rules(node_index, path, rules, best);
883
884        for &byte in path {
885            let Some(next_index) = self.child(node_index, byte) else {
886                return;
887            };
888            node_index = next_index;
889            self.apply_node_rules(node_index, path, rules, best);
890        }
891    }
892
893    fn apply_node_rules(
894        &self,
895        node_index: usize,
896        path: &[u8],
897        rules: &[WildcardRule<'_>],
898        best: &mut Option<(usize, RuleKind)>,
899    ) {
900        for &rule_index in &self.nodes[node_index].rule_indexes {
901            let rule = &rules[rule_index];
902            let Some(specificity) = rule.matching_specificity(path) else {
903                continue;
904            };
905
906            apply_rule_decision(specificity, rule.kind, best);
907        }
908    }
909
910    fn child(&self, node_index: usize, byte: u8) -> Option<usize> {
911        self.nodes[node_index]
912            .edges
913            .iter()
914            .find_map(|&(edge_byte, child_index)| (edge_byte == byte).then_some(child_index))
915    }
916
917    fn child_or_insert(&mut self, node_index: usize, byte: u8) -> usize {
918        if let Some(child_index) = self.child(node_index, byte) {
919            return child_index;
920        }
921
922        let child_index = self.nodes.len();
923        self.nodes.push(WildcardRuleTrieNode::default());
924        self.nodes[node_index].edges.push((byte, child_index));
925        child_index
926    }
927}
928
929impl<'a> WildcardRule<'a> {
930    fn new(kind: RuleKind, pattern: &'a str, anchored: bool, first_wildcard: usize) -> Self {
931        let pattern_bytes = pattern.as_bytes();
932        let segments = pattern_bytes[first_wildcard + 1..]
933            .split(|byte| *byte == b'*')
934            .filter(|segment| !segment.is_empty())
935            .collect();
936
937        Self {
938            kind,
939            first_len: first_wildcard,
940            segments,
941            anchored,
942            ends_with_star: pattern_bytes.last() == Some(&b'*'),
943            specificity: pattern.len(),
944        }
945    }
946
947    fn matching_specificity(&self, path: &[u8]) -> Option<usize> {
948        let mut offset = self.first_len;
949
950        for segment in &self.segments {
951            let found = memmem::find(&path[offset..], segment)?;
952            offset += found + segment.len();
953        }
954
955        (!self.anchored || self.ends_with_star || offset == path.len()).then_some(self.specificity)
956    }
957}
958
959/// Checks an input length against the configured parser size limit.
960fn check_size(len: usize, options: ParseOptions) -> Result<(), ParseError> {
961    if let Some(max) = options.max_bytes {
962        if len > max {
963            return Err(ParseError::TooLarge { len, max });
964        }
965    }
966
967    Ok(())
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971enum DirectiveKind {
972    UserAgent,
973    Allow,
974    Disallow,
975    #[cfg(feature = "extensions")]
976    Sitemap,
977    #[cfg(feature = "extensions")]
978    CrawlDelay,
979    #[cfg(feature = "extensions")]
980    Host,
981    #[cfg(feature = "extensions")]
982    CleanParam,
983    Other,
984}
985
986fn classify_directive_key(key: &str) -> DirectiveKind {
987    match key.as_bytes() {
988        b"Allow" | b"allow" => return DirectiveKind::Allow,
989        b"Disallow" | b"disallow" => return DirectiveKind::Disallow,
990        b"User-agent" | b"user-agent" => return DirectiveKind::UserAgent,
991        #[cfg(feature = "extensions")]
992        b"Host" | b"host" => return DirectiveKind::Host,
993        #[cfg(feature = "extensions")]
994        b"Sitemap" | b"sitemap" => return DirectiveKind::Sitemap,
995        #[cfg(feature = "extensions")]
996        b"Crawl-delay" | b"crawl-delay" => return DirectiveKind::CrawlDelay,
997        #[cfg(feature = "extensions")]
998        b"Clean-param" | b"clean-param" => return DirectiveKind::CleanParam,
999        _ => {}
1000    }
1001
1002    classify_directive_key_ignore_case(key)
1003}
1004
1005#[cold]
1006#[inline(never)]
1007fn classify_directive_key_ignore_case(key: &str) -> DirectiveKind {
1008    match key.len() {
1009        5 if key.eq_ignore_ascii_case("allow") => DirectiveKind::Allow,
1010        8 if key.eq_ignore_ascii_case("disallow") => DirectiveKind::Disallow,
1011        10 if key.eq_ignore_ascii_case("user-agent") => DirectiveKind::UserAgent,
1012        #[cfg(feature = "extensions")]
1013        4 if key.eq_ignore_ascii_case("host") => DirectiveKind::Host,
1014        #[cfg(feature = "extensions")]
1015        7 if key.eq_ignore_ascii_case("sitemap") => DirectiveKind::Sitemap,
1016        #[cfg(feature = "extensions")]
1017        11 if key.eq_ignore_ascii_case("crawl-delay") => DirectiveKind::CrawlDelay,
1018        #[cfg(feature = "extensions")]
1019        11 if key.eq_ignore_ascii_case("clean-param") => DirectiveKind::CleanParam,
1020        _ => DirectiveKind::Other,
1021    }
1022}
1023
1024fn new_group(agent: &str) -> Group<'_> {
1025    Group {
1026        agents: vec![agent],
1027        rules: Vec::with_capacity(4),
1028    }
1029}
1030
1031/// Shared parser implementation for tolerant and diagnostics-enabled parsing.
1032///
1033/// The parser walks the file one line at a time, strips comments and ASCII
1034/// whitespace, tracks the current user-agent group, and optionally records soft
1035/// failures as [`ParseWarning`] values.
1036fn parse_inner<'a>(input: &'a str, diagnostics: bool) -> ParseReport<'a> {
1037    let mut groups = vec![];
1038    let mut current: Option<Group<'a>> = None;
1039    let mut current_has_rules = false;
1040    let mut warnings = vec![];
1041
1042    #[cfg(feature = "extensions")]
1043    let mut extensions = Extensions::default();
1044
1045    for (line_number, line) in Lines::new(input) {
1046        let line = trim_ascii(strip_comment(line));
1047        if line.is_empty() {
1048            continue;
1049        }
1050
1051        let Some((key, value)) = split_directive(line) else {
1052            if diagnostics {
1053                warnings.push(ParseWarning {
1054                    line: line_number,
1055                    kind: ParseWarningKind::MissingSeparator { line },
1056                });
1057            }
1058            continue;
1059        };
1060
1061        let key = trim_ascii(key);
1062        let value = trim_ascii(value);
1063        if key.is_empty() {
1064            if diagnostics {
1065                warnings.push(ParseWarning {
1066                    line: line_number,
1067                    kind: ParseWarningKind::EmptyDirectiveKey,
1068                });
1069            }
1070            continue;
1071        }
1072
1073        let directive = classify_directive_key(key);
1074
1075        match directive {
1076            DirectiveKind::UserAgent => {
1077                if value.is_empty() {
1078                    if diagnostics {
1079                        warnings.push(ParseWarning {
1080                            line: line_number,
1081                            kind: ParseWarningKind::EmptyUserAgent,
1082                        });
1083                    }
1084                    continue;
1085                }
1086
1087                match current.as_mut() {
1088                    Some(group) if !current_has_rules => group.agents.push(value),
1089                    Some(_) => {
1090                        groups.push(current.take().expect("current group exists"));
1091                        current = Some(new_group(value));
1092                        current_has_rules = false;
1093                    }
1094                    None => {
1095                        current = Some(new_group(value));
1096                    }
1097                }
1098            }
1099            DirectiveKind::Allow | DirectiveKind::Disallow => {
1100                let Some(group) = current.as_mut() else {
1101                    if diagnostics {
1102                        warnings.push(ParseWarning {
1103                            line: line_number,
1104                            kind: ParseWarningKind::RuleBeforeUserAgent { key },
1105                        });
1106                    }
1107                    continue;
1108                };
1109
1110                let kind = match directive {
1111                    DirectiveKind::Allow => RuleKind::Allow,
1112                    DirectiveKind::Disallow => RuleKind::Disallow,
1113                    _ => unreachable!("only allow/disallow directives reach this branch"),
1114                };
1115
1116                group.rules.push(Rule {
1117                    kind,
1118                    pattern: value,
1119                });
1120                current_has_rules = true;
1121            }
1122            _ => {
1123                #[cfg(feature = "extensions")]
1124                collect_extension(&mut extensions, current.as_ref(), directive, key, value);
1125            }
1126        }
1127    }
1128
1129    if let Some(group) = current {
1130        groups.push(group);
1131    }
1132
1133    ParseReport {
1134        robots: RobotsTxt {
1135            groups,
1136            #[cfg(feature = "extensions")]
1137            extensions,
1138        },
1139        warnings,
1140    }
1141}
1142
1143/// Applies matching rules from a group to the current best access decision.
1144///
1145/// `best` stores the specificity and kind of the strongest matching rule seen
1146/// so far. More specific patterns replace less specific ones, and `Allow`
1147/// replaces `Disallow` on ties.
1148fn apply_group_rules(group: &Group<'_>, path: &str, best: &mut Option<(usize, RuleKind)>) {
1149    for rule in &group.rules {
1150        let Some(specificity) = matching_specificity(rule.pattern, path) else {
1151            continue;
1152        };
1153
1154        apply_rule_decision(specificity, rule.kind, best);
1155    }
1156}
1157
1158fn apply_rule_decision(specificity: usize, kind: RuleKind, best: &mut Option<(usize, RuleKind)>) {
1159    let should_replace = !matches!(
1160        *best,
1161        Some((best_specificity, best_kind))
1162            if specificity < best_specificity
1163                || (specificity == best_specificity
1164                    && !(kind == RuleKind::Allow && best_kind == RuleKind::Disallow))
1165    );
1166
1167    if should_replace {
1168        *best = Some((specificity, kind));
1169    }
1170}
1171
1172fn rule_decision(best: Option<(usize, RuleKind)>) -> bool {
1173    match best {
1174        Some((_, RuleKind::Allow)) | None => true,
1175        Some((_, RuleKind::Disallow)) => false,
1176    }
1177}
1178
1179/// Returns matching specificity for robots longest-match rule selection.
1180///
1181/// Patterns without wildcards use the common prefix fast path. A trailing `$`
1182/// requires the match to consume the whole path but does not increase
1183/// specificity.
1184fn matching_specificity(pattern: &str, path: &str) -> Option<usize> {
1185    if pattern.is_empty() {
1186        return None;
1187    }
1188
1189    let (pattern, anchored) = strip_end_anchor(pattern);
1190    let matched = if pattern.as_bytes().contains(&b'*') {
1191        glob_matches(pattern.as_bytes(), path.as_bytes(), anchored)
1192    } else if anchored {
1193        path == pattern
1194    } else {
1195        path.starts_with(pattern)
1196    };
1197
1198    matched.then_some(pattern.len())
1199}
1200
1201/// Matches a `*` wildcard pattern against a path byte slice.
1202///
1203/// The first pattern segment must match at the start of the path; remaining
1204/// segments are located in order with SIMD-backed substring search.
1205fn glob_matches(pattern: &[u8], path: &[u8], anchored: bool) -> bool {
1206    let mut parts = pattern.split(|byte| *byte == b'*');
1207    let Some(first) = parts.next() else {
1208        return true;
1209    };
1210
1211    if !path.starts_with(first) {
1212        return false;
1213    }
1214
1215    let mut offset = first.len();
1216    let mut ends_with_star = pattern.last() == Some(&b'*');
1217
1218    for part in parts {
1219        if part.is_empty() {
1220            ends_with_star = true;
1221            continue;
1222        }
1223
1224        ends_with_star = false;
1225        let Some(found) = memmem::find(&path[offset..], part) else {
1226            return false;
1227        };
1228        offset += found + part.len();
1229    }
1230
1231    !anchored || ends_with_star || offset == path.len()
1232}
1233
1234fn strip_end_anchor(pattern: &str) -> (&str, bool) {
1235    match pattern.strip_suffix('$') {
1236        Some(pattern) => (pattern, true),
1237        None => (pattern, false),
1238    }
1239}
1240
1241#[cfg(feature = "extensions")]
1242/// Stores a non-core directive in the feature-gated extension metadata.
1243///
1244/// Extension directives intentionally do not alter group boundaries or access
1245/// rules. `Crawl-delay` snapshots the current group agents so callers can
1246/// associate the value with the group where it appeared.
1247fn collect_extension<'a>(
1248    extensions: &mut Extensions<'a>,
1249    current: Option<&Group<'a>>,
1250    directive: DirectiveKind,
1251    key: &'a str,
1252    value: &'a str,
1253) {
1254    match directive {
1255        DirectiveKind::Sitemap => {
1256            if !value.is_empty() {
1257                extensions.sitemaps.push(value);
1258            }
1259        }
1260        DirectiveKind::CrawlDelay => {
1261            extensions.crawl_delays.push(CrawlDelay {
1262                agents: current
1263                    .map(|group| group.agents.clone())
1264                    .unwrap_or_default(),
1265                value,
1266            });
1267        }
1268        DirectiveKind::Host => {
1269            if !value.is_empty() {
1270                extensions.hosts.push(value);
1271            }
1272        }
1273        DirectiveKind::CleanParam => {
1274            if !value.is_empty() {
1275                extensions.clean_params.push(CleanParam { value });
1276            }
1277        }
1278        _ => {
1279            extensions.other.push(Directive { key, value });
1280        }
1281    }
1282}
1283
1284/// Removes an inline `#` comment from a line.
1285fn strip_comment(line: &str) -> &str {
1286    match memchr(b'#', line.as_bytes()) {
1287        Some(index) => &line[..index],
1288        None => line,
1289    }
1290}
1291
1292/// Splits a directive line into raw key and value slices.
1293///
1294/// Only the first `:` is structural; additional colons remain part of the value.
1295fn split_directive(line: &str) -> Option<(&str, &str)> {
1296    let index = memchr(b':', line.as_bytes())?;
1297    Some((&line[..index], &line[index + 1..]))
1298}
1299
1300/// Trims ASCII spaces and tabs from both ends of a directive fragment.
1301///
1302/// Robots directives are byte-oriented, so this deliberately avoids full
1303/// Unicode whitespace handling.
1304fn trim_ascii(value: &str) -> &str {
1305    let bytes = value.as_bytes();
1306    let Some((&first, rest)) = bytes.split_first() else {
1307        return value;
1308    };
1309    let last = rest.last().copied().unwrap_or(first);
1310
1311    if !matches!(first, b' ' | b'\t') && !matches!(last, b' ' | b'\t') {
1312        return value;
1313    }
1314
1315    let mut start = 0;
1316    let mut end = bytes.len();
1317
1318    while start < end && matches!(bytes[start], b' ' | b'\t') {
1319        start += 1;
1320    }
1321    while end > start && matches!(bytes[end - 1], b' ' | b'\t') {
1322        end -= 1;
1323    }
1324
1325    &value[start..end]
1326}
1327
1328/// Iterator over input lines with one-based source line numbers.
1329///
1330/// Handles both LF and CRLF endings while keeping returned line slices borrowed
1331/// from the original input.
1332struct Lines<'a> {
1333    input: &'a str,
1334    offset: usize,
1335    line: usize,
1336}
1337
1338impl<'a> Lines<'a> {
1339    /// Creates a line iterator for `input`.
1340    fn new(input: &'a str) -> Self {
1341        Self {
1342            input,
1343            offset: 0,
1344            line: 1,
1345        }
1346    }
1347}
1348
1349impl<'a> Iterator for Lines<'a> {
1350    type Item = (usize, &'a str);
1351
1352    /// Returns the next line and its one-based line number.
1353    fn next(&mut self) -> Option<Self::Item> {
1354        if self.offset > self.input.len() {
1355            return None;
1356        }
1357
1358        let remaining = &self.input[self.offset..];
1359        if remaining.is_empty() {
1360            self.offset += 1;
1361            return None;
1362        }
1363
1364        let line_end = memchr(b'\n', remaining.as_bytes()).unwrap_or(remaining.len());
1365        let mut line = &remaining[..line_end];
1366        if let Some(stripped) = line.strip_suffix('\r') {
1367            line = stripped;
1368        }
1369
1370        let line_number = self.line;
1371        self.line += 1;
1372        self.offset += line_end + 1;
1373        Some((line_number, line))
1374    }
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380
1381    #[test]
1382    fn parses_groups_comments_and_crlf() {
1383        let robots = RobotsTxt::parse(
1384            "# ignored\r\nUser-agent: FooBot\r\nUser-agent: BarBot # same group\r\nDisallow: /private\r\nAllow: /private/public\r\n",
1385        );
1386
1387        assert_eq!(robots.groups.len(), 1);
1388        assert_eq!(robots.groups[0].agents, vec!["FooBot", "BarBot"]);
1389        assert_eq!(robots.groups[0].rules.len(), 2);
1390        assert!(!robots.is_allowed("FooBot", "/private/file"));
1391        assert!(robots.is_allowed("FooBot", "/private/public/file"));
1392    }
1393
1394    #[test]
1395    fn parses_directive_keys_case_insensitively() {
1396        let robots =
1397            RobotsTxt::parse("uSeR-aGeNt: FooBot\nDiSaLlOw: /private\nAlLoW: /private/public\n");
1398
1399        assert!(!robots.is_allowed("FooBot", "/private/file"));
1400        assert!(robots.is_allowed("FooBot", "/private/public/file"));
1401    }
1402
1403    #[test]
1404    fn ignores_rules_before_first_user_agent() {
1405        let robots = RobotsTxt::parse("Disallow: /\nUser-agent: *\nAllow: /\n");
1406
1407        assert!(robots.is_allowed("AnyBot", "/anything"));
1408    }
1409
1410    #[test]
1411    fn starts_new_group_after_rules() {
1412        let robots = RobotsTxt::parse(
1413            "User-agent: FooBot\nDisallow: /foo\nUser-agent: BarBot\nDisallow: /bar\n",
1414        );
1415
1416        assert_eq!(robots.groups.len(), 2);
1417        assert!(!robots.is_allowed("FooBot", "/foo"));
1418        assert!(robots.is_allowed("FooBot", "/bar"));
1419        assert!(!robots.is_allowed("BarBot", "/bar"));
1420    }
1421
1422    #[test]
1423    fn merges_multiple_exact_matching_groups() {
1424        let robots = RobotsTxt::parse(
1425            "User-agent: FooBot\nDisallow: /foo\n\nUser-agent: FooBot\nDisallow: /bar\n",
1426        );
1427
1428        assert!(!robots.is_allowed("FooBot", "/foo"));
1429        assert!(!robots.is_allowed("FooBot", "/bar"));
1430    }
1431
1432    #[test]
1433    fn falls_back_to_star_group() {
1434        let robots =
1435            RobotsTxt::parse("User-agent: *\nDisallow: /all\nUser-agent: FooBot\nAllow: /\n");
1436
1437        assert!(!robots.is_allowed("OtherBot", "/all"));
1438        assert!(robots.is_allowed("FooBot", "/all"));
1439    }
1440
1441    #[test]
1442    fn longest_match_wins_and_allow_wins_ties() {
1443        let robots = RobotsTxt::parse(
1444            "User-agent: *\nDisallow: /example/\nAllow: /example/public\nDisallow: /tie\nAllow: /tie\n",
1445        );
1446
1447        assert!(!robots.is_allowed("AnyBot", "/example/private"));
1448        assert!(robots.is_allowed("AnyBot", "/example/public/page"));
1449        assert!(robots.is_allowed("AnyBot", "/tie"));
1450    }
1451
1452    #[test]
1453    fn supports_wildcard_and_end_anchor() {
1454        let robots = RobotsTxt::parse("User-agent: *\nDisallow: /*.gif$\nAllow: /public/*.gif$\n");
1455
1456        assert!(!robots.is_allowed("AnyBot", "/images/a.gif"));
1457        assert!(robots.is_allowed("AnyBot", "/images/a.gif?size=large"));
1458        assert!(robots.is_allowed("AnyBot", "/public/a.gif"));
1459    }
1460
1461    #[test]
1462    fn empty_disallow_does_not_block() {
1463        let robots = RobotsTxt::parse("User-agent: *\nDisallow:\n");
1464
1465        assert!(robots.is_allowed("AnyBot", "/anything"));
1466    }
1467
1468    #[test]
1469    fn robots_txt_is_implicitly_allowed() {
1470        let robots = RobotsTxt::parse("User-agent: *\nDisallow: /\n");
1471
1472        assert!(robots.is_allowed("AnyBot", "/robots.txt"));
1473    }
1474
1475    #[test]
1476    fn compiled_matcher_matches_regular_matcher_for_core_rules() {
1477        let robots = RobotsTxt::parse(
1478            "User-agent: FooBot\n\
1479            Disallow: /foo\n\
1480            \n\
1481            User-agent: FooBot\n\
1482            Disallow: /bar\n\
1483            Allow: /bar/public\n\
1484            Disallow: /tie\n\
1485            Allow: /tie\n\
1486            \n\
1487            User-agent: ImageBot\n\
1488            Disallow: /*.gif$\n\
1489            Allow: /public/*.gif$\n\
1490            \n\
1491            User-agent: *\n\
1492            Disallow: /fallback\n",
1493        );
1494        let matcher = robots.matcher();
1495
1496        for (agent, path) in [
1497            ("FooBot", "/foo/page"),
1498            ("FooBot", "/bar/page"),
1499            ("FooBot", "/bar/public/page"),
1500            ("FooBot", "/tie"),
1501            ("ImageBot", "/images/a.gif"),
1502            ("ImageBot", "/images/a.gif?size=large"),
1503            ("ImageBot", "/public/a.gif"),
1504            ("OtherBot", "/fallback/page"),
1505            ("OtherBot", "/public/page"),
1506            ("OtherBot", "/robots.txt"),
1507        ] {
1508            assert_eq!(
1509                matcher.is_allowed(agent, path),
1510                robots.is_allowed(agent, path),
1511                "compiled matcher differed for {agent} {path}"
1512            );
1513        }
1514    }
1515
1516    #[test]
1517    fn compiled_matcher_matches_specialized_rule_indexes() {
1518        let robots = RobotsTxt::parse(
1519            "User-agent: *\n\
1520            Disallow: /exact$\n\
1521            Allow: /exact/public\n\
1522            Disallow: *.secret$\n\
1523            Allow: /download/*/public/*.secret$\n\
1524            Disallow: /download/private/*\n",
1525        );
1526        let matcher = robots.matcher();
1527
1528        for path in [
1529            "/exact",
1530            "/exactly",
1531            "/exact/public/file",
1532            "/a.secret",
1533            "/a.secret?x=1",
1534            "/download/foo/public/file.secret",
1535            "/download/private/file",
1536        ] {
1537            assert_eq!(
1538                matcher.is_allowed("AnyBot", path),
1539                robots.is_allowed("AnyBot", path),
1540                "compiled matcher differed for {path}"
1541            );
1542        }
1543    }
1544
1545    #[test]
1546    fn parse_bytes_rejects_invalid_utf8() {
1547        let error = RobotsTxt::parse_bytes(&[0xff]).expect_err("invalid UTF-8 should fail");
1548
1549        assert!(matches!(error, ParseError::Utf8(_)));
1550    }
1551
1552    #[test]
1553    fn parse_with_options_rejects_oversized_input() {
1554        let error =
1555            RobotsTxt::parse_with_options("User-agent: *\n", ParseOptions { max_bytes: Some(4) })
1556                .expect_err("oversized input should fail");
1557
1558        assert!(matches!(error, ParseError::TooLarge { len: 14, max: 4 }));
1559    }
1560
1561    #[test]
1562    fn parse_with_options_allows_disabled_limit() {
1563        let robots = RobotsTxt::parse_with_options(
1564            "User-agent: *\nDisallow: /private\n",
1565            ParseOptions { max_bytes: None },
1566        )
1567        .expect("disabled size limit should parse");
1568
1569        assert!(!robots.is_allowed("AnyBot", "/private"));
1570    }
1571
1572    #[test]
1573    fn diagnostics_report_soft_parse_issues() {
1574        let report = RobotsTxt::parse_with_diagnostics(
1575            "Disallow: /\nMissing separator\n: value\nUser-agent:\nUser-agent: *\nDisallow: /private\n",
1576        );
1577
1578        assert_eq!(report.warnings.len(), 4);
1579        assert_eq!(
1580            report.warnings,
1581            vec![
1582                ParseWarning {
1583                    line: 1,
1584                    kind: ParseWarningKind::RuleBeforeUserAgent { key: "Disallow" },
1585                },
1586                ParseWarning {
1587                    line: 2,
1588                    kind: ParseWarningKind::MissingSeparator {
1589                        line: "Missing separator",
1590                    },
1591                },
1592                ParseWarning {
1593                    line: 3,
1594                    kind: ParseWarningKind::EmptyDirectiveKey,
1595                },
1596                ParseWarning {
1597                    line: 4,
1598                    kind: ParseWarningKind::EmptyUserAgent,
1599                },
1600            ]
1601        );
1602        assert!(!report.robots.is_allowed("AnyBot", "/private"));
1603    }
1604
1605    #[cfg(feature = "extensions")]
1606    #[test]
1607    fn collects_extensions_without_changing_groups() {
1608        let robots = RobotsTxt::parse(
1609            "Sitemap: https://example.com/sitemap.xml\nUser-agent: Bingbot\nCrawl-delay: 5\nDisallow: /slow\nHost: example.com\nClean-param: ref /shop\nX-Test: value\n",
1610        );
1611
1612        assert_eq!(
1613            robots.extensions.sitemaps,
1614            vec!["https://example.com/sitemap.xml"]
1615        );
1616        assert_eq!(robots.extensions.crawl_delays.len(), 1);
1617        assert_eq!(robots.extensions.crawl_delays[0].agents, vec!["Bingbot"]);
1618        assert_eq!(robots.extensions.crawl_delays[0].value, "5");
1619        assert_eq!(robots.extensions.hosts, vec!["example.com"]);
1620        assert_eq!(robots.extensions.clean_params[0].value, "ref /shop");
1621        assert_eq!(robots.extensions.other[0].key, "X-Test");
1622        assert!(!robots.is_allowed("Bingbot", "/slow"));
1623    }
1624}