1use super::ContentType;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum ValidationSeverity {
11 Critical,
13 Error,
15 Warning,
17 Info,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ValidationViolation {
24 pub constraint: String,
26 pub severity: ValidationSeverity,
28 pub location: String,
30 pub text: String,
32 pub suggestion: String,
34}
35
36impl ValidationViolation {
37 pub(crate) fn new(
38 constraint: &str,
39 severity: ValidationSeverity,
40 location: String,
41 text: String,
42 suggestion: &str,
43 ) -> Self {
44 Self {
45 constraint: constraint.to_string(),
46 severity,
47 location,
48 text,
49 suggestion: suggestion.to_string(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct ValidationResult {
57 pub passed: bool,
59 pub score: u8,
61 pub violations: Vec<ValidationViolation>,
63}
64
65impl ValidationResult {
66 pub fn pass(score: u8) -> Self {
68 Self { passed: true, score, violations: Vec::new() }
69 }
70
71 pub fn fail(violations: Vec<ValidationViolation>) -> Self {
73 let score = Self::calculate_score(&violations);
74 Self { passed: false, score, violations }
75 }
76
77 pub fn add_violation(&mut self, violation: ValidationViolation) {
79 self.violations.push(violation);
80 self.score = Self::calculate_score(&self.violations);
81 self.passed = !self.violations.iter().any(|v| {
82 matches!(v.severity, ValidationSeverity::Critical | ValidationSeverity::Error)
83 });
84 }
85
86 fn calculate_score(violations: &[ValidationViolation]) -> u8 {
88 let mut score = 100i32;
89 for v in violations {
90 match v.severity {
91 ValidationSeverity::Critical => score -= 50,
92 ValidationSeverity::Error => score -= 25,
93 ValidationSeverity::Warning => score -= 10,
94 ValidationSeverity::Info => score -= 2,
95 }
96 }
97 score.max(0) as u8
98 }
99
100 pub fn has_critical(&self) -> bool {
102 self.violations.iter().any(|v| v.severity == ValidationSeverity::Critical)
103 }
104
105 pub fn has_errors(&self) -> bool {
107 self.violations.iter().any(|v| v.severity == ValidationSeverity::Error)
108 }
109
110 pub fn format_display(&self) -> String {
112 let mut output = String::new();
113 output.push_str(&format!("Quality Score: {}/100\n\n", self.score));
114
115 if self.violations.is_empty() {
116 output.push_str("No violations found. ✓\n");
117 return output;
118 }
119
120 output.push_str(&format!("Violations ({}):\n", self.violations.len()));
121 for (i, v) in self.violations.iter().enumerate() {
122 let prefix = if i == self.violations.len() - 1 { "└──" } else { "├──" };
123 let severity = match v.severity {
124 ValidationSeverity::Critical => "CRITICAL",
125 ValidationSeverity::Error => "ERROR",
126 ValidationSeverity::Warning => "WARNING",
127 ValidationSeverity::Info => "INFO",
128 };
129 output.push_str(&format!(
130 "{} [{}] {} @ {}\n",
131 prefix, severity, v.constraint, v.location
132 ));
133 output.push_str(&format!(" Text: \"{}\"\n", v.text));
134 output.push_str(&format!(" Fix: {}\n", v.suggestion));
135 }
136
137 output
138 }
139}
140
141#[derive(Debug, Clone)]
143pub struct ContentValidator {
144 content_type: ContentType,
146}
147
148impl ContentValidator {
149 pub fn new(content_type: ContentType) -> Self {
151 Self { content_type }
152 }
153
154 pub fn validate(&self, content: &str) -> ValidationResult {
156 let mut result = ValidationResult::pass(100);
157
158 self.validate_instructor_voice(content, &mut result);
160 self.validate_code_blocks(content, &mut result);
161 self.validate_heading_hierarchy(content, &mut result);
162 self.validate_meta_commentary(content, &mut result);
163
164 match self.content_type {
166 ContentType::BookChapter | ContentType::BlogPost => {
167 self.validate_frontmatter(content, &mut result);
168 }
169 _ => {}
170 }
171
172 result
173 }
174
175 fn validate_meta_commentary(&self, content: &str, result: &mut ValidationResult) {
177 let meta_phrases = [
178 "in this chapter",
179 "in this section",
180 "we will learn",
181 "we will explore",
182 "we will discuss",
183 "this chapter covers",
184 "this section covers",
185 "as mentioned earlier",
186 "as we discussed",
187 ];
188
189 for (line_num, line) in content.lines().enumerate() {
190 let lower = line.to_lowercase();
191 for phrase in &meta_phrases {
192 if lower.contains(phrase) {
193 result.add_violation(ValidationViolation::new(
194 "no_meta_commentary",
195 ValidationSeverity::Warning,
196 format!("line {}", line_num + 1),
197 line.trim().chars().take(60).collect::<String>() + "...",
198 "Use direct instruction instead of meta-commentary",
199 ));
200 }
201 }
202 }
203 }
204
205 fn validate_instructor_voice(&self, content: &str, result: &mut ValidationResult) {
207 let passive_indicators =
209 ["is being", "was being", "has been", "have been", "will be shown", "can be seen"];
210
211 for (line_num, line) in content.lines().enumerate() {
212 let lower = line.to_lowercase();
213 if !line.trim().starts_with("```") && !line.trim().starts_with("//") {
215 for phrase in &passive_indicators {
216 if lower.contains(phrase) {
217 result.add_violation(ValidationViolation::new(
218 "instructor_voice",
219 ValidationSeverity::Info,
220 format!("line {}", line_num + 1),
221 line.trim().chars().take(60).collect::<String>(),
222 "Consider using active voice for clearer instruction",
223 ));
224 }
225 }
226 }
227 }
228 }
229
230 fn validate_code_blocks(&self, content: &str, result: &mut ValidationResult) {
232 let mut in_code_block = false;
233 let mut block_start = 0;
234
235 for (line_num, line) in content.lines().enumerate() {
236 if line.trim().starts_with("```") {
237 if !in_code_block {
238 in_code_block = true;
240 block_start = line_num + 1;
241 let lang = line.trim().trim_start_matches('`');
242 if lang.is_empty() {
243 result.add_violation(ValidationViolation::new(
244 "code_block_language",
245 ValidationSeverity::Warning,
246 format!("line {}", line_num + 1),
247 "```".to_string(),
248 "Specify language: ```rust, ```python, ```bash, etc.",
249 ));
250 }
251 } else {
252 in_code_block = false;
254 }
255 }
256 }
257
258 if in_code_block {
260 result.add_violation(ValidationViolation::new(
261 "code_block_closed",
262 ValidationSeverity::Error,
263 format!("line {}", block_start),
264 "Unclosed code block".to_string(),
265 "Add closing ``` to code block",
266 ));
267 }
268 }
269
270 fn validate_heading_hierarchy(&self, content: &str, result: &mut ValidationResult) {
272 let mut last_level = 0;
273
274 for (line_num, line) in content.lines().enumerate() {
275 if line.starts_with('#') {
276 let level = line.chars().take_while(|c| *c == '#').count();
277 if last_level > 0 && level > last_level + 1 {
278 result.add_violation(ValidationViolation::new(
279 "heading_hierarchy",
280 ValidationSeverity::Error,
281 format!("line {}", line_num + 1),
282 line.trim().to_string(),
283 &format!(
284 "Heading level {} skips from level {}. Use H{}.",
285 level,
286 last_level,
287 last_level + 1
288 ),
289 ));
290 }
291 last_level = level;
292 }
293 }
294 }
295
296 fn validate_frontmatter(&self, content: &str, result: &mut ValidationResult) {
298 let _has_yaml_frontmatter = content.starts_with("---");
299 let has_toml_frontmatter = content.starts_with("+++");
300
301 if self.content_type == ContentType::BlogPost && !has_toml_frontmatter {
302 result.add_violation(ValidationViolation::new(
303 "frontmatter_present",
304 ValidationSeverity::Critical,
305 "beginning".to_string(),
306 "Missing TOML frontmatter".to_string(),
307 "Add +++ frontmatter with title, date, description",
308 ));
309 }
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
322 fn test_validation_severity_equality() {
323 assert_eq!(ValidationSeverity::Critical, ValidationSeverity::Critical);
324 assert_ne!(ValidationSeverity::Critical, ValidationSeverity::Error);
325 }
326
327 #[test]
328 fn test_validation_severity_serialization() {
329 let severity = ValidationSeverity::Warning;
330 let json = serde_json::to_string(&severity).expect("json serialize failed");
331 let deserialized: ValidationSeverity =
332 serde_json::from_str(&json).expect("json deserialize failed");
333 assert_eq!(deserialized, severity);
334 }
335
336 #[test]
341 fn test_validation_violation_new() {
342 let v = ValidationViolation::new(
343 "test_constraint",
344 ValidationSeverity::Error,
345 "line 1".to_string(),
346 "offending text".to_string(),
347 "suggested fix",
348 );
349 assert_eq!(v.constraint, "test_constraint");
350 assert_eq!(v.severity, ValidationSeverity::Error);
351 assert_eq!(v.location, "line 1");
352 }
353
354 #[test]
355 fn test_validation_violation_serialization() {
356 let v = ValidationViolation::new(
357 "test",
358 ValidationSeverity::Info,
359 "loc".to_string(),
360 "text".to_string(),
361 "fix",
362 );
363 let json = serde_json::to_string(&v).expect("json serialize failed");
364 let deserialized: ValidationViolation =
365 serde_json::from_str(&json).expect("json deserialize failed");
366 assert_eq!(deserialized.constraint, v.constraint);
367 }
368
369 #[test]
374 fn test_validation_result_pass() {
375 let result = ValidationResult::pass(100);
376 assert!(result.passed);
377 assert_eq!(result.score, 100);
378 assert!(result.violations.is_empty());
379 }
380
381 #[test]
382 fn test_validation_result_fail() {
383 let violations = vec![ValidationViolation::new(
384 "test",
385 ValidationSeverity::Error,
386 "loc".to_string(),
387 "text".to_string(),
388 "fix",
389 )];
390 let result = ValidationResult::fail(violations);
391 assert!(!result.passed);
392 assert_eq!(result.score, 75); }
394
395 #[test]
396 fn test_validation_result_add_violation() {
397 let mut result = ValidationResult::pass(100);
398 result.add_violation(ValidationViolation::new(
399 "test",
400 ValidationSeverity::Warning,
401 "loc".to_string(),
402 "text".to_string(),
403 "fix",
404 ));
405 assert!(result.passed); assert_eq!(result.score, 90); }
408
409 #[test]
410 fn test_validation_result_add_critical() {
411 let mut result = ValidationResult::pass(100);
412 result.add_violation(ValidationViolation::new(
413 "test",
414 ValidationSeverity::Critical,
415 "loc".to_string(),
416 "text".to_string(),
417 "fix",
418 ));
419 assert!(!result.passed); assert_eq!(result.score, 50); }
422
423 #[test]
424 fn test_validation_result_has_critical() {
425 let mut result = ValidationResult::pass(100);
426 assert!(!result.has_critical());
427
428 result.add_violation(ValidationViolation::new(
429 "test",
430 ValidationSeverity::Critical,
431 "loc".to_string(),
432 "text".to_string(),
433 "fix",
434 ));
435 assert!(result.has_critical());
436 }
437
438 #[test]
439 fn test_validation_result_has_errors() {
440 let mut result = ValidationResult::pass(100);
441 assert!(!result.has_errors());
442
443 result.add_violation(ValidationViolation::new(
444 "test",
445 ValidationSeverity::Error,
446 "loc".to_string(),
447 "text".to_string(),
448 "fix",
449 ));
450 assert!(result.has_errors());
451 }
452
453 #[test]
454 fn test_validation_result_format_display_no_violations() {
455 let result = ValidationResult::pass(100);
456 let output = result.format_display();
457 assert!(output.contains("Quality Score: 100/100"));
458 assert!(output.contains("No violations found"));
459 }
460
461 #[test]
462 fn test_validation_result_format_display_with_violations() {
463 let violations = vec![ValidationViolation::new(
464 "test_constraint",
465 ValidationSeverity::Error,
466 "line 1".to_string(),
467 "bad text".to_string(),
468 "use good text",
469 )];
470 let result = ValidationResult::fail(violations);
471 let output = result.format_display();
472 assert!(output.contains("[ERROR]"));
473 assert!(output.contains("test_constraint"));
474 assert!(output.contains("bad text"));
475 }
476
477 #[test]
478 fn test_validation_result_default() {
479 let result = ValidationResult::default();
480 assert!(!result.passed);
481 assert_eq!(result.score, 0);
482 }
483
484 #[test]
485 fn test_validation_result_score_floor() {
486 let mut result = ValidationResult::pass(100);
488 for _ in 0..10 {
489 result.add_violation(ValidationViolation::new(
490 "test",
491 ValidationSeverity::Critical,
492 "loc".to_string(),
493 "text".to_string(),
494 "fix",
495 ));
496 }
497 assert_eq!(result.score, 0); }
499
500 #[test]
505 fn test_content_validator_new() {
506 let validator = ContentValidator::new(ContentType::BookChapter);
507 assert!(std::mem::size_of_val(&validator) > 0);
509 }
510
511 #[test]
512 fn test_content_validator_clean_content() {
513 let validator = ContentValidator::new(ContentType::BookChapter);
514 let content = "# Title\n\nSome clean content here.\n\n```rust\nfn main() {}\n```\n";
515 let result = validator.validate(content);
516 assert!(result.passed);
517 }
518
519 #[test]
520 fn test_content_validator_meta_commentary() {
521 let validator = ContentValidator::new(ContentType::BookChapter);
522 let content = "# Title\n\nIn this chapter, we will learn about Rust.\n";
523 let result = validator.validate(content);
524 assert!(result.violations.iter().any(|v| v.constraint == "no_meta_commentary"));
526 }
527
528 #[test]
529 fn test_content_validator_instructor_voice() {
530 let validator = ContentValidator::new(ContentType::BookChapter);
531 let content = "# Title\n\nThe code has been written and will be shown below.\n";
532 let result = validator.validate(content);
533 assert!(result.violations.iter().any(|v| v.constraint == "instructor_voice"));
535 }
536
537 #[test]
538 fn test_content_validator_heading_hierarchy() {
539 let validator = ContentValidator::new(ContentType::BookChapter);
540 let content = "# Title\n\n### Skipped H2\n";
541 let result = validator.validate(content);
542 assert!(result.violations.iter().any(|v| v.constraint == "heading_hierarchy"));
544 }
545
546 #[test]
547 fn test_content_validator_blog_post_missing_frontmatter() {
548 let validator = ContentValidator::new(ContentType::BlogPost);
549 let content = "# My Blog Post\n\nContent here.\n";
550 let result = validator.validate(content);
551 assert!(!result.passed);
553 assert!(result.violations.iter().any(|v| v.constraint == "frontmatter_present"));
554 }
555
556 #[test]
557 fn test_content_validator_blog_post_with_frontmatter() {
558 let validator = ContentValidator::new(ContentType::BlogPost);
559 let content = "+++\ntitle = \"Test\"\n+++\n\n# My Blog Post\n\nContent here.\n";
560 let result = validator.validate(content);
561 assert!(!result.violations.iter().any(|v| v.constraint == "frontmatter_present"));
563 }
564
565 #[test]
566 fn test_content_validator_code_block_without_lang() {
567 let validator = ContentValidator::new(ContentType::BookChapter);
568 let content = "# Title\n\n```\ncode without language\n```\n";
569 let result = validator.validate(content);
570 assert!(result.violations.iter().any(|v| v.constraint == "code_block_language"));
572 }
573}