Skip to main content

batuta/content/
validation.rs

1//! Validation (Jidoka)
2//!
3//! Content validation for quality gates with stop-on-error behavior.
4
5use super::ContentType;
6use serde::{Deserialize, Serialize};
7
8/// Validation severity levels
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum ValidationSeverity {
11    /// Critical - must halt
12    Critical,
13    /// Error - should halt
14    Error,
15    /// Warning - flag for revision
16    Warning,
17    /// Info - informational
18    Info,
19}
20
21/// A single validation violation
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ValidationViolation {
24    /// Constraint that was violated
25    pub constraint: String,
26    /// Severity level
27    pub severity: ValidationSeverity,
28    /// Location in content (e.g., "paragraph 3", "code block 5")
29    pub location: String,
30    /// The offending text
31    pub text: String,
32    /// Suggested fix
33    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/// Validation result from content validation
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct ValidationResult {
57    /// Whether validation passed
58    pub passed: bool,
59    /// Quality score (0-100)
60    pub score: u8,
61    /// List of violations
62    pub violations: Vec<ValidationViolation>,
63}
64
65impl ValidationResult {
66    /// Create a passing result
67    pub fn pass(score: u8) -> Self {
68        Self { passed: true, score, violations: Vec::new() }
69    }
70
71    /// Create a failing result
72    pub fn fail(violations: Vec<ValidationViolation>) -> Self {
73        let score = Self::calculate_score(&violations);
74        Self { passed: false, score, violations }
75    }
76
77    /// Add a violation
78    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    /// Calculate score based on violations
87    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    /// Check if there are critical violations
101    pub fn has_critical(&self) -> bool {
102        self.violations.iter().any(|v| v.severity == ValidationSeverity::Critical)
103    }
104
105    /// Check if there are errors
106    pub fn has_errors(&self) -> bool {
107        self.violations.iter().any(|v| v.severity == ValidationSeverity::Error)
108    }
109
110    /// Format as display string
111    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/// Content validator for Jidoka quality gates
142#[derive(Debug, Clone)]
143pub struct ContentValidator {
144    /// Content type being validated
145    content_type: ContentType,
146}
147
148impl ContentValidator {
149    /// Create a new validator for a content type
150    pub fn new(content_type: ContentType) -> Self {
151        Self { content_type }
152    }
153
154    /// Validate content against all rules
155    pub fn validate(&self, content: &str) -> ValidationResult {
156        let mut result = ValidationResult::pass(100);
157
158        // Run all validation checks
159        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        // Content-type specific validation
165        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    /// Check for meta-commentary (Andon)
176    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    /// Validate instructor voice
206    fn validate_instructor_voice(&self, content: &str, result: &mut ValidationResult) {
207        // Check for passive voice indicators in instruction contexts
208        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            // Only check non-code lines
214            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    /// Validate code blocks have language specifiers
231    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                    // Starting a code block
239                    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                    // Ending a code block
253                    in_code_block = false;
254                }
255            }
256        }
257
258        // Check for unclosed code block
259        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    /// Validate heading hierarchy (no skipped levels)
271    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    /// Validate frontmatter presence (for BCH and BLP)
297    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    // =========================================================================
318    // ValidationSeverity Tests
319    // =========================================================================
320
321    #[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    // =========================================================================
337    // ValidationViolation Tests
338    // =========================================================================
339
340    #[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    // =========================================================================
370    // ValidationResult Tests
371    // =========================================================================
372
373    #[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); // 100 - 25 for error
393    }
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); // Warnings don't fail
406        assert_eq!(result.score, 90); // 100 - 10 for warning
407    }
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); // Critical fails
420        assert_eq!(result.score, 50); // 100 - 50 for critical
421    }
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        // Test that score doesn't go below 0
487        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); // Should floor at 0
498    }
499
500    // =========================================================================
501    // ContentValidator Tests
502    // =========================================================================
503
504    #[test]
505    fn test_content_validator_new() {
506        let validator = ContentValidator::new(ContentType::BookChapter);
507        // Just test it creates without panic
508        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        // Should have a warning for meta-commentary
525        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        // Should have a warning for passive voice
534        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        // Should have error for skipped heading level
543        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        // Should fail for missing TOML frontmatter
552        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        // Should not fail for frontmatter
562        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        // Should have warning for code block without language
571        assert!(result.violations.iter().any(|v| v.constraint == "code_block_language"));
572    }
573}