schemaorg_rs/profiles/mod.rs
1//! Rich Results profile system -- platform-specific validation beyond Schema.org.
2//!
3//! After M2's vocabulary validation answers *"is this valid Schema.org?"*, the
4//! profile system answers *"will Google actually show a rich result for this?"*.
5//!
6//! # Architecture
7//!
8//! ```text
9//! StructuredDataGraph
10//! +---- ProfileRegistry
11//! +---- Google Product profile
12//! +---- Google Article profile
13//! +---- Google `FAQPage` profile
14//! +---- Google `BreadcrumbList` profile
15//! +---- Google `LocalBusiness` profile
16//! +---- Google Event profile
17//! +---- Google Recipe profile
18//! +---- Baseline profile
19//! ```
20//!
21//! # Examples
22//!
23//! ```no_run
24//! # #[cfg(feature = "profiles")]
25//! # {
26//! use schemaorg_rs::{extract_all, validation};
27//! use schemaorg_rs::profiles::{ProfileRegistry, Eligibility};
28//!
29//! let html = r#"<script type="application/ld+json">{
30//! "@context": "https://schema.org",
31//! "@type": "Product",
32//! "name": "Widget"
33//! }</script>"#;
34//!
35//! let graph = extract_all(html).unwrap();
36//! let vocab_result = validation::validate(&graph);
37//! let registry = ProfileRegistry::with_google();
38//! let result = registry.evaluate("google", &graph, &vocab_result.diagnostics).unwrap();
39//!
40//! match result.eligibility {
41//! Eligibility::Eligible => println!("Rich result eligible!"),
42//! Eligibility::WarningsOnly => println!("Eligible with warnings"),
43//! Eligibility::NotEligible => println!("Not eligible"),
44//! Eligibility::Restricted => println!("Restricted eligibility"),
45//! }
46//! # }
47//! ```
48
49pub mod baseline;
50pub mod engine;
51pub mod google;
52
53use std::fmt;
54
55use serde::{Deserialize, Serialize};
56use thiserror::Error;
57
58use crate::graph::StructuredDataGraph;
59use crate::types::SchemaNode;
60use crate::validation::ValidationDiagnostic;
61
62/// A deployment profile adds platform-specific rules beyond Schema.org
63/// vocabulary validation.
64///
65/// Each profile defines which types it covers and how to evaluate nodes
66/// against its requirements (required fields, recommended fields, nested
67/// sub-requirements, etc.).
68pub trait Profile: Send + Sync {
69 /// Profile identifier (e.g., `"google"`).
70 fn name(&self) -> &'static str;
71
72 /// Profile version -- date-based to track when rules were last verified
73 /// against the platform's documentation.
74 fn version(&self) -> &'static str;
75
76 /// Source documentation URL.
77 fn source_url(&self) -> &'static str;
78
79 /// Which Schema.org types does this profile cover?
80 ///
81 /// The engine checks each node's types against this list to decide
82 /// whether to evaluate it.
83 fn supported_types(&self) -> &[&str];
84
85 /// Evaluate a single node against this profile's rules.
86 ///
87 /// Called by the engine for each node whose type matches
88 /// [`supported_types()`](Self::supported_types).
89 fn evaluate_node(
90 &self,
91 node: &SchemaNode,
92 vocab_diagnostics: &[ValidationDiagnostic],
93 ) -> NodeProfileResult;
94}
95
96/// Overall result of evaluating a graph against a profile.
97///
98/// Contains the aggregate eligibility, per-type breakdowns, and any
99/// profile-specific diagnostics.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[must_use]
102pub struct ProfileResult {
103 /// Overall eligibility across all evaluated nodes.
104 pub eligibility: Eligibility,
105 /// Per-type eligibility breakdown.
106 pub type_results: Vec<TypeEligibility>,
107 /// Profile-specific diagnostics (on top of vocabulary diagnostics).
108 pub diagnostics: Vec<ValidationDiagnostic>,
109}
110
111/// Eligibility verdict for rich result display.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
113#[must_use]
114pub enum Eligibility {
115 /// All requirements met -- rich result should display.
116 Eligible,
117 /// Requirements met but warnings present.
118 WarningsOnly,
119 /// Missing required fields or structural issues -- no rich result.
120 NotEligible,
121 /// Structurally valid but eligibility depends on external factors
122 /// (e.g., `FAQPage` requires site authority since 2024).
123 Restricted,
124}
125
126/// Per-type eligibility breakdown.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[must_use]
129pub struct TypeEligibility {
130 /// The Schema.org type evaluated (e.g., `"Product"`).
131 pub schema_type: String,
132 /// Whether this type passes all required checks.
133 pub eligible: bool,
134 /// Required fields that are missing.
135 pub required_missing: Vec<String>,
136 /// Recommended fields that are missing.
137 pub recommended_missing: Vec<String>,
138 /// Per-field diagnostics specific to this type evaluation.
139 pub field_diagnostics: Vec<ValidationDiagnostic>,
140}
141
142/// Result of evaluating a single node against a profile.
143#[must_use]
144pub struct NodeProfileResult {
145 /// Type-level eligibility for this node.
146 pub type_eligibility: TypeEligibility,
147}
148
149/// Errors that can occur during profile evaluation.
150#[derive(Debug, Clone, PartialEq, Eq, Error)]
151pub enum ProfileError {
152 /// The requested profile name was not found in the registry.
153 #[error("unknown profile: '{0}'")]
154 UnknownProfile(String),
155 /// No nodes in the graph matched any of the profile's supported types.
156 #[error("no nodes matched the profile's supported types")]
157 NoMatchingTypes,
158}
159
160impl fmt::Display for Eligibility {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 match self {
163 Self::Eligible => write!(f, "Eligible"),
164 Self::WarningsOnly => write!(f, "WarningsOnly"),
165 Self::NotEligible => write!(f, "NotEligible"),
166 Self::Restricted => write!(f, "Restricted"),
167 }
168 }
169}
170
171/// A registry of available profiles for evaluation.
172///
173/// Create one with [`with_google()`](Self::with_google) for Google Rich Results
174/// profiles, or build a custom registry with [`new()`](Self::new) and
175/// [`register()`](Self::register).
176pub struct ProfileRegistry {
177 profiles: Vec<Box<dyn Profile>>,
178}
179
180impl ProfileRegistry {
181 /// Creates an empty registry.
182 #[must_use]
183 pub fn new() -> Self {
184 Self {
185 profiles: Vec::new(),
186 }
187 }
188
189 /// Creates a registry with all built-in Google Rich Results profiles.
190 #[must_use]
191 pub fn with_google() -> Self {
192 let mut registry = Self::new();
193 google::register_all(&mut registry);
194 registry
195 }
196
197 /// Creates a registry with the baseline Schema.org profile.
198 #[must_use]
199 pub fn with_baseline() -> Self {
200 let mut registry = Self::new();
201 registry.register(Box::new(baseline::BaselineProfile));
202 registry
203 }
204
205 /// Registers a profile in the registry.
206 pub fn register(&mut self, profile: Box<dyn Profile>) {
207 self.profiles.push(profile);
208 }
209
210 /// Evaluates a graph against all profiles with the given name.
211 ///
212 /// Multiple profiles may share the same name (e.g., all Google profiles
213 /// are named `"google"`). This method runs all of them and merges results.
214 ///
215 /// # Errors
216 ///
217 /// Returns [`ProfileError::UnknownProfile`] if no profile with the given
218 /// name is registered.
219 pub fn evaluate(
220 &self,
221 profile_name: &str,
222 graph: &StructuredDataGraph,
223 vocab_diagnostics: &[ValidationDiagnostic],
224 ) -> Result<ProfileResult, ProfileError> {
225 let matching: Vec<_> = self
226 .profiles
227 .iter()
228 .filter(|p| p.name() == profile_name)
229 .collect();
230
231 if matching.is_empty() {
232 return Err(ProfileError::UnknownProfile(profile_name.to_string()));
233 }
234
235 let mut all_type_results = Vec::new();
236 let mut all_diagnostics = Vec::new();
237
238 for profile in &matching {
239 let result = engine::evaluate_graph(profile.as_ref(), graph, vocab_diagnostics);
240 all_type_results.extend(result.type_results);
241 all_diagnostics.extend(result.diagnostics);
242 }
243
244 let eligibility = engine::aggregate_eligibility(&all_type_results, &all_diagnostics);
245
246 Ok(ProfileResult {
247 eligibility,
248 type_results: all_type_results,
249 diagnostics: all_diagnostics,
250 })
251 }
252
253 /// Returns the names of all registered profiles.
254 #[must_use]
255 pub fn profile_names(&self) -> Vec<&str> {
256 self.profiles.iter().map(|p| p.name()).collect()
257 }
258}
259
260impl Default for ProfileRegistry {
261 fn default() -> Self {
262 Self::new()
263 }
264}