Skip to main content

use_robots/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned by robots primitive constructors.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum RobotsValueError {
10    /// Bot name was empty after trimming whitespace.
11    EmptyBotName,
12    /// Bot name contained unsupported characters.
13    InvalidBotName,
14}
15
16impl fmt::Display for RobotsValueError {
17    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::EmptyBotName => formatter.write_str("bot name cannot be empty"),
20            Self::InvalidBotName => {
21                formatter.write_str("bot name must contain visible ASCII characters")
22            },
23        }
24    }
25}
26
27impl Error for RobotsValueError {}
28
29/// Bot name label used with robots directives.
30#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub struct BotName(String);
32
33impl BotName {
34    /// Creates a bot name label.
35    ///
36    /// # Errors
37    ///
38    /// Returns [`RobotsValueError`] when the bot name is empty or unsupported.
39    pub fn new(value: impl AsRef<str>) -> Result<Self, RobotsValueError> {
40        let trimmed = value.as_ref().trim();
41        if trimmed.is_empty() {
42            return Err(RobotsValueError::EmptyBotName);
43        }
44        if trimmed.bytes().all(|byte| byte.is_ascii_graphic()) {
45            Ok(Self(trimmed.to_string()))
46        } else {
47            Err(RobotsValueError::InvalidBotName)
48        }
49    }
50
51    /// Returns the bot name.
52    #[must_use]
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl AsRef<str> for BotName {
59    fn as_ref(&self) -> &str {
60        self.as_str()
61    }
62}
63
64impl fmt::Display for BotName {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        formatter.write_str(self.as_str())
67    }
68}
69
70impl FromStr for BotName {
71    type Err = RobotsValueError;
72
73    fn from_str(value: &str) -> Result<Self, Self::Err> {
74        Self::new(value)
75    }
76}
77
78/// Indexing directive.
79#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
80pub enum IndexDirective {
81    /// Allow indexing.
82    Index,
83    /// Disallow indexing.
84    NoIndex,
85}
86
87impl IndexDirective {
88    /// Returns the directive label.
89    #[must_use]
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::Index => "index",
93            Self::NoIndex => "noindex",
94        }
95    }
96}
97
98/// Link-following directive.
99#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
100pub enum FollowDirective {
101    /// Allow following links.
102    Follow,
103    /// Disallow following links.
104    NoFollow,
105}
106
107impl FollowDirective {
108    /// Returns the directive label.
109    #[must_use]
110    pub const fn as_str(self) -> &'static str {
111        match self {
112            Self::Follow => "follow",
113            Self::NoFollow => "nofollow",
114        }
115    }
116}
117
118/// Snippet directive.
119#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
120pub enum SnippetDirective {
121    /// Allow snippets.
122    Snippet,
123    /// Disallow snippets.
124    NoSnippet,
125    /// Limit snippets to a maximum character count.
126    MaxSnippet(u16),
127}
128
129impl SnippetDirective {
130    /// Formats the directive for a robots meta content value.
131    #[must_use]
132    pub fn to_content(self) -> String {
133        match self {
134            Self::Snippet => "snippet".to_string(),
135            Self::NoSnippet => "nosnippet".to_string(),
136            Self::MaxSnippet(max) => format!("max-snippet:{max}"),
137        }
138    }
139}
140
141/// Archive directive.
142#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
143pub enum ArchiveDirective {
144    /// Allow cached/archive snippets.
145    Archive,
146    /// Disallow cached/archive snippets.
147    NoArchive,
148}
149
150impl ArchiveDirective {
151    /// Returns the directive label.
152    #[must_use]
153    pub const fn as_str(self) -> &'static str {
154        match self {
155            Self::Archive => "archive",
156            Self::NoArchive => "noarchive",
157        }
158    }
159}
160
161/// A robots directive set suitable for meta content formatting.
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub struct RobotsDirective {
164    index: IndexDirective,
165    follow: FollowDirective,
166    snippet: Option<SnippetDirective>,
167    archive: Option<ArchiveDirective>,
168}
169
170impl RobotsDirective {
171    /// Creates a robots directive from index and follow directives.
172    #[must_use]
173    pub const fn new(index: IndexDirective, follow: FollowDirective) -> Self {
174        Self {
175            index,
176            follow,
177            snippet: None,
178            archive: None,
179        }
180    }
181
182    /// Common `index,follow` directive.
183    #[must_use]
184    pub const fn index_follow() -> Self {
185        Self::new(IndexDirective::Index, FollowDirective::Follow)
186    }
187
188    /// Common `noindex,nofollow` directive.
189    #[must_use]
190    pub const fn noindex_nofollow() -> Self {
191        Self::new(IndexDirective::NoIndex, FollowDirective::NoFollow)
192    }
193
194    /// Sets the snippet directive.
195    #[must_use]
196    pub const fn with_snippet(mut self, snippet: SnippetDirective) -> Self {
197        self.snippet = Some(snippet);
198        self
199    }
200
201    /// Sets the archive directive.
202    #[must_use]
203    pub const fn with_archive(mut self, archive: ArchiveDirective) -> Self {
204        self.archive = Some(archive);
205        self
206    }
207
208    /// Returns the index directive.
209    #[must_use]
210    pub const fn index(&self) -> IndexDirective {
211        self.index
212    }
213
214    /// Returns the follow directive.
215    #[must_use]
216    pub const fn follow(&self) -> FollowDirective {
217        self.follow
218    }
219
220    /// Formats this directive as a robots meta content value.
221    #[must_use]
222    pub fn to_meta_content(&self) -> String {
223        let mut parts = vec![
224            self.index.as_str().to_string(),
225            self.follow.as_str().to_string(),
226        ];
227        if let Some(snippet) = self.snippet {
228            parts.push(snippet.to_content());
229        }
230        if let Some(archive) = self.archive {
231            parts.push(archive.as_str().to_string());
232        }
233        parts.join(",")
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::{
240        ArchiveDirective, BotName, FollowDirective, IndexDirective, RobotsDirective,
241        RobotsValueError, SnippetDirective,
242    };
243
244    #[test]
245    fn validates_bot_names() {
246        assert_eq!(BotName::new(" "), Err(RobotsValueError::EmptyBotName));
247        assert_eq!(BotName::new("Googlebot").unwrap().as_str(), "Googlebot");
248    }
249
250    #[test]
251    fn formats_common_directives() {
252        assert_eq!(
253            RobotsDirective::index_follow().to_meta_content(),
254            "index,follow"
255        );
256        assert_eq!(
257            RobotsDirective::noindex_nofollow().to_meta_content(),
258            "noindex,nofollow"
259        );
260    }
261
262    #[test]
263    fn formats_extended_directives() {
264        let directive = RobotsDirective::new(IndexDirective::NoIndex, FollowDirective::Follow)
265            .with_snippet(SnippetDirective::MaxSnippet(120))
266            .with_archive(ArchiveDirective::NoArchive);
267
268        assert_eq!(
269            directive.to_meta_content(),
270            "noindex,follow,max-snippet:120,noarchive"
271        );
272    }
273}