Skip to main content

schemaorg_rs/profiles/google/
product.rs

1//! Google Rich Results profile for Product.
2//!
3//! Source: <https://developers.google.com/search/docs/appearance/structured-data/product>
4//! Verified: 2026-04-01
5
6use crate::types::SchemaNode;
7use crate::validation::diagnostics::{DiagnosticCode, Severity, ValidationDiagnostic};
8use crate::validation::ValidationDiagnostic as VD;
9
10use super::common::{
11    get_nested_nodes, recommend_property, require_one_of, require_property, validate_nested,
12};
13use crate::profiles::{NodeProfileResult, Profile, TypeEligibility};
14
15/// Google Rich Results profile for Product structured data.
16pub struct GoogleProductProfile;
17
18impl Profile for GoogleProductProfile {
19    fn name(&self) -> &'static str {
20        "google"
21    }
22
23    fn version(&self) -> &'static str {
24        "2026-04-01"
25    }
26
27    fn source_url(&self) -> &'static str {
28        "https://developers.google.com/search/docs/appearance/structured-data/product"
29    }
30
31    fn supported_types(&self) -> &[&str] {
32        &["Product"]
33    }
34
35    fn evaluate_node(&self, node: &SchemaNode, _vocab_diagnostics: &[VD]) -> NodeProfileResult {
36        let path = "Product";
37        let mut diagnostics: Vec<ValidationDiagnostic> = Vec::new();
38        let mut required_missing = Vec::new();
39        let mut recommended_missing = Vec::new();
40
41        // Required: name
42        if let Some(d) = require_property(node, "name", path) {
43            required_missing.push("name".to_string());
44            diagnostics.push(d);
45        }
46
47        // Recommended fields
48        for prop in &["image", "description", "brand", "sku"] {
49            if let Some(d) = recommend_property(node, prop, path) {
50                recommended_missing.push((*prop).to_string());
51                diagnostics.push(d);
52            }
53        }
54
55        // Recommended: at least one global identifier
56        if let Some(d) = require_one_of(
57            node,
58            &["gtin", "gtin8", "gtin13", "gtin14", "isbn", "mpn"],
59            path,
60            Severity::Warning,
61        ) {
62            recommended_missing.push("gtin/isbn/mpn".to_string());
63            diagnostics.push(d);
64        }
65
66        // Recommended: offers or aggregateOffer
67        if let Some(d) =
68            require_one_of(node, &["offers", "aggregateOffer"], path, Severity::Warning)
69        {
70            recommended_missing.push("offers".to_string());
71            diagnostics.push(d);
72        }
73
74        // Recommended: aggregateRating or review
75        if let Some(d) = require_one_of(
76            node,
77            &["aggregateRating", "review"],
78            path,
79            Severity::Warning,
80        ) {
81            recommended_missing.push("aggregateRating/review".to_string());
82            diagnostics.push(d);
83        }
84
85        // Nested Offer validation
86        let offer_diags = validate_nested(
87            node,
88            "offers",
89            "Offer",
90            &["price", "priceCurrency", "availability"],
91            &["url", "priceValidUntil", "itemCondition"],
92            path,
93        );
94        diagnostics.extend(offer_diags);
95
96        // Nested AggregateOffer validation
97        let agg_offer_diags = validate_nested(
98            node,
99            "aggregateOffer",
100            "AggregateOffer",
101            &["lowPrice", "priceCurrency"],
102            &["highPrice"],
103            path,
104        );
105        diagnostics.extend(agg_offer_diags);
106
107        // Nested AggregateRating validation
108        for rating_node in get_nested_nodes(node, "aggregateRating") {
109            let rating_path = format!("{path}.aggregateRating");
110            if let Some(d) = require_property(rating_node, "ratingValue", &rating_path) {
111                diagnostics.push(d);
112            }
113            if let Some(d) = require_one_of(
114                rating_node,
115                &["ratingCount", "reviewCount"],
116                &rating_path,
117                Severity::Error,
118            ) {
119                diagnostics.push(d);
120            }
121        }
122
123        // Nested Review validation
124        let review_diags = validate_nested(
125            node,
126            "review",
127            "Review",
128            &["author"],
129            &["reviewRating"],
130            path,
131        );
132        diagnostics.extend(review_diags);
133
134        let eligible = required_missing.is_empty()
135            && !diagnostics
136                .iter()
137                .any(|d| d.code == DiagnosticCode::NestedRequiredFieldMissing);
138
139        NodeProfileResult {
140            type_eligibility: TypeEligibility {
141                schema_type: "Product".to_string(),
142                eligible,
143                required_missing,
144                recommended_missing,
145                field_diagnostics: diagnostics,
146            },
147        }
148    }
149}