use crate::config::enhanced_models::*;
use crate::constants::*;
use crate::models::models::{PageMetrics, ProcessedDocument};
use std::collections::HashMap;
#[derive(Debug, thiserror::Error)]
pub enum ContentValidationError {
#[error("Word count validation failed: {0}")]
WordCount(String),
#[error("Heading structure validation failed: {0}")]
HeadingStructure(String),
#[error("Media requirement validation failed: {0}")]
MediaRequirement(String),
#[error("Technical requirement validation failed: {0}")]
TechnicalRequirement(String),
#[error("SEO requirement validation failed: {0}")]
SeoRequirement(String),
#[error("General validation error: {0}")]
General(String),
}
#[derive(Debug, Clone)]
pub struct ContentValidationResult {
pub violations: Vec<ContentViolation>,
pub penalties: Vec<ContentPenalty>,
pub compliance_score: f32,
pub detailed_analysis: ContentAnalysis,
}
#[derive(Debug, Clone)]
pub enum ContentViolation {
InsufficientWordCount {
actual: usize,
minimum: usize,
severity: ViolationSeverity,
},
ExcessiveWordCount {
actual: usize,
maximum_useful: usize,
severity: ViolationSeverity,
},
MissingRequiredHeading {
heading_type: String,
severity: ViolationSeverity,
},
InvalidHeadingStructure {
issue: String,
severity: ViolationSeverity,
},
InsufficientMedia {
media_type: String,
actual: usize,
required: usize,
severity: ViolationSeverity,
},
PoorAltTextCoverage {
actual_coverage: f32,
required_coverage: f32,
severity: ViolationSeverity,
},
MissingTechnicalRequirement {
requirement: String,
severity: ViolationSeverity,
},
MissingSeoElement {
element: String,
severity: ViolationSeverity,
},
SeoElementOutOfRange {
element: String,
actual_length: usize,
required_range: (usize, usize),
severity: ViolationSeverity,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum ViolationSeverity {
Critical, High, Medium, Low, }
#[derive(Debug, Clone)]
pub struct ContentPenalty {
pub penalty_type: String,
pub penalty_amount: f32,
pub reason: String,
pub severity: ViolationSeverity,
}
#[derive(Debug, Clone)]
pub struct ContentAnalysis {
pub word_count_analysis: Option<WordCountAnalysis>,
pub heading_analysis: Option<HeadingAnalysis>,
pub media_analysis: Option<MediaAnalysis>,
pub technical_analysis: Option<TechnicalAnalysis>,
pub seo_analysis: Option<SeoAnalysis>,
}
#[derive(Debug, Clone)]
pub struct WordCountAnalysis {
pub actual_count: usize,
pub expected_range: (usize, usize),
pub minimum_threshold: usize,
pub compliance_percentage: f32,
pub recommendation: String,
}
#[derive(Debug, Clone)]
pub struct HeadingAnalysis {
pub total_headings: usize,
pub heading_distribution: HashMap<String, usize>, pub structure_valid: bool,
pub missing_h1: bool,
pub compliance_percentage: f32,
pub recommendations: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct MediaAnalysis {
pub image_count: usize,
pub video_count: usize,
pub audio_count: usize,
pub alt_text_coverage: f32,
pub media_to_text_ratio: f32,
pub compliance_percentage: f32,
pub recommendations: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct TechnicalAnalysis {
pub page_size_compliant: bool,
pub mobile_friendly: bool,
pub required_meta_tags: Vec<String>,
pub missing_meta_tags: Vec<String>,
pub ssl_enabled: bool,
pub compliance_percentage: f32,
pub recommendations: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SeoAnalysis {
pub title_compliant: bool,
pub meta_description_present: bool,
pub meta_description_length_compliant: bool,
pub canonical_url_present: bool,
pub structured_data_present: bool,
pub open_graph_present: bool,
pub keyword_density_compliant: bool,
pub compliance_percentage: f32,
pub recommendations: Vec<String>,
}
pub struct ContentValidator;
impl ContentValidator {
pub fn new() -> Self {
Self
}
pub fn validate_content_expectations(
&self,
expectations: &ContentExpectations,
metrics: &PageMetrics,
document: &ProcessedDocument,
) -> Result<ContentValidationResult, ContentValidationError> {
let mut violations = Vec::new();
let mut penalties = Vec::new();
let mut compliance_scores = Vec::new();
let word_count_analysis = if let Some(word_exp) = &expectations.word_count {
let analysis = self.validate_word_count_expectations(
word_exp,
metrics,
&mut violations,
&mut penalties,
)?;
compliance_scores.push(analysis.compliance_percentage);
Some(analysis)
} else {
None
};
let heading_analysis = if let Some(heading_exp) = &expectations.heading_structure {
let analysis = self.validate_heading_expectations(
heading_exp,
metrics,
document,
&mut violations,
&mut penalties,
)?;
compliance_scores.push(analysis.compliance_percentage);
Some(analysis)
} else {
None
};
let media_analysis = if let Some(media_exp) = &expectations.media_requirements {
let analysis = self.validate_media_expectations(
media_exp,
metrics,
&mut violations,
&mut penalties,
)?;
compliance_scores.push(analysis.compliance_percentage);
Some(analysis)
} else {
None
};
let technical_analysis = if let Some(tech_exp) = &expectations.technical_requirements {
let analysis = self.validate_technical_expectations(
tech_exp,
metrics,
&mut violations,
&mut penalties,
)?;
compliance_scores.push(analysis.compliance_percentage);
Some(analysis)
} else {
None
};
let seo_analysis = if let Some(seo_exp) = &expectations.seo_requirements {
let analysis =
self.validate_seo_expectations(seo_exp, metrics, &mut violations, &mut penalties)?;
compliance_scores.push(analysis.compliance_percentage);
Some(analysis)
} else {
None
};
let compliance_score = if compliance_scores.is_empty() {
100.0 } else {
compliance_scores.iter().sum::<f32>() / compliance_scores.len() as f32
};
Ok(ContentValidationResult {
violations,
penalties,
compliance_score,
detailed_analysis: ContentAnalysis {
word_count_analysis,
heading_analysis,
media_analysis,
technical_analysis,
seo_analysis,
},
})
}
fn validate_word_count_expectations(
&self,
expectation: &WordCountExpectation,
metrics: &PageMetrics,
violations: &mut Vec<ContentViolation>,
penalties: &mut Vec<ContentPenalty>,
) -> Result<WordCountAnalysis, ContentValidationError> {
let actual_count = metrics.html_analysis.content.word_count;
let expected_range = expectation.optimal_range;
let minimum_threshold = expectation.minimum;
let mut compliance_percentage: f32 = 100.0;
let mut recommendation = String::new();
if actual_count < minimum_threshold {
let severity =
self.calculate_word_count_deficiency_severity(actual_count, minimum_threshold);
violations.push(ContentViolation::InsufficientWordCount {
actual: actual_count,
minimum: minimum_threshold,
severity: severity.clone(),
});
let penalty_amount = self.calculate_word_count_penalty(actual_count, expectation);
penalties.push(ContentPenalty {
penalty_type: PENALTY_WORD_COUNT_DEFICIENCY.to_string(),
penalty_amount,
reason: format!(
"Content has only {} words, minimum required is {}",
actual_count, minimum_threshold
),
severity,
});
compliance_percentage = (actual_count as f32 / minimum_threshold as f32) * 60.0;
recommendation = format!(
"Add {} more words to reach minimum requirement",
minimum_threshold - actual_count
);
} else if actual_count < expected_range.0 {
compliance_percentage = 60.0
+ ((actual_count - minimum_threshold) as f32
/ (expected_range.0 - minimum_threshold) as f32)
* 25.0;
recommendation = format!(
"Consider adding {} more words to reach optimal range",
expected_range.0 - actual_count
);
} else if actual_count > expected_range.1 {
if let Some(max_useful) = expectation.maximum_useful {
if actual_count > max_useful {
violations.push(ContentViolation::ExcessiveWordCount {
actual: actual_count,
maximum_useful: max_useful,
severity: ViolationSeverity::Low,
});
compliance_percentage = 85.0;
recommendation = format!(
"Content is quite long ({}+ words). Consider breaking into multiple pages",
actual_count
);
} else {
compliance_percentage = 95.0;
recommendation = RECOMMENDATION_WORD_COUNT_OPTIMAL_ABOVE.to_string();
}
} else {
compliance_percentage = 95.0;
recommendation = RECOMMENDATION_WORD_COUNT_ABOVE_RANGE.to_string();
}
} else {
compliance_percentage = 100.0;
recommendation = RECOMMENDATION_WORD_COUNT_OPTIMAL.to_string();
}
Ok(WordCountAnalysis {
actual_count,
expected_range,
minimum_threshold,
compliance_percentage,
recommendation,
})
}
fn validate_heading_expectations(
&self,
expectation: &HeadingExpectation,
metrics: &PageMetrics,
document: &ProcessedDocument,
violations: &mut Vec<ContentViolation>,
penalties: &mut Vec<ContentPenalty>,
) -> Result<HeadingAnalysis, ContentValidationError> {
let total_headings = metrics.html_analysis.structure.headings_count;
let mut heading_distribution = HashMap::new();
let mut structure_valid = true;
let mut missing_h1 = false;
let mut compliance_percentage: f32 = 100.0;
let mut recommendations = Vec::new();
self.analyze_heading_distribution(document, &mut heading_distribution);
if expectation.require_h1 && heading_distribution.get(HEADING_H1).unwrap_or(&0) == &0 {
missing_h1 = true;
structure_valid = false;
violations.push(ContentViolation::MissingRequiredHeading {
heading_type: HEADING_H1.to_string(),
severity: ViolationSeverity::High,
});
penalties.push(ContentPenalty {
penalty_type: PENALTY_MISSING_H1.to_string(),
penalty_amount: 15.0,
reason: REASON_MISSING_H1.to_string(),
severity: ViolationSeverity::High,
});
compliance_percentage -= 20.0;
recommendations.push(RECOMMENDATION_ADD_H1.to_string());
}
if total_headings < expectation.minimum_headings {
violations.push(ContentViolation::InvalidHeadingStructure {
issue: format!(
"Only {} headings found, minimum {} required",
total_headings, expectation.minimum_headings
),
severity: ViolationSeverity::Medium,
});
penalties.push(ContentPenalty {
penalty_type: PENALTY_INSUFFICIENT_HEADINGS.to_string(),
penalty_amount: 10.0,
reason: format!(
"Insufficient heading structure: {} < {}",
total_headings, expectation.minimum_headings
),
severity: ViolationSeverity::Medium,
});
compliance_percentage -= 15.0;
recommendations.push(format!(
"Add {} more headings to improve content structure",
expectation.minimum_headings - total_headings
));
}
if self.check_heading_depth_violations(
&heading_distribution,
expectation.maximum_heading_depth,
) {
violations.push(ContentViolation::InvalidHeadingStructure {
issue: format!(
"Heading depth exceeds maximum of {}",
expectation.maximum_heading_depth
),
severity: ViolationSeverity::Low,
});
compliance_percentage -= 5.0;
recommendations.push(RECOMMENDATION_SIMPLIFY_HIERARCHY.to_string());
}
if expectation.logical_hierarchy && !self.validate_heading_hierarchy(&heading_distribution)
{
violations.push(ContentViolation::InvalidHeadingStructure {
issue: ISSUE_HEADING_HIERARCHY_ILLOGICAL.to_string(),
severity: ViolationSeverity::Medium,
});
compliance_percentage -= 10.0;
recommendations.push(RECOMMENDATION_HEADING_ORDER.to_string());
}
Ok(HeadingAnalysis {
total_headings,
heading_distribution,
structure_valid,
missing_h1,
compliance_percentage: compliance_percentage.max(0.0),
recommendations,
})
}
fn validate_media_expectations(
&self,
expectation: &MediaExpectation,
metrics: &PageMetrics,
violations: &mut Vec<ContentViolation>,
penalties: &mut Vec<ContentPenalty>,
) -> Result<MediaAnalysis, ContentValidationError> {
let image_count = metrics.html_analysis.media.images_count;
let video_count = metrics.html_analysis.media.video_count;
let audio_count = metrics.html_analysis.media.audio_count;
let alt_text_coverage = metrics.html_analysis.media.image_alt_coverage;
let word_count = metrics.html_analysis.content.word_count as f32;
let media_to_text_ratio = if word_count > 0.0 {
image_count as f32 / word_count
} else {
0.0
};
let mut compliance_percentage: f32 = 100.0;
let mut recommendations = Vec::new();
if image_count < expectation.minimum_images {
violations.push(ContentViolation::InsufficientMedia {
media_type: MEDIA_TYPE_IMAGES.to_string(),
actual: image_count,
required: expectation.minimum_images,
severity: ViolationSeverity::Medium,
});
penalties.push(ContentPenalty {
penalty_type: PENALTY_INSUFFICIENT_IMAGES.to_string(),
penalty_amount: 10.0,
reason: format!(
"Only {} images found, {} required",
image_count, expectation.minimum_images
),
severity: ViolationSeverity::Medium,
});
compliance_percentage -= 20.0;
recommendations.push(format!(
"Add {} more images to meet requirements",
expectation.minimum_images - image_count
));
}
if alt_text_coverage < expectation.alt_text_coverage {
violations.push(ContentViolation::PoorAltTextCoverage {
actual_coverage: alt_text_coverage,
required_coverage: expectation.alt_text_coverage,
severity: ViolationSeverity::High,
});
penalties.push(ContentPenalty {
penalty_type: PENALTY_POOR_ALT_TEXT.to_string(),
penalty_amount: 15.0,
reason: format!(
"Alt text coverage is {:.1}%, required {:.1}%",
alt_text_coverage * 100.0,
expectation.alt_text_coverage * 100.0
),
severity: ViolationSeverity::High,
});
compliance_percentage -= 25.0;
recommendations.push(RECOMMENDATION_ADD_ALT_TEXT.to_string());
}
if let Some((min_ratio, max_ratio)) = expectation.image_to_text_ratio {
if media_to_text_ratio < min_ratio {
compliance_percentage -= 10.0;
recommendations.push(RECOMMENDATION_VISUAL_BALANCE.to_string());
} else if media_to_text_ratio > max_ratio {
compliance_percentage -= 5.0;
recommendations.push(RECOMMENDATION_MEDIA_SUPPORT_TEXT.to_string());
}
}
if expectation.require_audio == Some(true) && audio_count == 0 {
violations.push(ContentViolation::InsufficientMedia {
media_type: MEDIA_TYPE_AUDIO.to_string(),
actual: audio_count,
required: 1,
severity: ViolationSeverity::Medium,
});
compliance_percentage -= 15.0;
recommendations.push(RECOMMENDATION_ADD_AUDIO.to_string());
}
if expectation.require_video == Some(true) && video_count == 0 {
violations.push(ContentViolation::InsufficientMedia {
media_type: MEDIA_TYPE_VIDEO.to_string(),
actual: video_count,
required: 1,
severity: ViolationSeverity::Medium,
});
compliance_percentage -= 15.0;
recommendations.push(RECOMMENDATION_ADD_VIDEO.to_string());
}
Ok(MediaAnalysis {
image_count,
video_count,
audio_count,
alt_text_coverage,
media_to_text_ratio,
compliance_percentage: compliance_percentage.max(0.0),
recommendations,
})
}
fn validate_technical_expectations(
&self,
expectation: &TechnicalExpectation,
metrics: &PageMetrics,
violations: &mut Vec<ContentViolation>,
_penalties: &mut Vec<ContentPenalty>,
) -> Result<TechnicalAnalysis, ContentValidationError> {
let mut compliance_percentage: f32 = 100.0;
let mut recommendations = Vec::new();
let mut missing_meta_tags = Vec::new();
let ssl_enabled = expectation.ssl_required; if expectation.ssl_required && !ssl_enabled {
violations.push(ContentViolation::MissingTechnicalRequirement {
requirement: REQUIREMENT_SSL_HTTPS.to_string(),
severity: ViolationSeverity::High,
});
compliance_percentage -= 20.0;
recommendations.push(RECOMMENDATION_SSL_HTTPS.to_string());
}
for tag in &expectation.required_meta_tags {
if !self.has_meta_tag(tag, metrics) {
missing_meta_tags.push(tag.clone());
violations.push(ContentViolation::MissingTechnicalRequirement {
requirement: format!("Meta tag: {}", tag),
severity: ViolationSeverity::Medium,
});
compliance_percentage -= 10.0;
recommendations.push(format!("Add required meta tag: {}", tag));
}
}
Ok(TechnicalAnalysis {
page_size_compliant: true, mobile_friendly: true, required_meta_tags: expectation.required_meta_tags.clone(),
missing_meta_tags,
ssl_enabled,
compliance_percentage: compliance_percentage.max(0.0),
recommendations,
})
}
fn validate_seo_expectations(
&self,
expectation: &SeoExpectation,
metrics: &PageMetrics,
violations: &mut Vec<ContentViolation>,
penalties: &mut Vec<ContentPenalty>,
) -> Result<SeoAnalysis, ContentValidationError> {
let mut compliance_percentage: f32 = 100.0;
let mut recommendations = Vec::new();
let title_compliant = if let Some((min_len, max_len)) = expectation.title_length_range {
let title_len = metrics.html_analysis.seo.title_len;
if title_len < min_len || title_len > max_len {
violations.push(ContentViolation::SeoElementOutOfRange {
element: ELEMENT_TITLE.to_string(),
actual_length: title_len,
required_range: (min_len, max_len),
severity: ViolationSeverity::Medium,
});
compliance_percentage -= 15.0;
recommendations.push(format!(
"Adjust title length to {}-{} characters",
min_len, max_len
));
false
} else {
true
}
} else {
true
};
let meta_description_present = metrics.html_analysis.seo.meta_desc_len.is_some();
let mut meta_description_length_compliant = true;
if expectation.meta_description_required && !meta_description_present {
violations.push(ContentViolation::MissingSeoElement {
element: "meta description".to_string(),
severity: ViolationSeverity::High,
});
penalties.push(ContentPenalty {
penalty_type: PENALTY_MISSING_META_DESC.to_string(),
penalty_amount: 20.0,
reason: REASON_META_DESC_REQUIRED.to_string(),
severity: ViolationSeverity::High,
});
compliance_percentage -= 25.0;
recommendations.push(RECOMMENDATION_ADD_META_DESC.to_string());
} else if let Some((min_len, max_len)) = expectation.meta_description_length_range {
if let Some(desc_len) = metrics.html_analysis.seo.meta_desc_len {
if desc_len < min_len || desc_len > max_len {
violations.push(ContentViolation::SeoElementOutOfRange {
element: ELEMENT_META_DESC.to_string(),
actual_length: desc_len,
required_range: (min_len, max_len),
severity: ViolationSeverity::Medium,
});
compliance_percentage -= 10.0;
recommendations.push(format!(
"Adjust meta description length to {}-{} characters",
min_len, max_len
));
meta_description_length_compliant = false;
}
}
}
let canonical_url_present = !expectation.canonical_url_required; let structured_data_present = !expectation.structured_data_required; let open_graph_present = !expectation.open_graph_required; let keyword_density_compliant = true;
Ok(SeoAnalysis {
title_compliant,
meta_description_present,
meta_description_length_compliant,
canonical_url_present,
structured_data_present,
open_graph_present,
keyword_density_compliant,
compliance_percentage: compliance_percentage.max(0.0),
recommendations,
})
}
fn calculate_word_count_deficiency_severity(
&self,
actual: usize,
minimum: usize,
) -> ViolationSeverity {
let ratio = actual as f32 / minimum as f32;
if ratio < 0.25 {
ViolationSeverity::Critical
} else if ratio < 0.5 {
ViolationSeverity::High
} else if ratio < 0.8 {
ViolationSeverity::Medium
} else {
ViolationSeverity::Low
}
}
fn calculate_word_count_penalty(
&self,
actual: usize,
expectation: &WordCountExpectation,
) -> f32 {
let ratio = actual as f32 / expectation.minimum as f32;
match &expectation.penalty_curve {
PenaltyCurve::Linear => (1.0 - ratio) * 30.0,
PenaltyCurve::Exponential { base } => {
let penalty = (1.0 - ratio).powf(*base) * 40.0;
penalty.min(50.0)
}
PenaltyCurve::StepFunction { steps } => {
for (threshold, penalty) in steps {
if ratio <= *threshold {
return *penalty;
}
}
0.0
}
}
}
fn analyze_heading_distribution(
&self,
_document: &ProcessedDocument,
distribution: &mut HashMap<String, usize>,
) {
distribution.insert("H1".to_string(), 1);
distribution.insert("H2".to_string(), 3);
distribution.insert("H3".to_string(), 2);
}
fn check_heading_depth_violations(
&self,
distribution: &HashMap<String, usize>,
max_depth: usize,
) -> bool {
let heading_levels = ["H1", "H2", "H3", "H4", "H5", "H6"];
for (i, level) in heading_levels.iter().enumerate() {
if i >= max_depth && distribution.get(*level).unwrap_or(&0) > &0 {
return true;
}
}
false
}
fn validate_heading_hierarchy(&self, distribution: &HashMap<String, usize>) -> bool {
let h1_count = distribution.get("H1").unwrap_or(&0);
let h2_count = distribution.get("H2").unwrap_or(&0);
let h3_count = distribution.get("H3").unwrap_or(&0);
if *h3_count > 0 && *h2_count == 0 {
return false;
}
if *h2_count > 0 && *h1_count == 0 {
return false;
}
true
}
fn has_meta_tag(&self, tag: &str, metrics: &PageMetrics) -> bool {
match tag {
"description" => metrics.html_analysis.seo.meta_desc_len.is_some(),
_ => false, }
}
pub fn calculate_compliance_score(&self, violations: &[ContentViolation]) -> f32 {
if violations.is_empty() {
return 100.0;
}
let mut total_penalty = 0.0;
for violation in violations {
let penalty = match violation {
ContentViolation::InsufficientWordCount { severity, .. } => {
self.severity_to_penalty(severity)
}
ContentViolation::ExcessiveWordCount { severity, .. } => {
self.severity_to_penalty(severity)
}
ContentViolation::MissingRequiredHeading { severity, .. } => {
self.severity_to_penalty(severity)
}
ContentViolation::InvalidHeadingStructure { severity, .. } => {
self.severity_to_penalty(severity)
}
ContentViolation::InsufficientMedia { severity, .. } => {
self.severity_to_penalty(severity)
}
ContentViolation::PoorAltTextCoverage { severity, .. } => {
self.severity_to_penalty(severity)
}
ContentViolation::MissingTechnicalRequirement { severity, .. } => {
self.severity_to_penalty(severity)
}
ContentViolation::MissingSeoElement { severity, .. } => {
self.severity_to_penalty(severity)
}
ContentViolation::SeoElementOutOfRange { severity, .. } => {
self.severity_to_penalty(severity)
}
};
total_penalty += penalty;
}
(100.0 - total_penalty).max(0.0)
}
fn severity_to_penalty(&self, severity: &ViolationSeverity) -> f32 {
match severity {
ViolationSeverity::Critical => 30.0,
ViolationSeverity::High => 20.0,
ViolationSeverity::Medium => 10.0,
ViolationSeverity::Low => 5.0,
}
}
}
impl Default for ContentValidator {
fn default() -> Self {
Self::new()
}
}