Skip to main content

use_seo/
lib.rs

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/// Error returned by SEO primitive constructors.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum SeoValueError {
32    /// The supplied value was empty after trimming whitespace.
33    Empty { field: &'static str },
34    /// The supplied value exceeded the crate's conservative length hint.
35    TooLong {
36        field: &'static str,
37        max_length: usize,
38        actual_length: usize,
39    },
40    /// The supplied slug hint contained unsupported characters.
41    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/// A validated SEO title label.
66#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
67pub struct SeoTitle(String);
68
69impl SeoTitle {
70    /// Conservative title length hint.
71    pub const MAX_LENGTH: usize = 70;
72
73    /// Creates a title from non-empty text.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`SeoValueError`] when the title is empty or longer than [`Self::MAX_LENGTH`].
78    pub fn new(value: impl AsRef<str>) -> Result<Self, SeoValueError> {
79        validate_text(value, "seo title", Self::MAX_LENGTH).map(Self)
80    }
81
82    /// Returns the title text.
83    #[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/// A validated meta description label.
110#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
111pub struct MetaDescription(String);
112
113impl MetaDescription {
114    /// Conservative description length hint.
115    pub const MAX_LENGTH: usize = 180;
116
117    /// Creates a meta description from non-empty text.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`SeoValueError`] when the description is empty or longer than [`Self::MAX_LENGTH`].
122    pub fn new(value: impl AsRef<str>) -> Result<Self, SeoValueError> {
123        validate_text(value, "meta description", Self::MAX_LENGTH).map(Self)
124    }
125
126    /// Returns the description text.
127    #[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/// A normalized slug hint for a page or entity.
154#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
155pub struct SlugHint(String);
156
157impl SlugHint {
158    /// Creates a slug hint from lowercase ASCII words separated by hyphens.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`SeoValueError`] when the slug is empty or contains unsupported characters.
163    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    /// Returns the normalized slug hint.
188    #[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/// Search indexing intent for a page.
215#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
216pub enum IndexingHint {
217    /// The page may be indexed.
218    Index,
219    /// The page should not be indexed.
220    NoIndex,
221    /// Images on the page should not be indexed.
222    NoImageIndex,
223    /// Snippets should not be shown.
224    NoSnippet,
225}
226
227impl IndexingHint {
228    /// Returns the directive label.
229    #[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/// Link relation hints used by external surfaces.
241#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
242pub enum LinkRelationHint {
243    /// Canonical relationship.
244    Canonical,
245    /// Alternate surface relationship.
246    Alternate,
247    /// Previous page relationship.
248    Prev,
249    /// Next page relationship.
250    Next,
251    /// Untrusted or unendorsed link relationship.
252    NoFollow,
253    /// Sponsored link relationship.
254    Sponsored,
255    /// User-generated content relationship.
256    Ugc,
257}
258
259impl LinkRelationHint {
260    /// Returns the relation label.
261    #[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/// Page intent labels for search snippets and metadata.
276#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
277pub enum PageIntent {
278    /// Informational page intent.
279    Informational,
280    /// Navigational page intent.
281    Navigational,
282    /// Transactional page intent.
283    Transactional,
284    /// Commercial investigation intent.
285    Commercial,
286    /// Local discovery intent.
287    Local,
288    /// Unknown or unspecified page intent.
289    Unknown,
290}
291
292impl PageIntent {
293    /// Returns the intent label.
294    #[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/// Search snippet metadata composed from focused SEO primitives.
308#[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    /// Creates snippet metadata with default informational indexable intent.
320    #[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    /// Sets the page intent.
333    #[must_use]
334    pub const fn with_intent(mut self, intent: PageIntent) -> Self {
335        self.intent = intent;
336        self
337    }
338
339    /// Sets the indexing hint.
340    #[must_use]
341    pub const fn with_indexing_hint(mut self, hint: IndexingHint) -> Self {
342        self.indexing_hint = hint;
343        self
344    }
345
346    /// Sets the slug hint.
347    #[must_use]
348    pub fn with_slug_hint(mut self, hint: SlugHint) -> Self {
349        self.slug_hint = Some(hint);
350        self
351    }
352
353    /// Adds a link relation hint.
354    #[must_use]
355    pub fn with_relation_hint(mut self, hint: LinkRelationHint) -> Self {
356        self.relation_hints.push(hint);
357        self
358    }
359
360    /// Returns the title.
361    #[must_use]
362    pub const fn title(&self) -> &SeoTitle {
363        &self.title
364    }
365
366    /// Returns the description.
367    #[must_use]
368    pub const fn description(&self) -> &MetaDescription {
369        &self.description
370    }
371
372    /// Returns the page intent.
373    #[must_use]
374    pub const fn intent(&self) -> PageIntent {
375        self.intent
376    }
377
378    /// Returns the indexing hint.
379    #[must_use]
380    pub const fn indexing_hint(&self) -> IndexingHint {
381        self.indexing_hint
382    }
383
384    /// Returns the slug hint when present.
385    #[must_use]
386    pub const fn slug_hint(&self) -> Option<&SlugHint> {
387        self.slug_hint.as_ref()
388    }
389
390    /// Returns link relation hints.
391    #[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}