Skip to main content

schemaorg_rs/profiles/google/
breadcrumb.rs

1//! Google Rich Results profile for `BreadcrumbList`.
2//!
3//! Source: <https://developers.google.com/search/docs/appearance/structured-data/breadcrumb>
4//! Verified: 2026-04-01
5
6use crate::types::{SchemaNode, SchemaValue};
7use crate::validation::diagnostics::{DiagnosticCode, Severity, ValidationDiagnostic};
8use crate::validation::ValidationDiagnostic as VD;
9
10use super::common::{get_nested_nodes, has_non_empty_property, require_property};
11use crate::profiles::{NodeProfileResult, Profile, TypeEligibility};
12
13/// Google Rich Results profile for `BreadcrumbList` structured data.
14pub struct GoogleBreadcrumbProfile;
15
16impl Profile for GoogleBreadcrumbProfile {
17    fn name(&self) -> &'static str {
18        "google"
19    }
20
21    fn version(&self) -> &'static str {
22        "2026-04-01"
23    }
24
25    fn source_url(&self) -> &'static str {
26        "https://developers.google.com/search/docs/appearance/structured-data/breadcrumb"
27    }
28
29    fn supported_types(&self) -> &[&str] {
30        &["BreadcrumbList"]
31    }
32
33    fn evaluate_node(&self, node: &SchemaNode, _vocab_diagnostics: &[VD]) -> NodeProfileResult {
34        let path = "BreadcrumbList";
35        let mut diagnostics = Vec::new();
36        let mut required_missing = Vec::new();
37
38        // Required: itemListElement
39        if let Some(d) = require_property(node, "itemListElement", path) {
40            required_missing.push("itemListElement".to_string());
41            diagnostics.push(d);
42        }
43
44        let items = get_nested_nodes(node, "itemListElement");
45
46        if items.is_empty() && has_non_empty_property(node, "itemListElement") {
47            // itemListElement exists but has no nested nodes
48            diagnostics.push(ValidationDiagnostic {
49                path: format!("{path}.itemListElement"),
50                severity: Severity::Error,
51                code: DiagnosticCode::InvalidFieldValue,
52                message: "itemListElement must contain ListItem objects".to_string(),
53                source_location: None,
54            });
55        }
56
57        let total_items = items.len();
58
59        for (i, item) in items.iter().enumerate() {
60            let item_path = format!("{path}.itemListElement[{i}]");
61            let is_last = i == total_items - 1;
62
63            // ListItem: position required
64            if let Some(d) = require_property(item, "position", &item_path) {
65                diagnostics.push(d);
66            }
67
68            // ListItem: name required
69            if let Some(d) = require_property(item, "name", &item_path) {
70                diagnostics.push(d);
71            }
72
73            // ListItem: item required (except last item, which represents current page)
74            if !is_last {
75                if let Some(d) = require_property(item, "item", &item_path) {
76                    diagnostics.push(d);
77                }
78            }
79
80            // Validate position is sequential (starting at 1)
81            if let Some(values) = item.properties.get("position") {
82                #[allow(clippy::cast_precision_loss)] // position index is always tiny
83                let expected = (i + 1) as f64;
84                let actual = values.first().and_then(|v| match v {
85                    SchemaValue::Number(n) => Some(*n),
86                    SchemaValue::Text(s) => s.parse::<f64>().ok(),
87                    _ => None,
88                });
89
90                #[allow(clippy::float_cmp)] // position is always an integer
91                if let Some(pos) = actual {
92                    if pos != expected {
93                        diagnostics.push(ValidationDiagnostic {
94                            path: format!("{item_path}.position"),
95                            severity: Severity::Warning,
96                            code: DiagnosticCode::InvalidFieldValue,
97                            message: format!(
98                                "Position should be {}, got {}. \
99                                 Positions must be sequential starting at 1",
100                                i + 1,
101                                pos,
102                            ),
103                            source_location: None,
104                        });
105                    }
106                }
107            }
108        }
109
110        let eligible = required_missing.is_empty()
111            && !diagnostics.iter().any(|d| d.severity == Severity::Error);
112
113        NodeProfileResult {
114            type_eligibility: TypeEligibility {
115                schema_type: "BreadcrumbList".to_string(),
116                eligible,
117                required_missing,
118                recommended_missing: Vec::new(),
119                field_diagnostics: diagnostics,
120            },
121        }
122    }
123}