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#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum SchemaValueError {
19 Empty { field: &'static str },
21 EmptyCollection { field: &'static str },
23 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#[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 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 #[must_use]
75 pub const fn schema_type(&self) -> &'static str {
76 "PostalAddress"
77 }
78
79 #[must_use]
81 pub fn street_address(&self) -> &str {
82 &self.street_address
83 }
84}
85
86#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct AggregateRating {
89 rating_value: f32,
90 review_count: u32,
91}
92
93impl AggregateRating {
94 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 #[must_use]
112 pub const fn schema_type(&self) -> &'static str {
113 "AggregateRating"
114 }
115
116 #[must_use]
118 pub const fn rating_value(self) -> f32 {
119 self.rating_value
120 }
121
122 #[must_use]
124 pub const fn review_count(self) -> u32 {
125 self.review_count
126 }
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct OpeningHoursSpecification {
132 day_of_week: String,
133 opens: String,
134 closes: String,
135}
136
137impl OpeningHoursSpecification {
138 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 #[must_use]
157 pub const fn schema_type(&self) -> &'static str {
158 "OpeningHoursSpecification"
159 }
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct Organization {
165 name: String,
166 url: Option<String>,
167 address: Option<PostalAddress>,
168}
169
170impl Organization {
171 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 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 #[must_use]
196 pub fn with_address(mut self, address: PostalAddress) -> Self {
197 self.address = Some(address);
198 self
199 }
200
201 #[must_use]
203 pub const fn schema_type(&self) -> &'static str {
204 "Organization"
205 }
206
207 #[must_use]
209 pub fn name(&self) -> &str {
210 &self.name
211 }
212}
213
214#[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 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 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 #[must_use]
251 pub fn with_opening_hours(mut self, hours: OpeningHoursSpecification) -> Self {
252 self.opening_hours.push(hours);
253 self
254 }
255
256 #[must_use]
258 pub const fn schema_type(&self) -> &'static str {
259 "LocalBusiness"
260 }
261}
262
263#[derive(Clone, Debug, PartialEq)]
265pub struct Product {
266 name: String,
267 sku: Option<String>,
268 aggregate_rating: Option<AggregateRating>,
269}
270
271impl Product {
272 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 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 #[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 #[must_use]
304 pub const fn schema_type(&self) -> &'static str {
305 "Product"
306 }
307}
308
309#[derive(Clone, Debug, Eq, PartialEq)]
311pub struct Article {
312 headline: String,
313 author: Option<String>,
314}
315
316impl Article {
317 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 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 #[must_use]
341 pub const fn schema_type(&self) -> &'static str {
342 "Article"
343 }
344}
345
346#[derive(Clone, Debug, Eq, PartialEq)]
348pub struct Breadcrumb {
349 items: Vec<String>,
350}
351
352impl Breadcrumb {
353 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 #[must_use]
374 pub fn items(&self) -> &[String] {
375 &self.items
376 }
377
378 #[must_use]
380 pub const fn schema_type(&self) -> &'static str {
381 "BreadcrumbList"
382 }
383}
384
385#[derive(Clone, Debug, Eq, PartialEq)]
387pub struct FAQEntry {
388 question: String,
389 answer: String,
390}
391
392impl FAQEntry {
393 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 #[must_use]
410 pub fn question(&self) -> &str {
411 &self.question
412 }
413}
414
415#[allow(clippy::upper_case_acronyms)]
417#[derive(Clone, Debug, Eq, PartialEq)]
418pub struct FAQ {
419 entries: Vec<FAQEntry>,
420}
421
422impl FAQ {
423 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 #[must_use]
440 pub fn entries(&self) -> &[FAQEntry] {
441 &self.entries
442 }
443
444 #[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}