1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn validate_text(
8 value: impl AsRef<str>,
9 field: &'static str,
10 max_length: usize,
11) -> Result<String, SeoValueError> {
12 let trimmed = value.as_ref().trim();
13 if trimmed.is_empty() {
14 return Err(SeoValueError::Empty { field });
15 }
16
17 let actual_length = trimmed.chars().count();
18 if actual_length > max_length {
19 return Err(SeoValueError::TooLong {
20 field,
21 max_length,
22 actual_length,
23 });
24 }
25
26 Ok(trimmed.to_string())
27}
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum SeoValueError {
32 Empty { field: &'static str },
34 TooLong {
36 field: &'static str,
37 max_length: usize,
38 actual_length: usize,
39 },
40 InvalidSlugHint,
42}
43
44impl fmt::Display for SeoValueError {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 match self {
47 Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
48 Self::TooLong {
49 field,
50 max_length,
51 actual_length,
52 } => write!(
53 formatter,
54 "{field} is {actual_length} characters; maximum is {max_length}"
55 ),
56 Self::InvalidSlugHint => {
57 formatter.write_str("slug hint must be ASCII words separated by hyphens")
58 },
59 }
60 }
61}
62
63impl Error for SeoValueError {}
64
65#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
67pub struct SeoTitle(String);
68
69impl SeoTitle {
70 pub const MAX_LENGTH: usize = 70;
72
73 pub fn new(value: impl AsRef<str>) -> Result<Self, SeoValueError> {
79 validate_text(value, "seo title", Self::MAX_LENGTH).map(Self)
80 }
81
82 #[must_use]
84 pub fn as_str(&self) -> &str {
85 &self.0
86 }
87}
88
89impl AsRef<str> for SeoTitle {
90 fn as_ref(&self) -> &str {
91 self.as_str()
92 }
93}
94
95impl fmt::Display for SeoTitle {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 formatter.write_str(self.as_str())
98 }
99}
100
101impl FromStr for SeoTitle {
102 type Err = SeoValueError;
103
104 fn from_str(value: &str) -> Result<Self, Self::Err> {
105 Self::new(value)
106 }
107}
108
109#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
111pub struct MetaDescription(String);
112
113impl MetaDescription {
114 pub const MAX_LENGTH: usize = 180;
116
117 pub fn new(value: impl AsRef<str>) -> Result<Self, SeoValueError> {
123 validate_text(value, "meta description", Self::MAX_LENGTH).map(Self)
124 }
125
126 #[must_use]
128 pub fn as_str(&self) -> &str {
129 &self.0
130 }
131}
132
133impl AsRef<str> for MetaDescription {
134 fn as_ref(&self) -> &str {
135 self.as_str()
136 }
137}
138
139impl fmt::Display for MetaDescription {
140 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141 formatter.write_str(self.as_str())
142 }
143}
144
145impl FromStr for MetaDescription {
146 type Err = SeoValueError;
147
148 fn from_str(value: &str) -> Result<Self, Self::Err> {
149 Self::new(value)
150 }
151}
152
153#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
155pub struct SlugHint(String);
156
157impl SlugHint {
158 pub fn new(value: impl AsRef<str>) -> Result<Self, SeoValueError> {
164 let trimmed = value.as_ref().trim().trim_matches('/');
165 if trimmed.is_empty() {
166 return Err(SeoValueError::Empty { field: "slug hint" });
167 }
168
169 let normalized = trimmed
170 .split(|character: char| character.is_ascii_whitespace() || character == '_')
171 .filter(|segment| !segment.is_empty())
172 .collect::<Vec<_>>()
173 .join("-")
174 .to_ascii_lowercase();
175
176 let valid = normalized.bytes().all(|byte| {
177 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'/')
178 }) && !normalized.contains("--");
179
180 if valid {
181 Ok(Self(normalized))
182 } else {
183 Err(SeoValueError::InvalidSlugHint)
184 }
185 }
186
187 #[must_use]
189 pub fn as_str(&self) -> &str {
190 &self.0
191 }
192}
193
194impl AsRef<str> for SlugHint {
195 fn as_ref(&self) -> &str {
196 self.as_str()
197 }
198}
199
200impl fmt::Display for SlugHint {
201 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
202 formatter.write_str(self.as_str())
203 }
204}
205
206impl FromStr for SlugHint {
207 type Err = SeoValueError;
208
209 fn from_str(value: &str) -> Result<Self, Self::Err> {
210 Self::new(value)
211 }
212}
213
214#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
216pub enum IndexingHint {
217 Index,
219 NoIndex,
221 NoImageIndex,
223 NoSnippet,
225}
226
227impl IndexingHint {
228 #[must_use]
230 pub const fn as_str(self) -> &'static str {
231 match self {
232 Self::Index => "index",
233 Self::NoIndex => "noindex",
234 Self::NoImageIndex => "noimageindex",
235 Self::NoSnippet => "nosnippet",
236 }
237 }
238}
239
240#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
242pub enum LinkRelationHint {
243 Canonical,
245 Alternate,
247 Prev,
249 Next,
251 NoFollow,
253 Sponsored,
255 Ugc,
257}
258
259impl LinkRelationHint {
260 #[must_use]
262 pub const fn as_str(self) -> &'static str {
263 match self {
264 Self::Canonical => "canonical",
265 Self::Alternate => "alternate",
266 Self::Prev => "prev",
267 Self::Next => "next",
268 Self::NoFollow => "nofollow",
269 Self::Sponsored => "sponsored",
270 Self::Ugc => "ugc",
271 }
272 }
273}
274
275#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
277pub enum PageIntent {
278 Informational,
280 Navigational,
282 Transactional,
284 Commercial,
286 Local,
288 Unknown,
290}
291
292impl PageIntent {
293 #[must_use]
295 pub const fn as_str(self) -> &'static str {
296 match self {
297 Self::Informational => "informational",
298 Self::Navigational => "navigational",
299 Self::Transactional => "transactional",
300 Self::Commercial => "commercial",
301 Self::Local => "local",
302 Self::Unknown => "unknown",
303 }
304 }
305}
306
307#[derive(Clone, Debug, Eq, PartialEq)]
309pub struct SearchSnippetMetadata {
310 title: SeoTitle,
311 description: MetaDescription,
312 intent: PageIntent,
313 indexing_hint: IndexingHint,
314 slug_hint: Option<SlugHint>,
315 relation_hints: Vec<LinkRelationHint>,
316}
317
318impl SearchSnippetMetadata {
319 #[must_use]
321 pub const fn new(title: SeoTitle, description: MetaDescription) -> Self {
322 Self {
323 title,
324 description,
325 intent: PageIntent::Informational,
326 indexing_hint: IndexingHint::Index,
327 slug_hint: None,
328 relation_hints: Vec::new(),
329 }
330 }
331
332 #[must_use]
334 pub const fn with_intent(mut self, intent: PageIntent) -> Self {
335 self.intent = intent;
336 self
337 }
338
339 #[must_use]
341 pub const fn with_indexing_hint(mut self, hint: IndexingHint) -> Self {
342 self.indexing_hint = hint;
343 self
344 }
345
346 #[must_use]
348 pub fn with_slug_hint(mut self, hint: SlugHint) -> Self {
349 self.slug_hint = Some(hint);
350 self
351 }
352
353 #[must_use]
355 pub fn with_relation_hint(mut self, hint: LinkRelationHint) -> Self {
356 self.relation_hints.push(hint);
357 self
358 }
359
360 #[must_use]
362 pub const fn title(&self) -> &SeoTitle {
363 &self.title
364 }
365
366 #[must_use]
368 pub const fn description(&self) -> &MetaDescription {
369 &self.description
370 }
371
372 #[must_use]
374 pub const fn intent(&self) -> PageIntent {
375 self.intent
376 }
377
378 #[must_use]
380 pub const fn indexing_hint(&self) -> IndexingHint {
381 self.indexing_hint
382 }
383
384 #[must_use]
386 pub const fn slug_hint(&self) -> Option<&SlugHint> {
387 self.slug_hint.as_ref()
388 }
389
390 #[must_use]
392 pub fn relation_hints(&self) -> &[LinkRelationHint] {
393 &self.relation_hints
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::{
400 IndexingHint, LinkRelationHint, MetaDescription, PageIntent, SearchSnippetMetadata,
401 SeoTitle, SeoValueError, SlugHint,
402 };
403
404 #[test]
405 fn validates_title_and_description_lengths() {
406 assert!(SeoTitle::new("Example").is_ok());
407 assert_eq!(
408 SeoTitle::new(" "),
409 Err(SeoValueError::Empty { field: "seo title" })
410 );
411 assert!(MetaDescription::new("a".repeat(MetaDescription::MAX_LENGTH + 1)).is_err());
412 }
413
414 #[test]
415 fn normalizes_slug_hints() {
416 let slug = SlugHint::new(" Local Services ").unwrap();
417
418 assert_eq!(slug.as_str(), "local-services");
419 assert!(SlugHint::new("bad?slug").is_err());
420 }
421
422 #[test]
423 fn composes_search_snippet_metadata() {
424 let snippet = SearchSnippetMetadata::new(
425 SeoTitle::new("Example Services").unwrap(),
426 MetaDescription::new("Service details for Example.").unwrap(),
427 )
428 .with_intent(PageIntent::Local)
429 .with_indexing_hint(IndexingHint::NoIndex)
430 .with_slug_hint(SlugHint::new("services").unwrap())
431 .with_relation_hint(LinkRelationHint::Canonical);
432
433 assert_eq!(snippet.intent(), PageIntent::Local);
434 assert_eq!(snippet.indexing_hint().as_str(), "noindex");
435 assert_eq!(snippet.slug_hint().unwrap().as_str(), "services");
436 assert_eq!(snippet.relation_hints(), &[LinkRelationHint::Canonical]);
437 }
438}