Skip to main content

use_schema/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6
7fn non_empty(value: impl AsRef<str>, field: &'static str) -> Result<String, SchemaValueError> {
8    let trimmed = value.as_ref().trim();
9    if trimmed.is_empty() {
10        Err(SchemaValueError::Empty { field })
11    } else {
12        Ok(trimmed.to_string())
13    }
14}
15
16/// Error returned by structured-data primitive constructors.
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum SchemaValueError {
19    /// The supplied value was empty after trimming whitespace.
20    Empty { field: &'static str },
21    /// A collection required at least one item.
22    EmptyCollection { field: &'static str },
23    /// Rating value was outside the inclusive `0.0..=5.0` range or not finite.
24    InvalidRating(f32),
25}
26
27impl fmt::Display for SchemaValueError {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
31            Self::EmptyCollection { field } => {
32                write!(formatter, "{field} must contain at least one item")
33            },
34            Self::InvalidRating(value) => write!(formatter, "invalid aggregate rating {value}"),
35        }
36    }
37}
38
39impl Error for SchemaValueError {}
40
41/// Postal address primitive for structured-data records.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct PostalAddress {
44    street_address: String,
45    address_locality: String,
46    address_region: String,
47    postal_code: String,
48    address_country: String,
49}
50
51impl PostalAddress {
52    /// Creates a postal address record.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`SchemaValueError::Empty`] when any address component is empty.
57    pub fn new(
58        street_address: impl AsRef<str>,
59        address_locality: impl AsRef<str>,
60        address_region: impl AsRef<str>,
61        postal_code: impl AsRef<str>,
62        address_country: impl AsRef<str>,
63    ) -> Result<Self, SchemaValueError> {
64        Ok(Self {
65            street_address: non_empty(street_address, "street address")?,
66            address_locality: non_empty(address_locality, "address locality")?,
67            address_region: non_empty(address_region, "address region")?,
68            postal_code: non_empty(postal_code, "postal code")?,
69            address_country: non_empty(address_country, "address country")?,
70        })
71    }
72
73    /// Returns the schema-like type label.
74    #[must_use]
75    pub const fn schema_type(&self) -> &'static str {
76        "PostalAddress"
77    }
78
79    /// Returns the street address.
80    #[must_use]
81    pub fn street_address(&self) -> &str {
82        &self.street_address
83    }
84}
85
86/// Aggregate rating primitive.
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct AggregateRating {
89    rating_value: f32,
90    review_count: u32,
91}
92
93impl AggregateRating {
94    /// Creates an aggregate rating.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`SchemaValueError::InvalidRating`] when the value is outside `0.0..=5.0`.
99    pub fn new(rating_value: f32, review_count: u32) -> Result<Self, SchemaValueError> {
100        if rating_value.is_finite() && (0.0..=5.0).contains(&rating_value) {
101            Ok(Self {
102                rating_value,
103                review_count,
104            })
105        } else {
106            Err(SchemaValueError::InvalidRating(rating_value))
107        }
108    }
109
110    /// Returns the schema-like type label.
111    #[must_use]
112    pub const fn schema_type(&self) -> &'static str {
113        "AggregateRating"
114    }
115
116    /// Returns the rating value.
117    #[must_use]
118    pub const fn rating_value(self) -> f32 {
119        self.rating_value
120    }
121
122    /// Returns the review count.
123    #[must_use]
124    pub const fn review_count(self) -> u32 {
125        self.review_count
126    }
127}
128
129/// Opening-hours specification primitive.
130#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct OpeningHoursSpecification {
132    day_of_week: String,
133    opens: String,
134    closes: String,
135}
136
137impl OpeningHoursSpecification {
138    /// Creates an opening-hours specification.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`SchemaValueError::Empty`] when any component is empty.
143    pub fn new(
144        day_of_week: impl AsRef<str>,
145        opens: impl AsRef<str>,
146        closes: impl AsRef<str>,
147    ) -> Result<Self, SchemaValueError> {
148        Ok(Self {
149            day_of_week: non_empty(day_of_week, "day of week")?,
150            opens: non_empty(opens, "opens")?,
151            closes: non_empty(closes, "closes")?,
152        })
153    }
154
155    /// Returns the schema-like type label.
156    #[must_use]
157    pub const fn schema_type(&self) -> &'static str {
158        "OpeningHoursSpecification"
159    }
160}
161
162/// Organization primitive.
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct Organization {
165    name: String,
166    url: Option<String>,
167    address: Option<PostalAddress>,
168}
169
170impl Organization {
171    /// Creates an organization record.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`SchemaValueError::Empty`] when the name is empty.
176    pub fn new(name: impl AsRef<str>) -> Result<Self, SchemaValueError> {
177        Ok(Self {
178            name: non_empty(name, "organization name")?,
179            url: None,
180            address: None,
181        })
182    }
183
184    /// Sets a URL label.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`SchemaValueError::Empty`] when the URL label is empty.
189    pub fn with_url(mut self, url: impl AsRef<str>) -> Result<Self, SchemaValueError> {
190        self.url = Some(non_empty(url, "organization URL")?);
191        Ok(self)
192    }
193
194    /// Sets a postal address.
195    #[must_use]
196    pub fn with_address(mut self, address: PostalAddress) -> Self {
197        self.address = Some(address);
198        self
199    }
200
201    /// Returns the schema-like type label.
202    #[must_use]
203    pub const fn schema_type(&self) -> &'static str {
204        "Organization"
205    }
206
207    /// Returns the organization name.
208    #[must_use]
209    pub fn name(&self) -> &str {
210        &self.name
211    }
212}
213
214/// Local business primitive.
215#[derive(Clone, Debug, Eq, PartialEq)]
216pub struct LocalBusiness {
217    name: String,
218    address: PostalAddress,
219    categories: Vec<String>,
220    opening_hours: Vec<OpeningHoursSpecification>,
221}
222
223impl LocalBusiness {
224    /// Creates a local business record.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`SchemaValueError::Empty`] when the name is empty.
229    pub fn new(name: impl AsRef<str>, address: PostalAddress) -> Result<Self, SchemaValueError> {
230        Ok(Self {
231            name: non_empty(name, "local business name")?,
232            address,
233            categories: Vec::new(),
234            opening_hours: Vec::new(),
235        })
236    }
237
238    /// Adds a category label.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`SchemaValueError::Empty`] when the category is empty.
243    pub fn with_category(mut self, category: impl AsRef<str>) -> Result<Self, SchemaValueError> {
244        self.categories
245            .push(non_empty(category, "local business category")?);
246        Ok(self)
247    }
248
249    /// Adds opening-hours specification.
250    #[must_use]
251    pub fn with_opening_hours(mut self, hours: OpeningHoursSpecification) -> Self {
252        self.opening_hours.push(hours);
253        self
254    }
255
256    /// Returns the schema-like type label.
257    #[must_use]
258    pub const fn schema_type(&self) -> &'static str {
259        "LocalBusiness"
260    }
261}
262
263/// Product primitive.
264#[derive(Clone, Debug, PartialEq)]
265pub struct Product {
266    name: String,
267    sku: Option<String>,
268    aggregate_rating: Option<AggregateRating>,
269}
270
271impl Product {
272    /// Creates a product record.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`SchemaValueError::Empty`] when the name is empty.
277    pub fn new(name: impl AsRef<str>) -> Result<Self, SchemaValueError> {
278        Ok(Self {
279            name: non_empty(name, "product name")?,
280            sku: None,
281            aggregate_rating: None,
282        })
283    }
284
285    /// Sets a SKU label.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`SchemaValueError::Empty`] when the SKU is empty.
290    pub fn with_sku(mut self, sku: impl AsRef<str>) -> Result<Self, SchemaValueError> {
291        self.sku = Some(non_empty(sku, "SKU")?);
292        Ok(self)
293    }
294
295    /// Sets the aggregate rating.
296    #[must_use]
297    pub const fn with_aggregate_rating(mut self, rating: AggregateRating) -> Self {
298        self.aggregate_rating = Some(rating);
299        self
300    }
301
302    /// Returns the schema-like type label.
303    #[must_use]
304    pub const fn schema_type(&self) -> &'static str {
305        "Product"
306    }
307}
308
309/// Article primitive.
310#[derive(Clone, Debug, Eq, PartialEq)]
311pub struct Article {
312    headline: String,
313    author: Option<String>,
314}
315
316impl Article {
317    /// Creates an article record.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`SchemaValueError::Empty`] when the headline is empty.
322    pub fn new(headline: impl AsRef<str>) -> Result<Self, SchemaValueError> {
323        Ok(Self {
324            headline: non_empty(headline, "article headline")?,
325            author: None,
326        })
327    }
328
329    /// Sets the author label.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`SchemaValueError::Empty`] when the author is empty.
334    pub fn with_author(mut self, author: impl AsRef<str>) -> Result<Self, SchemaValueError> {
335        self.author = Some(non_empty(author, "article author")?);
336        Ok(self)
337    }
338
339    /// Returns the schema-like type label.
340    #[must_use]
341    pub const fn schema_type(&self) -> &'static str {
342        "Article"
343    }
344}
345
346/// Breadcrumb primitive.
347#[derive(Clone, Debug, Eq, PartialEq)]
348pub struct Breadcrumb {
349    items: Vec<String>,
350}
351
352impl Breadcrumb {
353    /// Creates a breadcrumb trail from non-empty labels.
354    ///
355    /// # Errors
356    ///
357    /// Returns [`SchemaValueError`] when the trail is empty or contains empty labels.
358    pub fn new(items: impl IntoIterator<Item = impl AsRef<str>>) -> Result<Self, SchemaValueError> {
359        let values = items
360            .into_iter()
361            .map(|item| non_empty(item, "breadcrumb item"))
362            .collect::<Result<Vec<_>, _>>()?;
363        if values.is_empty() {
364            Err(SchemaValueError::EmptyCollection {
365                field: "breadcrumb items",
366            })
367        } else {
368            Ok(Self { items: values })
369        }
370    }
371
372    /// Returns breadcrumb items.
373    #[must_use]
374    pub fn items(&self) -> &[String] {
375        &self.items
376    }
377
378    /// Returns the schema-like type label.
379    #[must_use]
380    pub const fn schema_type(&self) -> &'static str {
381        "BreadcrumbList"
382    }
383}
384
385/// FAQ entry primitive.
386#[derive(Clone, Debug, Eq, PartialEq)]
387pub struct FAQEntry {
388    question: String,
389    answer: String,
390}
391
392impl FAQEntry {
393    /// Creates an FAQ entry.
394    ///
395    /// # Errors
396    ///
397    /// Returns [`SchemaValueError::Empty`] when the question or answer is empty.
398    pub fn new(
399        question: impl AsRef<str>,
400        answer: impl AsRef<str>,
401    ) -> Result<Self, SchemaValueError> {
402        Ok(Self {
403            question: non_empty(question, "FAQ question")?,
404            answer: non_empty(answer, "FAQ answer")?,
405        })
406    }
407
408    /// Returns the question label.
409    #[must_use]
410    pub fn question(&self) -> &str {
411        &self.question
412    }
413}
414
415/// FAQ structured-data primitive.
416#[allow(clippy::upper_case_acronyms)]
417#[derive(Clone, Debug, Eq, PartialEq)]
418pub struct FAQ {
419    entries: Vec<FAQEntry>,
420}
421
422impl FAQ {
423    /// Creates an FAQ collection.
424    ///
425    /// # Errors
426    ///
427    /// Returns [`SchemaValueError::EmptyCollection`] when no entries are supplied.
428    pub fn new(entries: Vec<FAQEntry>) -> Result<Self, SchemaValueError> {
429        if entries.is_empty() {
430            Err(SchemaValueError::EmptyCollection {
431                field: "FAQ entries",
432            })
433        } else {
434            Ok(Self { entries })
435        }
436    }
437
438    /// Returns entries.
439    #[must_use]
440    pub fn entries(&self) -> &[FAQEntry] {
441        &self.entries
442    }
443
444    /// Returns the schema-like type label.
445    #[must_use]
446    pub const fn schema_type(&self) -> &'static str {
447        "FAQPage"
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::{
454        AggregateRating, Breadcrumb, FAQ, FAQEntry, LocalBusiness, OpeningHoursSpecification,
455        Organization, PostalAddress, Product,
456    };
457
458    fn address() -> PostalAddress {
459        PostalAddress::new("1 Main St", "Portland", "OR", "97201", "US").unwrap()
460    }
461
462    #[test]
463    fn builds_organization_and_local_business() {
464        let organization = Organization::new("Example Co")
465            .unwrap()
466            .with_address(address());
467        let business = LocalBusiness::new("Example Cafe", address())
468            .unwrap()
469            .with_category("Cafe")
470            .unwrap()
471            .with_opening_hours(
472                OpeningHoursSpecification::new("Monday", "09:00", "17:00").unwrap(),
473            );
474
475        assert_eq!(organization.schema_type(), "Organization");
476        assert_eq!(business.schema_type(), "LocalBusiness");
477    }
478
479    #[test]
480    fn validates_rating_and_product() {
481        let rating = AggregateRating::new(4.5, 12).unwrap();
482        let product = Product::new("Example Product")
483            .unwrap()
484            .with_sku("SKU-1")
485            .unwrap()
486            .with_aggregate_rating(rating);
487
488        assert_eq!(product.schema_type(), "Product");
489        assert!(AggregateRating::new(6.0, 1).is_err());
490    }
491
492    #[test]
493    fn builds_breadcrumbs_and_faq() {
494        let breadcrumb = Breadcrumb::new(["Home", "Services"]).unwrap();
495        let faq = FAQ::new(vec![FAQEntry::new("What?", "A primitive.").unwrap()]).unwrap();
496
497        assert_eq!(breadcrumb.items().len(), 2);
498        assert_eq!(faq.schema_type(), "FAQPage");
499        assert!(FAQ::new(Vec::new()).is_err());
500    }
501}