Skip to main content

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}