1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum RobotsValueError {
10 EmptyBotName,
12 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub struct BotName(String);
32
33impl BotName {
34 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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
80pub enum IndexDirective {
81 Index,
83 NoIndex,
85}
86
87impl IndexDirective {
88 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
100pub enum FollowDirective {
101 Follow,
103 NoFollow,
105}
106
107impl FollowDirective {
108 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
120pub enum SnippetDirective {
121 Snippet,
123 NoSnippet,
125 MaxSnippet(u16),
127}
128
129impl SnippetDirective {
130 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
143pub enum ArchiveDirective {
144 Archive,
146 NoArchive,
148}
149
150impl ArchiveDirective {
151 #[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#[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 #[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 #[must_use]
184 pub const fn index_follow() -> Self {
185 Self::new(IndexDirective::Index, FollowDirective::Follow)
186 }
187
188 #[must_use]
190 pub const fn noindex_nofollow() -> Self {
191 Self::new(IndexDirective::NoIndex, FollowDirective::NoFollow)
192 }
193
194 #[must_use]
196 pub const fn with_snippet(mut self, snippet: SnippetDirective) -> Self {
197 self.snippet = Some(snippet);
198 self
199 }
200
201 #[must_use]
203 pub const fn with_archive(mut self, archive: ArchiveDirective) -> Self {
204 self.archive = Some(archive);
205 self
206 }
207
208 #[must_use]
210 pub const fn index(&self) -> IndexDirective {
211 self.index
212 }
213
214 #[must_use]
216 pub const fn follow(&self) -> FollowDirective {
217 self.follow
218 }
219
220 #[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}