schemaorg_rs/profiles/google/
breadcrumb.rs1use 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
13pub 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 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 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 if let Some(d) = require_property(item, "position", &item_path) {
65 diagnostics.push(d);
66 }
67
68 if let Some(d) = require_property(item, "name", &item_path) {
70 diagnostics.push(d);
71 }
72
73 if !is_last {
75 if let Some(d) = require_property(item, "item", &item_path) {
76 diagnostics.push(d);
77 }
78 }
79
80 if let Some(values) = item.properties.get("position") {
82 #[allow(clippy::cast_precision_loss)] 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)] 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}