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    agent_groups: HashMap<String, Vec<usize>>,
268    fallback_groups: Vec<usize>,
269    compiled_groups: 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    pub fn parse(input: &'a str) -> Self {
400        parse_inner(input, false).robots
401    }
402
403    /// Parses UTF-8 bytes into access rules using [`ParseOptions::default`].
404    ///
405    /// Returns [`ParseError::Utf8`] for invalid UTF-8 and
406    /// [`ParseError::TooLarge`] when the input is larger than
407    /// [`DEFAULT_MAX_BYTES`].
408    ///
409    /// # Examples
410    ///
411    /// ```
412    /// # fn main() -> Result<(), fast_robots::ParseError> {
413    /// use fast_robots::RobotsTxt;
414    ///
415    /// let robots = RobotsTxt::parse_bytes(b"User-agent: *\nDisallow: /tmp\n")?;
416    /// assert!(!robots.is_allowed("ExampleBot", "/tmp/cache"));
417    /// # Ok(())
418    /// # }
419    /// ```
420    pub fn parse_bytes(input: &'a [u8]) -> Result<Self, ParseError> {
421        Self::parse_bytes_with_options(input, ParseOptions::default())
422    }
423
424    /// Parses UTF-8 bytes into access rules with explicit options.
425    ///
426    /// Use this when reading raw bytes and you need a custom size limit.
427    ///
428    /// # Examples
429    ///
430    /// ```
431    /// # fn main() -> Result<(), fast_robots::ParseError> {
432    /// use fast_robots::{ParseOptions, RobotsTxt};
433    ///
434    /// let robots = RobotsTxt::parse_bytes_with_options(
435    ///     b"User-agent: *\nDisallow: /cache\n",
436    ///     ParseOptions { max_bytes: Some(1024) },
437    /// )?;
438    ///
439    /// assert!(!robots.is_allowed("ExampleBot", "/cache/file"));
440    /// # Ok(())
441    /// # }
442    /// ```
443    pub fn parse_bytes_with_options(
444        input: &'a [u8],
445        options: ParseOptions,
446    ) -> Result<Self, ParseError> {
447        check_size(input.len(), options)?;
448        let input = str::from_utf8(input)?;
449        Ok(Self::parse(input))
450    }
451
452    /// Parses a UTF-8 string into access rules with explicit options.
453    ///
454    /// This is useful when the input is already a `str` but should still be
455    /// checked against a maximum size.
456    ///
457    /// # Examples
458    ///
459    /// ```
460    /// # fn main() -> Result<(), fast_robots::ParseError> {
461    /// use fast_robots::{ParseOptions, RobotsTxt};
462    ///
463    /// let robots = RobotsTxt::parse_with_options(
464    ///     "User-agent: *\nDisallow: /private\n",
465    ///     ParseOptions { max_bytes: Some(1024) },
466    /// )?;
467    ///
468    /// assert!(!robots.is_allowed("ExampleBot", "/private"));
469    /// # Ok(())
470    /// # }
471    /// ```
472    pub fn parse_with_options(input: &'a str, options: ParseOptions) -> Result<Self, ParseError> {
473        check_size(input.len(), options)?;
474        Ok(Self::parse(input))
475    }
476
477    /// Parses a UTF-8 string and records recoverable syntax warnings.
478    ///
479    /// Diagnostics do not change parser recovery behavior; they only expose the
480    /// issues that tolerant parsing skipped.
481    ///
482    /// # Examples
483    ///
484    /// ```
485    /// use fast_robots::{ParseWarningKind, RobotsTxt};
486    ///
487    /// let report = RobotsTxt::parse_with_diagnostics(
488    ///     "Disallow: /\nMissing separator\nUser-agent: *\nDisallow: /private\n",
489    /// );
490    ///
491    /// assert_eq!(report.warnings.len(), 2);
492    /// assert!(matches!(
493    ///     report.warnings[0].kind,
494    ///     ParseWarningKind::RuleBeforeUserAgent { .. }
495    /// ));
496    /// assert!(!report.robots.is_allowed("ExampleBot", "/private"));
497    /// ```
498    pub fn parse_with_diagnostics(input: &'a str) -> ParseReport<'a> {
499        parse_inner(input, true)
500    }
501
502    /// Parses a UTF-8 string with diagnostics and explicit options.
503    ///
504    /// # Examples
505    ///
506    /// ```
507    /// # fn main() -> Result<(), fast_robots::ParseError> {
508    /// use fast_robots::{ParseOptions, RobotsTxt};
509    ///
510    /// let report = RobotsTxt::parse_with_diagnostics_options(
511    ///     "User-agent: *\nDisallow: /private\n",
512    ///     ParseOptions { max_bytes: Some(1024) },
513    /// )?;
514    ///
515    /// assert!(report.warnings.is_empty());
516    /// assert!(!report.robots.is_allowed("ExampleBot", "/private"));
517    /// # Ok(())
518    /// # }
519    /// ```
520    pub fn parse_with_diagnostics_options(
521        input: &'a str,
522        options: ParseOptions,
523    ) -> Result<ParseReport<'a>, ParseError> {
524        check_size(input.len(), options)?;
525        Ok(parse_inner(input, true))
526    }
527
528    /// Parses UTF-8 bytes and records recoverable syntax warnings.
529    ///
530    /// Uses [`ParseOptions::default`] for size checking.
531    ///
532    /// # Examples
533    ///
534    /// ```
535    /// # fn main() -> Result<(), fast_robots::ParseError> {
536    /// use fast_robots::RobotsTxt;
537    ///
538    /// let report = RobotsTxt::parse_bytes_with_diagnostics(
539    ///     b"User-agent: *\nDisallow: /private\n",
540    /// )?;
541    ///
542    /// assert!(report.warnings.is_empty());
543    /// assert!(!report.robots.is_allowed("ExampleBot", "/private"));
544    /// # Ok(())
545    /// # }
546    /// ```
547    pub fn parse_bytes_with_diagnostics(input: &'a [u8]) -> Result<ParseReport<'a>, ParseError> {
548        Self::parse_bytes_with_diagnostics_options(input, ParseOptions::default())
549    }
550
551    /// Parses UTF-8 bytes with diagnostics and explicit options.
552    ///
553    /// # Examples
554    ///
555    /// ```
556    /// # fn main() -> Result<(), fast_robots::ParseError> {
557    /// use fast_robots::{ParseOptions, RobotsTxt};
558    ///
559    /// let report = RobotsTxt::parse_bytes_with_diagnostics_options(
560    ///     b"User-agent: *\nDisallow: /private\n",
561    ///     ParseOptions { max_bytes: Some(1024) },
562    /// )?;
563    ///
564    /// assert!(report.warnings.is_empty());
565    /// # Ok(())
566    /// # }
567    /// ```
568    pub fn parse_bytes_with_diagnostics_options(
569        input: &'a [u8],
570        options: ParseOptions,
571    ) -> Result<ParseReport<'a>, ParseError> {
572        check_size(input.len(), options)?;
573        let input = str::from_utf8(input)?;
574        Ok(parse_inner(input, true))
575    }
576
577    /// Builds an indexed matcher for repeated access checks.
578    ///
579    /// The returned matcher borrows this parsed file, indexes user-agent groups
580    /// and rule prefixes, and pre-splits wildcard patterns. Use it when checking
581    /// many URLs against the same `robots.txt`; for one-off checks,
582    /// [`RobotsTxt::is_allowed`] avoids the upfront allocation cost.
583    ///
584    /// # Examples
585    ///
586    /// ```
587    /// use fast_robots::RobotsTxt;
588    ///
589    /// let robots = RobotsTxt::parse("User-agent: *\nDisallow: /private\n");
590    /// let matcher = robots.matcher();
591    ///
592    /// assert!(!matcher.is_allowed("ExampleBot", "/private/file"));
593    /// assert!(matcher.is_allowed("ExampleBot", "/public/file"));
594    /// ```
595    pub fn matcher(&'a self) -> RobotsMatcher<'a> {
596        RobotsMatcher::new(self)
597    }
598
599    /// Returns whether `user_agent` may crawl `path`.
600    ///
601    /// The matcher implements the core RFC 9309 access semantics used by this
602    /// crate: exact user-agent groups are considered before the `*` fallback,
603    /// matching exact groups are merged, the longest matching pattern wins, and
604    /// `Allow` wins ties. `/robots.txt` is always allowed.
605    ///
606    /// `path` should be the URL path and optional query string, not a full URL.
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// use fast_robots::RobotsTxt;
612    ///
613    /// let robots = RobotsTxt::parse(
614    ///     "User-agent: *\n\
615    ///      Disallow: /private\n\
616    ///      Allow: /private/public\n",
617    /// );
618    ///
619    /// assert!(!robots.is_allowed("ExampleBot", "/private/file"));
620    /// assert!(robots.is_allowed("ExampleBot", "/private/public/file"));
621    /// assert!(robots.is_allowed("ExampleBot", "/robots.txt"));
622    /// ```
623    pub fn is_allowed(&self, user_agent: &str, path: &str) -> bool {
624        if path == "/robots.txt" {
625            return true;
626        }
627
628        let mut exact_match = false;
629        let mut best: Option<(usize, RuleKind)> = None;
630
631        for group in &self.groups {
632            if group
633                .agents
634                .iter()
635                .any(|agent| *agent != "*" && agent.eq_ignore_ascii_case(user_agent))
636            {
637                exact_match = true;
638                apply_group_rules(group, path, &mut best);
639            }
640        }
641
642        if !exact_match {
643            for group in &self.groups {
644                if group.agents.contains(&"*") {
645                    apply_group_rules(group, path, &mut best);
646                }
647            }
648        }
649
650        rule_decision(best)
651    }
652}
653
654impl<'a> RobotsMatcher<'a> {
655    fn new(robots: &'a RobotsTxt<'a>) -> Self {
656        let groups = robots.groups.as_slice();
657        let mut agent_groups: HashMap<String, Vec<usize>> = HashMap::new();
658        let mut fallback_groups = vec![];
659        let mut compiled_groups = Vec::with_capacity(groups.len());
660
661        for (group_index, group) in groups.iter().enumerate() {
662            for agent in &group.agents {
663                if *agent == "*" {
664                    fallback_groups.push(group_index);
665                } else {
666                    let indexes = agent_groups.entry(agent.to_ascii_lowercase()).or_default();
667                    if !indexes.contains(&group_index) {
668                        indexes.push(group_index);
669                    }
670                }
671            }
672
673            compiled_groups.push(CompiledGroup::new(group));
674        }
675
676        Self {
677            agent_groups,
678            fallback_groups,
679            compiled_groups,
680        }
681    }
682
683    /// Returns whether `user_agent` may crawl `path` using the prebuilt index.
684    ///
685    /// This has the same access semantics as [`RobotsTxt::is_allowed`], including
686    /// exact user-agent precedence over `*`, merged exact groups, longest-match
687    /// rule selection, `Allow` tie wins, and implicit allowance for `/robots.txt`.
688    pub fn is_allowed(&self, user_agent: &str, path: &str) -> bool {
689        if path == "/robots.txt" {
690            return true;
691        }
692
693        let mut best: Option<(usize, RuleKind)> = None;
694        let agent = user_agent.to_ascii_lowercase();
695
696        if let Some(group_indexes) = self.agent_groups.get(&agent) {
697            self.apply_group_indexes(group_indexes, path, &mut best);
698        } else {
699            self.apply_group_indexes(&self.fallback_groups, path, &mut best);
700        }
701
702        rule_decision(best)
703    }
704
705    fn apply_group_indexes(
706        &self,
707        group_indexes: &[usize],
708        path: &str,
709        best: &mut Option<(usize, RuleKind)>,
710    ) {
711        for &group_index in group_indexes {
712            self.compiled_groups[group_index].apply_rules(path, best);
713        }
714    }
715}
716
717impl<'a> CompiledGroup<'a> {
718    fn new(group: &Group<'a>) -> Self {
719        let mut compiled = Self {
720            prefix_rules: RuleTrie::new(),
721            exact_rules: HashMap::new(),
722            wildcard_rules: vec![],
723            wildcard_prefixes: WildcardRuleTrie::new(),
724        };
725
726        for rule in &group.rules {
727            compiled.push_rule(rule);
728        }
729
730        compiled
731    }
732
733    fn push_rule(&mut self, rule: &Rule<'a>) {
734        if rule.pattern.is_empty() {
735            return;
736        }
737
738        let (pattern, anchored) = strip_end_anchor(rule.pattern);
739        let pattern_bytes = pattern.as_bytes();
740
741        if let Some(first_wildcard) = memchr(b'*', pattern_bytes) {
742            let rule_index = self.wildcard_rules.len();
743            self.wildcard_prefixes
744                .insert(&pattern_bytes[..first_wildcard], rule_index);
745            self.wildcard_rules.push(WildcardRule::new(
746                rule.kind,
747                pattern,
748                anchored,
749                first_wildcard,
750            ));
751        } else if anchored {
752            let decision = self.exact_rules.entry(pattern).or_insert(rule.kind);
753            if rule.kind == RuleKind::Allow {
754                *decision = RuleKind::Allow;
755            }
756        } else {
757            self.prefix_rules
758                .insert(pattern_bytes, pattern.len(), rule.kind);
759        }
760    }
761
762    fn apply_rules(&self, path: &str, best: &mut Option<(usize, RuleKind)>) {
763        let path_bytes = path.as_bytes();
764
765        self.prefix_rules.apply_matches(path_bytes, best);
766
767        if let Some(&kind) = self.exact_rules.get(path) {
768            apply_rule_decision(path.len(), kind, best);
769        }
770
771        self.wildcard_prefixes
772            .apply_matches(path_bytes, &self.wildcard_rules, best);
773    }
774}
775
776impl RuleTrie {
777    fn new() -> Self {
778        Self {
779            nodes: vec![RuleTrieNode::default()],
780        }
781    }
782
783    fn insert(&mut self, pattern: &[u8], specificity: usize, kind: RuleKind) {
784        let mut node_index = 0;
785        for &byte in pattern {
786            node_index = self.child_or_insert(node_index, byte);
787        }
788
789        apply_rule_decision(specificity, kind, &mut self.nodes[node_index].decision);
790    }
791
792    fn apply_matches(&self, path: &[u8], best: &mut Option<(usize, RuleKind)>) {
793        let mut node_index = 0;
794
795        for &byte in path {
796            let Some(next_index) = self.child(node_index, byte) else {
797                return;
798            };
799            node_index = next_index;
800
801            if let Some((specificity, kind)) = self.nodes[node_index].decision {
802                apply_rule_decision(specificity, kind, best);
803            }
804        }
805    }
806
807    fn child(&self, node_index: usize, byte: u8) -> Option<usize> {
808        self.nodes[node_index]
809            .edges
810            .iter()
811            .find_map(|&(edge_byte, child_index)| (edge_byte == byte).then_some(child_index))
812    }
813
814    fn child_or_insert(&mut self, node_index: usize, byte: u8) -> usize {
815        if let Some(child_index) = self.child(node_index, byte) {
816            return child_index;
817        }
818
819        let child_index = self.nodes.len();
820        self.nodes.push(RuleTrieNode::default());
821        self.nodes[node_index].edges.push((byte, child_index));
822        child_index
823    }
824}
825
826impl WildcardRuleTrie {
827    fn new() -> Self {
828        Self {
829            nodes: vec![WildcardRuleTrieNode::default()],
830        }
831    }
832
833    fn insert(&mut self, literal_prefix: &[u8], rule_index: usize) {
834        let mut node_index = 0;
835        for &byte in literal_prefix {
836            node_index = self.child_or_insert(node_index, byte);
837        }
838
839        self.nodes[node_index].rule_indexes.push(rule_index);
840    }
841
842    fn apply_matches(
843        &self,
844        path: &[u8],
845        rules: &[WildcardRule<'_>],
846        best: &mut Option<(usize, RuleKind)>,
847    ) {
848        let mut node_index = 0;
849        self.apply_node_rules(node_index, path, rules, best);
850
851        for &byte in path {
852            let Some(next_index) = self.child(node_index, byte) else {
853                return;
854            };
855            node_index = next_index;
856            self.apply_node_rules(node_index, path, rules, best);
857        }
858    }
859
860    fn apply_node_rules(
861        &self,
862        node_index: usize,
863        path: &[u8],
864        rules: &[WildcardRule<'_>],
865        best: &mut Option<(usize, RuleKind)>,
866    ) {
867        for &rule_index in &self.nodes[node_index].rule_indexes {
868            let rule = &rules[rule_index];
869            let Some(specificity) = rule.matching_specificity(path) else {
870                continue;
871            };
872
873            apply_rule_decision(specificity, rule.kind, best);
874        }
875    }
876
877    fn child(&self, node_index: usize, byte: u8) -> Option<usize> {
878        self.nodes[node_index]
879            .edges
880            .iter()
881            .find_map(|&(edge_byte, child_index)| (edge_byte == byte).then_some(child_index))
882    }
883
884    fn child_or_insert(&mut self, node_index: usize, byte: u8) -> usize {
885        if let Some(child_index) = self.child(node_index, byte) {
886            return child_index;
887        }
888
889        let child_index = self.nodes.len();
890        self.nodes.push(WildcardRuleTrieNode::default());
891        self.nodes[node_index].edges.push((byte, child_index));
892        child_index
893    }
894}
895
896impl<'a> WildcardRule<'a> {
897    fn new(kind: RuleKind, pattern: &'a str, anchored: bool, first_wildcard: usize) -> Self {
898        let pattern_bytes = pattern.as_bytes();
899        let segments = pattern_bytes[first_wildcard + 1..]
900            .split(|byte| *byte == b'*')
901            .filter(|segment| !segment.is_empty())
902            .collect();
903
904        Self {
905            kind,
906            first_len: first_wildcard,
907            segments,
908            anchored,
909            ends_with_star: pattern_bytes.last() == Some(&b'*'),
910            specificity: pattern.len(),
911        }
912    }
913
914    fn matching_specificity(&self, path: &[u8]) -> Option<usize> {
915        let mut offset = self.first_len;
916
917        for segment in &self.segments {
918            let found = memmem::find(&path[offset..], segment)?;
919            offset += found + segment.len();
920        }
921
922        (!self.anchored || self.ends_with_star || offset == path.len()).then_some(self.specificity)
923    }
924}
925
926/// Checks an input length against the configured parser size limit.
927fn check_size(len: usize, options: ParseOptions) -> Result<(), ParseError> {
928    if let Some(max) = options.max_bytes {
929        if len > max {
930            return Err(ParseError::TooLarge { len, max });
931        }
932    }
933
934    Ok(())
935}
936
937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
938enum DirectiveKind {
939    UserAgent,
940    Allow,
941    Disallow,
942    #[cfg(feature = "extensions")]
943    Sitemap,
944    #[cfg(feature = "extensions")]
945    CrawlDelay,
946    #[cfg(feature = "extensions")]
947    Host,
948    #[cfg(feature = "extensions")]
949    CleanParam,
950    Other,
951}
952
953fn classify_directive_key(key: &str) -> DirectiveKind {
954    match key.as_bytes() {
955        b"Allow" | b"allow" => return DirectiveKind::Allow,
956        b"Disallow" | b"disallow" => return DirectiveKind::Disallow,
957        b"User-agent" | b"user-agent" => return DirectiveKind::UserAgent,
958        #[cfg(feature = "extensions")]
959        b"Host" | b"host" => return DirectiveKind::Host,
960        #[cfg(feature = "extensions")]
961        b"Sitemap" | b"sitemap" => return DirectiveKind::Sitemap,
962        #[cfg(feature = "extensions")]
963        b"Crawl-delay" | b"crawl-delay" => return DirectiveKind::CrawlDelay,
964        #[cfg(feature = "extensions")]
965        b"Clean-param" | b"clean-param" => return DirectiveKind::CleanParam,
966        _ => {}
967    }
968
969    classify_directive_key_ignore_case(key)
970}
971
972#[cold]
973#[inline(never)]
974fn classify_directive_key_ignore_case(key: &str) -> DirectiveKind {
975    match key.len() {
976        5 if key.eq_ignore_ascii_case("allow") => DirectiveKind::Allow,
977        8 if key.eq_ignore_ascii_case("disallow") => DirectiveKind::Disallow,
978        10 if key.eq_ignore_ascii_case("user-agent") => DirectiveKind::UserAgent,
979        #[cfg(feature = "extensions")]
980        4 if key.eq_ignore_ascii_case("host") => DirectiveKind::Host,
981        #[cfg(feature = "extensions")]
982        7 if key.eq_ignore_ascii_case("sitemap") => DirectiveKind::Sitemap,
983        #[cfg(feature = "extensions")]
984        11 if key.eq_ignore_ascii_case("crawl-delay") => DirectiveKind::CrawlDelay,
985        #[cfg(feature = "extensions")]
986        11 if key.eq_ignore_ascii_case("clean-param") => DirectiveKind::CleanParam,
987        _ => DirectiveKind::Other,
988    }
989}
990
991fn new_group<'a>(agent: &'a str) -> Group<'a> {
992    Group {
993        agents: vec![agent],
994        rules: Vec::with_capacity(4),
995    }
996}
997
998/// Shared parser implementation for tolerant and diagnostics-enabled parsing.
999///
1000/// The parser walks the file one line at a time, strips comments and ASCII
1001/// whitespace, tracks the current user-agent group, and optionally records soft
1002/// failures as [`ParseWarning`] values.
1003fn parse_inner<'a>(input: &'a str, diagnostics: bool) -> ParseReport<'a> {
1004    let mut groups = vec![];
1005    let mut current: Option<Group<'a>> = None;
1006    let mut current_has_rules = false;
1007    let mut warnings = vec![];
1008
1009    #[cfg(feature = "extensions")]
1010    let mut extensions = Extensions::default();
1011
1012    for (line_number, line) in Lines::new(input) {
1013        let line = trim_ascii(strip_comment(line));
1014        if line.is_empty() {
1015            continue;
1016        }
1017
1018        let Some((key, value)) = split_directive(line) else {
1019            if diagnostics {
1020                warnings.push(ParseWarning {
1021                    line: line_number,
1022                    kind: ParseWarningKind::MissingSeparator { line },
1023                });
1024            }
1025            continue;
1026        };
1027
1028        let key = trim_ascii(key);
1029        let value = trim_ascii(value);
1030        if key.is_empty() {
1031            if diagnostics {
1032                warnings.push(ParseWarning {
1033                    line: line_number,
1034                    kind: ParseWarningKind::EmptyDirectiveKey,
1035                });
1036            }
1037            continue;
1038        }
1039
1040        let directive = classify_directive_key(key);
1041
1042        match directive {
1043            DirectiveKind::UserAgent => {
1044                if value.is_empty() {
1045                    if diagnostics {
1046                        warnings.push(ParseWarning {
1047                            line: line_number,
1048                            kind: ParseWarningKind::EmptyUserAgent,
1049                        });
1050                    }
1051                    continue;
1052                };
1053
1054                match current.as_mut() {
1055                    Some(group) if !current_has_rules => group.agents.push(value),
1056                    Some(_) => {
1057                        groups.push(current.take().expect("current group exists"));
1058                        current = Some(new_group(value));
1059                        current_has_rules = false;
1060                    }
1061                    None => {
1062                        current = Some(new_group(value));
1063                    }
1064                }
1065            }
1066            DirectiveKind::Allow | DirectiveKind::Disallow => {
1067                let Some(group) = current.as_mut() else {
1068                    if diagnostics {
1069                        warnings.push(ParseWarning {
1070                            line: line_number,
1071                            kind: ParseWarningKind::RuleBeforeUserAgent { key },
1072                        });
1073                    }
1074                    continue;
1075                };
1076
1077                let kind = match directive {
1078                    DirectiveKind::Allow => RuleKind::Allow,
1079                    DirectiveKind::Disallow => RuleKind::Disallow,
1080                    _ => unreachable!("only allow/disallow directives reach this branch"),
1081                };
1082
1083                group.rules.push(Rule {
1084                    kind,
1085                    pattern: value,
1086                });
1087                current_has_rules = true;
1088            }
1089            _ => {
1090                #[cfg(feature = "extensions")]
1091                collect_extension(&mut extensions, current.as_ref(), directive, key, value);
1092            }
1093        }
1094    }
1095
1096    if let Some(group) = current {
1097        groups.push(group);
1098    }
1099
1100    ParseReport {
1101        robots: RobotsTxt {
1102            groups,
1103            #[cfg(feature = "extensions")]
1104            extensions,
1105        },
1106        warnings,
1107    }
1108}
1109
1110/// Applies matching rules from a group to the current best access decision.
1111///
1112/// `best` stores the specificity and kind of the strongest matching rule seen
1113/// so far. More specific patterns replace less specific ones, and `Allow`
1114/// replaces `Disallow` on ties.
1115fn apply_group_rules(group: &Group<'_>, path: &str, best: &mut Option<(usize, RuleKind)>) {
1116    for rule in &group.rules {
1117        let Some(specificity) = matching_specificity(rule.pattern, path) else {
1118            continue;
1119        };
1120
1121        apply_rule_decision(specificity, rule.kind, best);
1122    }
1123}
1124
1125fn apply_rule_decision(specificity: usize, kind: RuleKind, best: &mut Option<(usize, RuleKind)>) {
1126    let should_replace = !matches!(
1127        *best,
1128        Some((best_specificity, best_kind))
1129            if specificity < best_specificity
1130                || (specificity == best_specificity
1131                    && !(kind == RuleKind::Allow && best_kind == RuleKind::Disallow))
1132    );
1133
1134    if should_replace {
1135        *best = Some((specificity, kind));
1136    }
1137}
1138
1139fn rule_decision(best: Option<(usize, RuleKind)>) -> bool {
1140    match best {
1141        Some((_, RuleKind::Allow)) | None => true,
1142        Some((_, RuleKind::Disallow)) => false,
1143    }
1144}
1145
1146/// Returns matching specificity for robots longest-match rule selection.
1147///
1148/// Patterns without wildcards use the common prefix fast path. A trailing `$`
1149/// requires the match to consume the whole path but does not increase
1150/// specificity.
1151fn matching_specificity(pattern: &str, path: &str) -> Option<usize> {
1152    if pattern.is_empty() {
1153        return None;
1154    }
1155
1156    let (pattern, anchored) = strip_end_anchor(pattern);
1157    let matched = if pattern.as_bytes().contains(&b'*') {
1158        glob_matches(pattern.as_bytes(), path.as_bytes(), anchored)
1159    } else if anchored {
1160        path == pattern
1161    } else {
1162        path.starts_with(pattern)
1163    };
1164
1165    matched.then_some(pattern.len())
1166}
1167
1168/// Matches a `*` wildcard pattern against a path byte slice.
1169///
1170/// The first pattern segment must match at the start of the path; remaining
1171/// segments are located in order with SIMD-backed substring search.
1172fn glob_matches(pattern: &[u8], path: &[u8], anchored: bool) -> bool {
1173    let mut parts = pattern.split(|byte| *byte == b'*');
1174    let Some(first) = parts.next() else {
1175        return true;
1176    };
1177
1178    if !path.starts_with(first) {
1179        return false;
1180    }
1181
1182    let mut offset = first.len();
1183    let mut ends_with_star = pattern.last() == Some(&b'*');
1184
1185    for part in parts {
1186        if part.is_empty() {
1187            ends_with_star = true;
1188            continue;
1189        }
1190
1191        ends_with_star = false;
1192        let Some(found) = memmem::find(&path[offset..], part) else {
1193            return false;
1194        };
1195        offset += found + part.len();
1196    }
1197
1198    !anchored || ends_with_star || offset == path.len()
1199}
1200
1201fn strip_end_anchor(pattern: &str) -> (&str, bool) {
1202    match pattern.strip_suffix('$') {
1203        Some(pattern) => (pattern, true),
1204        None => (pattern, false),
1205    }
1206}
1207
1208#[cfg(feature = "extensions")]
1209/// Stores a non-core directive in the feature-gated extension metadata.
1210///
1211/// Extension directives intentionally do not alter group boundaries or access
1212/// rules. `Crawl-delay` snapshots the current group agents so callers can
1213/// associate the value with the group where it appeared.
1214fn collect_extension<'a>(
1215    extensions: &mut Extensions<'a>,
1216    current: Option<&Group<'a>>,
1217    directive: DirectiveKind,
1218    key: &'a str,
1219    value: &'a str,
1220) {
1221    match directive {
1222        DirectiveKind::Sitemap => {
1223            if !value.is_empty() {
1224                extensions.sitemaps.push(value);
1225            }
1226        }
1227        DirectiveKind::CrawlDelay => {
1228            extensions.crawl_delays.push(CrawlDelay {
1229                agents: current
1230                    .map(|group| group.agents.clone())
1231                    .unwrap_or_default(),
1232                value,
1233            });
1234        }
1235        DirectiveKind::Host => {
1236            if !value.is_empty() {
1237                extensions.hosts.push(value);
1238            }
1239        }
1240        DirectiveKind::CleanParam => {
1241            if !value.is_empty() {
1242                extensions.clean_params.push(CleanParam { value });
1243            }
1244        }
1245        _ => {
1246            extensions.other.push(Directive { key, value });
1247        }
1248    }
1249}
1250
1251/// Removes an inline `#` comment from a line.
1252fn strip_comment(line: &str) -> &str {
1253    match memchr(b'#', line.as_bytes()) {
1254        Some(index) => &line[..index],
1255        None => line,
1256    }
1257}
1258
1259/// Splits a directive line into raw key and value slices.
1260///
1261/// Only the first `:` is structural; additional colons remain part of the value.
1262fn split_directive(line: &str) -> Option<(&str, &str)> {
1263    let index = memchr(b':', line.as_bytes())?;
1264    Some((&line[..index], &line[index + 1..]))
1265}
1266
1267/// Trims ASCII spaces and tabs from both ends of a directive fragment.
1268///
1269/// Robots directives are byte-oriented, so this deliberately avoids full
1270/// Unicode whitespace handling.
1271fn trim_ascii(value: &str) -> &str {
1272    let bytes = value.as_bytes();
1273    let Some((&first, rest)) = bytes.split_first() else {
1274        return value;
1275    };
1276    let last = rest.last().copied().unwrap_or(first);
1277
1278    if !matches!(first, b' ' | b'\t') && !matches!(last, b' ' | b'\t') {
1279        return value;
1280    }
1281
1282    let mut start = 0;
1283    let mut end = bytes.len();
1284
1285    while start < end && matches!(bytes[start], b' ' | b'\t') {
1286        start += 1;
1287    }
1288    while end > start && matches!(bytes[end - 1], b' ' | b'\t') {
1289        end -= 1;
1290    }
1291
1292    &value[start..end]
1293}
1294
1295/// Iterator over input lines with one-based source line numbers.
1296///
1297/// Handles both LF and CRLF endings while keeping returned line slices borrowed
1298/// from the original input.
1299struct Lines<'a> {
1300    input: &'a str,
1301    offset: usize,
1302    line: usize,
1303}
1304
1305impl<'a> Lines<'a> {
1306    /// Creates a line iterator for `input`.
1307    fn new(input: &'a str) -> Self {
1308        Self {
1309            input,
1310            offset: 0,
1311            line: 1,
1312        }
1313    }
1314}
1315
1316impl<'a> Iterator for Lines<'a> {
1317    type Item = (usize, &'a str);
1318
1319    /// Returns the next line and its one-based line number.
1320    fn next(&mut self) -> Option<Self::Item> {
1321        if self.offset > self.input.len() {
1322            return None;
1323        }
1324
1325        let remaining = &self.input[self.offset..];
1326        if remaining.is_empty() {
1327            self.offset += 1;
1328            return None;
1329        }
1330
1331        let line_end = memchr(b'\n', remaining.as_bytes()).unwrap_or(remaining.len());
1332        let mut line = &remaining[..line_end];
1333        if let Some(stripped) = line.strip_suffix('\r') {
1334            line = stripped;
1335        }
1336
1337        let line_number = self.line;
1338        self.line += 1;
1339        self.offset += line_end + 1;
1340        Some((line_number, line))
1341    }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346    use super::*;
1347
1348    #[test]
1349    fn parses_groups_comments_and_crlf() {
1350        let robots = RobotsTxt::parse(
1351            "# ignored\r\nUser-agent: FooBot\r\nUser-agent: BarBot # same group\r\nDisallow: /private\r\nAllow: /private/public\r\n",
1352        );
1353
1354        assert_eq!(robots.groups.len(), 1);
1355        assert_eq!(robots.groups[0].agents, vec!["FooBot", "BarBot"]);
1356        assert_eq!(robots.groups[0].rules.len(), 2);
1357        assert!(!robots.is_allowed("FooBot", "/private/file"));
1358        assert!(robots.is_allowed("FooBot", "/private/public/file"));
1359    }
1360
1361    #[test]
1362    fn parses_directive_keys_case_insensitively() {
1363        let robots =
1364            RobotsTxt::parse("uSeR-aGeNt: FooBot\nDiSaLlOw: /private\nAlLoW: /private/public\n");
1365
1366        assert!(!robots.is_allowed("FooBot", "/private/file"));
1367        assert!(robots.is_allowed("FooBot", "/private/public/file"));
1368    }
1369
1370    #[test]
1371    fn ignores_rules_before_first_user_agent() {
1372        let robots = RobotsTxt::parse("Disallow: /\nUser-agent: *\nAllow: /\n");
1373
1374        assert!(robots.is_allowed("AnyBot", "/anything"));
1375    }
1376
1377    #[test]
1378    fn starts_new_group_after_rules() {
1379        let robots = RobotsTxt::parse(
1380            "User-agent: FooBot\nDisallow: /foo\nUser-agent: BarBot\nDisallow: /bar\n",
1381        );
1382
1383        assert_eq!(robots.groups.len(), 2);
1384        assert!(!robots.is_allowed("FooBot", "/foo"));
1385        assert!(robots.is_allowed("FooBot", "/bar"));
1386        assert!(!robots.is_allowed("BarBot", "/bar"));
1387    }
1388
1389    #[test]
1390    fn merges_multiple_exact_matching_groups() {
1391        let robots = RobotsTxt::parse(
1392            "User-agent: FooBot\nDisallow: /foo\n\nUser-agent: FooBot\nDisallow: /bar\n",
1393        );
1394
1395        assert!(!robots.is_allowed("FooBot", "/foo"));
1396        assert!(!robots.is_allowed("FooBot", "/bar"));
1397    }
1398
1399    #[test]
1400    fn falls_back_to_star_group() {
1401        let robots =
1402            RobotsTxt::parse("User-agent: *\nDisallow: /all\nUser-agent: FooBot\nAllow: /\n");
1403
1404        assert!(!robots.is_allowed("OtherBot", "/all"));
1405        assert!(robots.is_allowed("FooBot", "/all"));
1406    }
1407
1408    #[test]
1409    fn longest_match_wins_and_allow_wins_ties() {
1410        let robots = RobotsTxt::parse(
1411            "User-agent: *\nDisallow: /example/\nAllow: /example/public\nDisallow: /tie\nAllow: /tie\n",
1412        );
1413
1414        assert!(!robots.is_allowed("AnyBot", "/example/private"));
1415        assert!(robots.is_allowed("AnyBot", "/example/public/page"));
1416        assert!(robots.is_allowed("AnyBot", "/tie"));
1417    }
1418
1419    #[test]
1420    fn supports_wildcard_and_end_anchor() {
1421        let robots = RobotsTxt::parse("User-agent: *\nDisallow: /*.gif$\nAllow: /public/*.gif$\n");
1422
1423        assert!(!robots.is_allowed("AnyBot", "/images/a.gif"));
1424        assert!(robots.is_allowed("AnyBot", "/images/a.gif?size=large"));
1425        assert!(robots.is_allowed("AnyBot", "/public/a.gif"));
1426    }
1427
1428    #[test]
1429    fn empty_disallow_does_not_block() {
1430        let robots = RobotsTxt::parse("User-agent: *\nDisallow:\n");
1431
1432        assert!(robots.is_allowed("AnyBot", "/anything"));
1433    }
1434
1435    #[test]
1436    fn robots_txt_is_implicitly_allowed() {
1437        let robots = RobotsTxt::parse("User-agent: *\nDisallow: /\n");
1438
1439        assert!(robots.is_allowed("AnyBot", "/robots.txt"));
1440    }
1441
1442    #[test]
1443    fn compiled_matcher_matches_regular_matcher_for_core_rules() {
1444        let robots = RobotsTxt::parse(
1445            "User-agent: FooBot\n\
1446            Disallow: /foo\n\
1447            \n\
1448            User-agent: FooBot\n\
1449            Disallow: /bar\n\
1450            Allow: /bar/public\n\
1451            Disallow: /tie\n\
1452            Allow: /tie\n\
1453            \n\
1454            User-agent: ImageBot\n\
1455            Disallow: /*.gif$\n\
1456            Allow: /public/*.gif$\n\
1457            \n\
1458            User-agent: *\n\
1459            Disallow: /fallback\n",
1460        );
1461        let matcher = robots.matcher();
1462
1463        for (agent, path) in [
1464            ("FooBot", "/foo/page"),
1465            ("FooBot", "/bar/page"),
1466            ("FooBot", "/bar/public/page"),
1467            ("FooBot", "/tie"),
1468            ("ImageBot", "/images/a.gif"),
1469            ("ImageBot", "/images/a.gif?size=large"),
1470            ("ImageBot", "/public/a.gif"),
1471            ("OtherBot", "/fallback/page"),
1472            ("OtherBot", "/public/page"),
1473            ("OtherBot", "/robots.txt"),
1474        ] {
1475            assert_eq!(
1476                matcher.is_allowed(agent, path),
1477                robots.is_allowed(agent, path),
1478                "compiled matcher differed for {agent} {path}"
1479            );
1480        }
1481    }
1482
1483    #[test]
1484    fn compiled_matcher_matches_specialized_rule_indexes() {
1485        let robots = RobotsTxt::parse(
1486            "User-agent: *\n\
1487            Disallow: /exact$\n\
1488            Allow: /exact/public\n\
1489            Disallow: *.secret$\n\
1490            Allow: /download/*/public/*.secret$\n\
1491            Disallow: /download/private/*\n",
1492        );
1493        let matcher = robots.matcher();
1494
1495        for path in [
1496            "/exact",
1497            "/exactly",
1498            "/exact/public/file",
1499            "/a.secret",
1500            "/a.secret?x=1",
1501            "/download/foo/public/file.secret",
1502            "/download/private/file",
1503        ] {
1504            assert_eq!(
1505                matcher.is_allowed("AnyBot", path),
1506                robots.is_allowed("AnyBot", path),
1507                "compiled matcher differed for {path}"
1508            );
1509        }
1510    }
1511
1512    #[test]
1513    fn parse_bytes_rejects_invalid_utf8() {
1514        let error = RobotsTxt::parse_bytes(&[0xff]).expect_err("invalid UTF-8 should fail");
1515
1516        assert!(matches!(error, ParseError::Utf8(_)));
1517    }
1518
1519    #[test]
1520    fn parse_with_options_rejects_oversized_input() {
1521        let error =
1522            RobotsTxt::parse_with_options("User-agent: *\n", ParseOptions { max_bytes: Some(4) })
1523                .expect_err("oversized input should fail");
1524
1525        assert!(matches!(error, ParseError::TooLarge { len: 14, max: 4 }));
1526    }
1527
1528    #[test]
1529    fn parse_with_options_allows_disabled_limit() {
1530        let robots = RobotsTxt::parse_with_options(
1531            "User-agent: *\nDisallow: /private\n",
1532            ParseOptions { max_bytes: None },
1533        )
1534        .expect("disabled size limit should parse");
1535
1536        assert!(!robots.is_allowed("AnyBot", "/private"));
1537    }
1538
1539    #[test]
1540    fn diagnostics_report_soft_parse_issues() {
1541        let report = RobotsTxt::parse_with_diagnostics(
1542            "Disallow: /\nMissing separator\n: value\nUser-agent:\nUser-agent: *\nDisallow: /private\n",
1543        );
1544
1545        assert_eq!(report.warnings.len(), 4);
1546        assert_eq!(
1547            report.warnings,
1548            vec![
1549                ParseWarning {
1550                    line: 1,
1551                    kind: ParseWarningKind::RuleBeforeUserAgent { key: "Disallow" },
1552                },
1553                ParseWarning {
1554                    line: 2,
1555                    kind: ParseWarningKind::MissingSeparator {
1556                        line: "Missing separator",
1557                    },
1558                },
1559                ParseWarning {
1560                    line: 3,
1561                    kind: ParseWarningKind::EmptyDirectiveKey,
1562                },
1563                ParseWarning {
1564                    line: 4,
1565                    kind: ParseWarningKind::EmptyUserAgent,
1566                },
1567            ]
1568        );
1569        assert!(!report.robots.is_allowed("AnyBot", "/private"));
1570    }
1571
1572    #[cfg(feature = "extensions")]
1573    #[test]
1574    fn collects_extensions_without_changing_groups() {
1575        let robots = RobotsTxt::parse(
1576            "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",
1577        );
1578
1579        assert_eq!(
1580            robots.extensions.sitemaps,
1581            vec!["https://example.com/sitemap.xml"]
1582        );
1583        assert_eq!(robots.extensions.crawl_delays.len(), 1);
1584        assert_eq!(robots.extensions.crawl_delays[0].agents, vec!["Bingbot"]);
1585        assert_eq!(robots.extensions.crawl_delays[0].value, "5");
1586        assert_eq!(robots.extensions.hosts, vec!["example.com"]);
1587        assert_eq!(robots.extensions.clean_params[0].value, "ref /shop");
1588        assert_eq!(robots.extensions.other[0].key, "X-Test");
1589        assert!(!robots.is_allowed("Bingbot", "/slow"));
1590    }
1591}