use super::ContentType;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ValidationSeverity {
Critical,
Error,
Warning,
Info,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationViolation {
pub constraint: String,
pub severity: ValidationSeverity,
pub location: String,
pub text: String,
pub suggestion: String,
}
impl ValidationViolation {
pub(crate) fn new(
constraint: &str,
severity: ValidationSeverity,
location: String,
text: String,
suggestion: &str,
) -> Self {
Self {
constraint: constraint.to_string(),
severity,
location,
text,
suggestion: suggestion.to_string(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ValidationResult {
pub passed: bool,
pub score: u8,
pub violations: Vec<ValidationViolation>,
}
impl ValidationResult {
pub fn pass(score: u8) -> Self {
Self { passed: true, score, violations: Vec::new() }
}
pub fn fail(violations: Vec<ValidationViolation>) -> Self {
let score = Self::calculate_score(&violations);
Self { passed: false, score, violations }
}
pub fn add_violation(&mut self, violation: ValidationViolation) {
self.violations.push(violation);
self.score = Self::calculate_score(&self.violations);
self.passed = !self.violations.iter().any(|v| {
matches!(v.severity, ValidationSeverity::Critical | ValidationSeverity::Error)
});
}
fn calculate_score(violations: &[ValidationViolation]) -> u8 {
let mut score = 100i32;
for v in violations {
match v.severity {
ValidationSeverity::Critical => score -= 50,
ValidationSeverity::Error => score -= 25,
ValidationSeverity::Warning => score -= 10,
ValidationSeverity::Info => score -= 2,
}
}
score.max(0) as u8
}
pub fn has_critical(&self) -> bool {
self.violations.iter().any(|v| v.severity == ValidationSeverity::Critical)
}
pub fn has_errors(&self) -> bool {
self.violations.iter().any(|v| v.severity == ValidationSeverity::Error)
}
pub fn format_display(&self) -> String {
let mut output = String::new();
output.push_str(&format!("Quality Score: {}/100\n\n", self.score));
if self.violations.is_empty() {
output.push_str("No violations found. ✓\n");
return output;
}
output.push_str(&format!("Violations ({}):\n", self.violations.len()));
for (i, v) in self.violations.iter().enumerate() {
let prefix = if i == self.violations.len() - 1 { "└──" } else { "├──" };
let severity = match v.severity {
ValidationSeverity::Critical => "CRITICAL",
ValidationSeverity::Error => "ERROR",
ValidationSeverity::Warning => "WARNING",
ValidationSeverity::Info => "INFO",
};
output.push_str(&format!(
"{} [{}] {} @ {}\n",
prefix, severity, v.constraint, v.location
));
output.push_str(&format!(" Text: \"{}\"\n", v.text));
output.push_str(&format!(" Fix: {}\n", v.suggestion));
}
output
}
}
#[derive(Debug, Clone)]
pub struct ContentValidator {
content_type: ContentType,
}
impl ContentValidator {
pub fn new(content_type: ContentType) -> Self {
Self { content_type }
}
pub fn validate(&self, content: &str) -> ValidationResult {
let mut result = ValidationResult::pass(100);
self.validate_instructor_voice(content, &mut result);
self.validate_code_blocks(content, &mut result);
self.validate_heading_hierarchy(content, &mut result);
self.validate_meta_commentary(content, &mut result);
match self.content_type {
ContentType::BookChapter | ContentType::BlogPost => {
self.validate_frontmatter(content, &mut result);
}
_ => {}
}
result
}
fn validate_meta_commentary(&self, content: &str, result: &mut ValidationResult) {
let meta_phrases = [
"in this chapter",
"in this section",
"we will learn",
"we will explore",
"we will discuss",
"this chapter covers",
"this section covers",
"as mentioned earlier",
"as we discussed",
];
for (line_num, line) in content.lines().enumerate() {
let lower = line.to_lowercase();
for phrase in &meta_phrases {
if lower.contains(phrase) {
result.add_violation(ValidationViolation::new(
"no_meta_commentary",
ValidationSeverity::Warning,
format!("line {}", line_num + 1),
line.trim().chars().take(60).collect::<String>() + "...",
"Use direct instruction instead of meta-commentary",
));
}
}
}
}
fn validate_instructor_voice(&self, content: &str, result: &mut ValidationResult) {
let passive_indicators =
["is being", "was being", "has been", "have been", "will be shown", "can be seen"];
for (line_num, line) in content.lines().enumerate() {
let lower = line.to_lowercase();
if !line.trim().starts_with("```") && !line.trim().starts_with("//") {
for phrase in &passive_indicators {
if lower.contains(phrase) {
result.add_violation(ValidationViolation::new(
"instructor_voice",
ValidationSeverity::Info,
format!("line {}", line_num + 1),
line.trim().chars().take(60).collect::<String>(),
"Consider using active voice for clearer instruction",
));
}
}
}
}
}
fn validate_code_blocks(&self, content: &str, result: &mut ValidationResult) {
let mut in_code_block = false;
let mut block_start = 0;
for (line_num, line) in content.lines().enumerate() {
if line.trim().starts_with("```") {
if !in_code_block {
in_code_block = true;
block_start = line_num + 1;
let lang = line.trim().trim_start_matches('`');
if lang.is_empty() {
result.add_violation(ValidationViolation::new(
"code_block_language",
ValidationSeverity::Warning,
format!("line {}", line_num + 1),
"```".to_string(),
"Specify language: ```rust, ```python, ```bash, etc.",
));
}
} else {
in_code_block = false;
}
}
}
if in_code_block {
result.add_violation(ValidationViolation::new(
"code_block_closed",
ValidationSeverity::Error,
format!("line {}", block_start),
"Unclosed code block".to_string(),
"Add closing ``` to code block",
));
}
}
fn validate_heading_hierarchy(&self, content: &str, result: &mut ValidationResult) {
let mut last_level = 0;
for (line_num, line) in content.lines().enumerate() {
if line.starts_with('#') {
let level = line.chars().take_while(|c| *c == '#').count();
if last_level > 0 && level > last_level + 1 {
result.add_violation(ValidationViolation::new(
"heading_hierarchy",
ValidationSeverity::Error,
format!("line {}", line_num + 1),
line.trim().to_string(),
&format!(
"Heading level {} skips from level {}. Use H{}.",
level,
last_level,
last_level + 1
),
));
}
last_level = level;
}
}
}
fn validate_frontmatter(&self, content: &str, result: &mut ValidationResult) {
let _has_yaml_frontmatter = content.starts_with("---");
let has_toml_frontmatter = content.starts_with("+++");
if self.content_type == ContentType::BlogPost && !has_toml_frontmatter {
result.add_violation(ValidationViolation::new(
"frontmatter_present",
ValidationSeverity::Critical,
"beginning".to_string(),
"Missing TOML frontmatter".to_string(),
"Add +++ frontmatter with title, date, description",
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validation_severity_equality() {
assert_eq!(ValidationSeverity::Critical, ValidationSeverity::Critical);
assert_ne!(ValidationSeverity::Critical, ValidationSeverity::Error);
}
#[test]
fn test_validation_severity_serialization() {
let severity = ValidationSeverity::Warning;
let json = serde_json::to_string(&severity).expect("json serialize failed");
let deserialized: ValidationSeverity =
serde_json::from_str(&json).expect("json deserialize failed");
assert_eq!(deserialized, severity);
}
#[test]
fn test_validation_violation_new() {
let v = ValidationViolation::new(
"test_constraint",
ValidationSeverity::Error,
"line 1".to_string(),
"offending text".to_string(),
"suggested fix",
);
assert_eq!(v.constraint, "test_constraint");
assert_eq!(v.severity, ValidationSeverity::Error);
assert_eq!(v.location, "line 1");
}
#[test]
fn test_validation_violation_serialization() {
let v = ValidationViolation::new(
"test",
ValidationSeverity::Info,
"loc".to_string(),
"text".to_string(),
"fix",
);
let json = serde_json::to_string(&v).expect("json serialize failed");
let deserialized: ValidationViolation =
serde_json::from_str(&json).expect("json deserialize failed");
assert_eq!(deserialized.constraint, v.constraint);
}
#[test]
fn test_validation_result_pass() {
let result = ValidationResult::pass(100);
assert!(result.passed);
assert_eq!(result.score, 100);
assert!(result.violations.is_empty());
}
#[test]
fn test_validation_result_fail() {
let violations = vec![ValidationViolation::new(
"test",
ValidationSeverity::Error,
"loc".to_string(),
"text".to_string(),
"fix",
)];
let result = ValidationResult::fail(violations);
assert!(!result.passed);
assert_eq!(result.score, 75); }
#[test]
fn test_validation_result_add_violation() {
let mut result = ValidationResult::pass(100);
result.add_violation(ValidationViolation::new(
"test",
ValidationSeverity::Warning,
"loc".to_string(),
"text".to_string(),
"fix",
));
assert!(result.passed); assert_eq!(result.score, 90); }
#[test]
fn test_validation_result_add_critical() {
let mut result = ValidationResult::pass(100);
result.add_violation(ValidationViolation::new(
"test",
ValidationSeverity::Critical,
"loc".to_string(),
"text".to_string(),
"fix",
));
assert!(!result.passed); assert_eq!(result.score, 50); }
#[test]
fn test_validation_result_has_critical() {
let mut result = ValidationResult::pass(100);
assert!(!result.has_critical());
result.add_violation(ValidationViolation::new(
"test",
ValidationSeverity::Critical,
"loc".to_string(),
"text".to_string(),
"fix",
));
assert!(result.has_critical());
}
#[test]
fn test_validation_result_has_errors() {
let mut result = ValidationResult::pass(100);
assert!(!result.has_errors());
result.add_violation(ValidationViolation::new(
"test",
ValidationSeverity::Error,
"loc".to_string(),
"text".to_string(),
"fix",
));
assert!(result.has_errors());
}
#[test]
fn test_validation_result_format_display_no_violations() {
let result = ValidationResult::pass(100);
let output = result.format_display();
assert!(output.contains("Quality Score: 100/100"));
assert!(output.contains("No violations found"));
}
#[test]
fn test_validation_result_format_display_with_violations() {
let violations = vec![ValidationViolation::new(
"test_constraint",
ValidationSeverity::Error,
"line 1".to_string(),
"bad text".to_string(),
"use good text",
)];
let result = ValidationResult::fail(violations);
let output = result.format_display();
assert!(output.contains("[ERROR]"));
assert!(output.contains("test_constraint"));
assert!(output.contains("bad text"));
}
#[test]
fn test_validation_result_default() {
let result = ValidationResult::default();
assert!(!result.passed);
assert_eq!(result.score, 0);
}
#[test]
fn test_validation_result_score_floor() {
let mut result = ValidationResult::pass(100);
for _ in 0..10 {
result.add_violation(ValidationViolation::new(
"test",
ValidationSeverity::Critical,
"loc".to_string(),
"text".to_string(),
"fix",
));
}
assert_eq!(result.score, 0); }
#[test]
fn test_content_validator_new() {
let validator = ContentValidator::new(ContentType::BookChapter);
assert!(std::mem::size_of_val(&validator) > 0);
}
#[test]
fn test_content_validator_clean_content() {
let validator = ContentValidator::new(ContentType::BookChapter);
let content = "# Title\n\nSome clean content here.\n\n```rust\nfn main() {}\n```\n";
let result = validator.validate(content);
assert!(result.passed);
}
#[test]
fn test_content_validator_meta_commentary() {
let validator = ContentValidator::new(ContentType::BookChapter);
let content = "# Title\n\nIn this chapter, we will learn about Rust.\n";
let result = validator.validate(content);
assert!(result.violations.iter().any(|v| v.constraint == "no_meta_commentary"));
}
#[test]
fn test_content_validator_instructor_voice() {
let validator = ContentValidator::new(ContentType::BookChapter);
let content = "# Title\n\nThe code has been written and will be shown below.\n";
let result = validator.validate(content);
assert!(result.violations.iter().any(|v| v.constraint == "instructor_voice"));
}
#[test]
fn test_content_validator_heading_hierarchy() {
let validator = ContentValidator::new(ContentType::BookChapter);
let content = "# Title\n\n### Skipped H2\n";
let result = validator.validate(content);
assert!(result.violations.iter().any(|v| v.constraint == "heading_hierarchy"));
}
#[test]
fn test_content_validator_blog_post_missing_frontmatter() {
let validator = ContentValidator::new(ContentType::BlogPost);
let content = "# My Blog Post\n\nContent here.\n";
let result = validator.validate(content);
assert!(!result.passed);
assert!(result.violations.iter().any(|v| v.constraint == "frontmatter_present"));
}
#[test]
fn test_content_validator_blog_post_with_frontmatter() {
let validator = ContentValidator::new(ContentType::BlogPost);
let content = "+++\ntitle = \"Test\"\n+++\n\n# My Blog Post\n\nContent here.\n";
let result = validator.validate(content);
assert!(!result.violations.iter().any(|v| v.constraint == "frontmatter_present"));
}
#[test]
fn test_content_validator_code_block_without_lang() {
let validator = ContentValidator::new(ContentType::BookChapter);
let content = "# Title\n\n```\ncode without language\n```\n";
let result = validator.validate(content);
assert!(result.violations.iter().any(|v| v.constraint == "code_block_language"));
}
}