ricecoder_specs/
validation.rs

1//! Validation engine for specs
2
3use crate::error::{Severity, SpecError, ValidationError};
4use crate::models::Spec;
5use regex::Regex;
6
7/// Validates spec structure and semantic correctness
8pub struct ValidationEngine;
9
10impl ValidationEngine {
11    /// Validate a spec - runs all validation checks
12    pub fn validate(spec: &Spec) -> Result<(), SpecError> {
13        let mut all_errors = Vec::new();
14
15        // Run structure validation
16        if let Err(errors) = Self::validate_structure(spec) {
17            all_errors.extend(errors);
18        }
19
20        // Run EARS compliance validation
21        if let Err(errors) = Self::validate_ears_compliance(spec) {
22            all_errors.extend(errors);
23        }
24
25        // Run INCOSE rules validation
26        if let Err(errors) = Self::validate_incose_rules(spec) {
27            all_errors.extend(errors);
28        }
29
30        if !all_errors.is_empty() {
31            return Err(SpecError::ValidationFailed(all_errors));
32        }
33
34        Ok(())
35    }
36
37    /// Validate spec structure - checks required fields and structure
38    pub fn validate_structure(spec: &Spec) -> Result<(), Vec<ValidationError>> {
39        let mut errors = Vec::new();
40        let path = format!("spec:{}", spec.id);
41
42        // Check required fields
43        if spec.id.is_empty() {
44            errors.push(ValidationError {
45                path: path.clone(),
46                line: 1,
47                column: 1,
48                message: "Spec ID is required and cannot be empty".to_string(),
49                severity: Severity::Error,
50            });
51        }
52
53        if spec.name.is_empty() {
54            errors.push(ValidationError {
55                path: path.clone(),
56                line: 2,
57                column: 1,
58                message: "Spec name is required and cannot be empty".to_string(),
59                severity: Severity::Error,
60            });
61        }
62
63        if spec.version.is_empty() {
64            errors.push(ValidationError {
65                path: path.clone(),
66                line: 3,
67                column: 1,
68                message: "Spec version is required and cannot be empty".to_string(),
69                severity: Severity::Error,
70            });
71        }
72
73        // Validate requirements structure
74        for (idx, req) in spec.requirements.iter().enumerate() {
75            let req_line = 10 + (idx * 5);
76
77            if req.id.is_empty() {
78                errors.push(ValidationError {
79                    path: path.clone(),
80                    line: req_line,
81                    column: 1,
82                    message: format!("Requirement {} has empty ID", idx + 1),
83                    severity: Severity::Error,
84                });
85            }
86
87            if req.user_story.is_empty() {
88                errors.push(ValidationError {
89                    path: path.clone(),
90                    line: req_line + 1,
91                    column: 1,
92                    message: format!("Requirement {} has empty user story", req.id),
93                    severity: Severity::Warning,
94                });
95            }
96
97            if req.acceptance_criteria.is_empty() {
98                errors.push(ValidationError {
99                    path: path.clone(),
100                    line: req_line + 2,
101                    column: 1,
102                    message: format!("Requirement {} has no acceptance criteria", req.id),
103                    severity: Severity::Warning,
104                });
105            }
106
107            // Validate acceptance criteria
108            for (ac_idx, ac) in req.acceptance_criteria.iter().enumerate() {
109                let ac_line = req_line + 3 + ac_idx;
110
111                if ac.id.is_empty() {
112                    errors.push(ValidationError {
113                        path: path.clone(),
114                        line: ac_line,
115                        column: 1,
116                        message: format!(
117                            "Acceptance criterion {} in requirement {} has empty ID",
118                            ac_idx + 1,
119                            req.id
120                        ),
121                        severity: Severity::Error,
122                    });
123                }
124
125                if ac.when.is_empty() {
126                    errors.push(ValidationError {
127                        path: path.clone(),
128                        line: ac_line,
129                        column: 1,
130                        message: format!(
131                            "Acceptance criterion {} in requirement {} has empty 'when' clause",
132                            ac.id, req.id
133                        ),
134                        severity: Severity::Error,
135                    });
136                }
137
138                if ac.then.is_empty() {
139                    errors.push(ValidationError {
140                        path: path.clone(),
141                        line: ac_line,
142                        column: 1,
143                        message: format!(
144                            "Acceptance criterion {} in requirement {} has empty 'then' clause",
145                            ac.id, req.id
146                        ),
147                        severity: Severity::Error,
148                    });
149                }
150            }
151        }
152
153        // Validate tasks structure
154        for (idx, task) in spec.tasks.iter().enumerate() {
155            let task_line = 50 + (idx * 3);
156
157            if task.id.is_empty() {
158                errors.push(ValidationError {
159                    path: path.clone(),
160                    line: task_line,
161                    column: 1,
162                    message: format!("Task {} has empty ID", idx + 1),
163                    severity: Severity::Error,
164                });
165            }
166
167            if task.description.is_empty() {
168                errors.push(ValidationError {
169                    path: path.clone(),
170                    line: task_line + 1,
171                    column: 1,
172                    message: format!("Task {} has empty description", task.id),
173                    severity: Severity::Warning,
174                });
175            }
176        }
177
178        if errors.is_empty() {
179            Ok(())
180        } else {
181            Err(errors)
182        }
183    }
184
185    /// Validate EARS compliance - checks requirement patterns
186    pub fn validate_ears_compliance(spec: &Spec) -> Result<(), Vec<ValidationError>> {
187        let mut errors = Vec::new();
188        let path = format!("spec:{}", spec.id);
189
190        // EARS patterns to check
191        let ubiquitous_pattern = Regex::new(r"(?i)^THE\s+\w+\s+SHALL").unwrap();
192        let event_driven_pattern =
193            Regex::new(r"(?i)^WHEN\s+.+\s+THEN\s+THE\s+\w+\s+SHALL").unwrap();
194        let state_driven_pattern = Regex::new(r"(?i)^WHILE\s+.+\s+THE\s+\w+\s+SHALL").unwrap();
195        let unwanted_event_pattern =
196            Regex::new(r"(?i)^IF\s+.+\s+THEN\s+THE\s+\w+\s+SHALL").unwrap();
197        let optional_pattern = Regex::new(r"(?i)^WHERE\s+.+\s+THE\s+\w+\s+SHALL").unwrap();
198
199        for (idx, req) in spec.requirements.iter().enumerate() {
200            let req_line = 10 + (idx * 5);
201
202            // Check each acceptance criterion for EARS compliance
203            for (ac_idx, ac) in req.acceptance_criteria.iter().enumerate() {
204                let ac_line = req_line + 3 + ac_idx;
205                let combined = format!("{} {}", ac.when, ac.then);
206
207                // Check if it matches any EARS pattern
208                let matches_ears = ubiquitous_pattern.is_match(&combined)
209                    || event_driven_pattern.is_match(&combined)
210                    || state_driven_pattern.is_match(&combined)
211                    || unwanted_event_pattern.is_match(&combined)
212                    || optional_pattern.is_match(&combined);
213
214                if !matches_ears {
215                    errors.push(ValidationError {
216                        path: path.clone(),
217                        line: ac_line,
218                        column: 1,
219                        message: format!(
220                            "Acceptance criterion {} does not match EARS pattern. \
221                             Expected one of: WHEN/THEN, WHILE, IF/THEN, WHERE, or THE...SHALL",
222                            ac.id
223                        ),
224                        severity: Severity::Warning,
225                    });
226                }
227            }
228        }
229
230        if errors.is_empty() {
231            Ok(())
232        } else {
233            Err(errors)
234        }
235    }
236
237    /// Validate INCOSE semantic rules - checks semantic correctness
238    pub fn validate_incose_rules(spec: &Spec) -> Result<(), Vec<ValidationError>> {
239        let mut errors = Vec::new();
240        let path = format!("spec:{}", spec.id);
241
242        for (idx, req) in spec.requirements.iter().enumerate() {
243            let req_line = 10 + (idx * 5);
244
245            // Rule 1: Active voice - check for passive constructions
246            if Self::is_passive_voice(&req.user_story) {
247                errors.push(ValidationError {
248                    path: path.clone(),
249                    line: req_line + 1,
250                    column: 1,
251                    message: format!(
252                        "Requirement {} user story appears to use passive voice. \
253                         Use active voice (e.g., 'I want' instead of 'should be able to')",
254                        req.id
255                    ),
256                    severity: Severity::Warning,
257                });
258            }
259
260            // Rule 2: No vague terms
261            if Self::contains_vague_terms(&req.user_story) {
262                errors.push(ValidationError {
263                    path: path.clone(),
264                    line: req_line + 1,
265                    column: 1,
266                    message: format!(
267                        "Requirement {} user story contains vague terms. \
268                         Use specific, measurable language",
269                        req.id
270                    ),
271                    severity: Severity::Warning,
272                });
273            }
274
275            // Rule 3: Check acceptance criteria for vague terms
276            for (ac_idx, ac) in req.acceptance_criteria.iter().enumerate() {
277                let ac_line = req_line + 3 + ac_idx;
278
279                if Self::contains_vague_terms(&ac.when) || Self::contains_vague_terms(&ac.then) {
280                    errors.push(ValidationError {
281                        path: path.clone(),
282                        line: ac_line,
283                        column: 1,
284                        message: format!(
285                            "Acceptance criterion {} contains vague terms. \
286                             Use specific, measurable language",
287                            ac.id
288                        ),
289                        severity: Severity::Warning,
290                    });
291                }
292
293                // Rule 4: Check for negative statements
294                if Self::contains_negative_statement(&ac.when)
295                    || Self::contains_negative_statement(&ac.then)
296                {
297                    errors.push(ValidationError {
298                        path: path.clone(),
299                        line: ac_line,
300                        column: 1,
301                        message: format!(
302                            "Acceptance criterion {} uses negative statements. \
303                             Rephrase as positive requirements",
304                            ac.id
305                        ),
306                        severity: Severity::Info,
307                    });
308                }
309
310                // Rule 5: Check for pronouns
311                if Self::contains_pronouns(&ac.when) || Self::contains_pronouns(&ac.then) {
312                    errors.push(ValidationError {
313                        path: path.clone(),
314                        line: ac_line,
315                        column: 1,
316                        message: format!(
317                            "Acceptance criterion {} uses pronouns (it, them, etc.). \
318                             Use explicit references",
319                            ac.id
320                        ),
321                        severity: Severity::Info,
322                    });
323                }
324            }
325        }
326
327        if errors.is_empty() {
328            Ok(())
329        } else {
330            Err(errors)
331        }
332    }
333
334    // Helper functions for INCOSE validation
335
336    fn is_passive_voice(text: &str) -> bool {
337        let passive_indicators = ["should be", "can be", "is able to", "is required to"];
338        passive_indicators
339            .iter()
340            .any(|indicator| text.to_lowercase().contains(indicator))
341    }
342
343    fn contains_vague_terms(text: &str) -> bool {
344        let vague_terms = vec![
345            "quickly",
346            "slowly",
347            "fast",
348            "adequate",
349            "sufficient",
350            "appropriate",
351            "suitable",
352            "good",
353            "bad",
354            "nice",
355            "easy",
356            "hard",
357            "simple",
358            "complex",
359            "etc",
360            "and so on",
361            "as needed",
362            "as appropriate",
363            "where possible",
364            "if possible",
365        ];
366        let lower = text.to_lowercase();
367        vague_terms.iter().any(|term| lower.contains(term))
368    }
369
370    fn contains_negative_statement(text: &str) -> bool {
371        let lower = text.to_lowercase();
372        lower.contains("shall not")
373            || lower.contains("should not")
374            || lower.contains("must not")
375            || lower.contains("cannot")
376            || lower.contains("will not")
377    }
378
379    fn contains_pronouns(text: &str) -> bool {
380        let pronouns = [
381            "it ", " it", "them", "they", "this", "that", "these", "those",
382        ];
383        let lower = text.to_lowercase();
384        pronouns.iter().any(|pronoun| lower.contains(pronoun))
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::models::*;
392    use chrono::Utc;
393
394    fn create_minimal_spec() -> Spec {
395        Spec {
396            id: "test-spec".to_string(),
397            name: "Test Spec".to_string(),
398            version: "1.0.0".to_string(),
399            requirements: vec![],
400            design: None,
401            tasks: vec![],
402            metadata: SpecMetadata {
403                author: None,
404                created_at: Utc::now(),
405                updated_at: Utc::now(),
406                phase: SpecPhase::Requirements,
407                status: SpecStatus::Draft,
408            },
409            inheritance: None,
410        }
411    }
412
413    fn create_valid_requirement() -> Requirement {
414        Requirement {
415            id: "REQ-1".to_string(),
416            user_story: "As a user, I want to create tasks".to_string(),
417            acceptance_criteria: vec![AcceptanceCriterion {
418                id: "AC-1.1".to_string(),
419                when: "user enters task description".to_string(),
420                then: "THE system SHALL add task to list".to_string(),
421            }],
422            priority: Priority::Must,
423        }
424    }
425
426    // ============================================================================
427    // Structure Validation Tests
428    // ============================================================================
429
430    #[test]
431    fn test_validate_structure_valid_spec() {
432        let spec = create_minimal_spec();
433        assert!(ValidationEngine::validate_structure(&spec).is_ok());
434    }
435
436    #[test]
437    fn test_validate_structure_empty_id() {
438        let mut spec = create_minimal_spec();
439        spec.id = String::new();
440
441        let result = ValidationEngine::validate_structure(&spec);
442        assert!(result.is_err());
443
444        let errors = result.unwrap_err();
445        assert!(errors.iter().any(|e| e.message.contains("ID is required")));
446    }
447
448    #[test]
449    fn test_validate_structure_empty_name() {
450        let mut spec = create_minimal_spec();
451        spec.name = String::new();
452
453        let result = ValidationEngine::validate_structure(&spec);
454        assert!(result.is_err());
455
456        let errors = result.unwrap_err();
457        assert!(errors
458            .iter()
459            .any(|e| e.message.contains("name is required")));
460    }
461
462    #[test]
463    fn test_validate_structure_empty_version() {
464        let mut spec = create_minimal_spec();
465        spec.version = String::new();
466
467        let result = ValidationEngine::validate_structure(&spec);
468        assert!(result.is_err());
469
470        let errors = result.unwrap_err();
471        assert!(errors
472            .iter()
473            .any(|e| e.message.contains("version is required")));
474    }
475
476    #[test]
477    fn test_validate_structure_requirement_empty_id() {
478        let mut spec = create_minimal_spec();
479        let mut req = create_valid_requirement();
480        req.id = String::new();
481        spec.requirements.push(req);
482
483        let result = ValidationEngine::validate_structure(&spec);
484        assert!(result.is_err());
485
486        let errors = result.unwrap_err();
487        assert!(errors.iter().any(|e| e.message.contains("empty ID")));
488    }
489
490    #[test]
491    fn test_validate_structure_requirement_empty_user_story() {
492        let mut spec = create_minimal_spec();
493        let mut req = create_valid_requirement();
494        req.user_story = String::new();
495        spec.requirements.push(req);
496
497        let result = ValidationEngine::validate_structure(&spec);
498        assert!(result.is_err());
499
500        let errors = result.unwrap_err();
501        assert!(errors
502            .iter()
503            .any(|e| e.message.contains("empty user story")));
504    }
505
506    #[test]
507    fn test_validate_structure_requirement_no_criteria() {
508        let mut spec = create_minimal_spec();
509        let mut req = create_valid_requirement();
510        req.acceptance_criteria = vec![];
511        spec.requirements.push(req);
512
513        let result = ValidationEngine::validate_structure(&spec);
514        assert!(result.is_err());
515
516        let errors = result.unwrap_err();
517        assert!(errors
518            .iter()
519            .any(|e| e.message.contains("no acceptance criteria")));
520    }
521
522    #[test]
523    fn test_validate_structure_criterion_empty_id() {
524        let mut spec = create_minimal_spec();
525        let mut req = create_valid_requirement();
526        req.acceptance_criteria[0].id = String::new();
527        spec.requirements.push(req);
528
529        let result = ValidationEngine::validate_structure(&spec);
530        assert!(result.is_err());
531
532        let errors = result.unwrap_err();
533        assert!(errors.iter().any(|e| e.message.contains("empty ID")));
534    }
535
536    #[test]
537    fn test_validate_structure_criterion_empty_when() {
538        let mut spec = create_minimal_spec();
539        let mut req = create_valid_requirement();
540        req.acceptance_criteria[0].when = String::new();
541        spec.requirements.push(req);
542
543        let result = ValidationEngine::validate_structure(&spec);
544        assert!(result.is_err());
545
546        let errors = result.unwrap_err();
547        assert!(errors
548            .iter()
549            .any(|e| e.message.contains("empty 'when' clause")));
550    }
551
552    #[test]
553    fn test_validate_structure_criterion_empty_then() {
554        let mut spec = create_minimal_spec();
555        let mut req = create_valid_requirement();
556        req.acceptance_criteria[0].then = String::new();
557        spec.requirements.push(req);
558
559        let result = ValidationEngine::validate_structure(&spec);
560        assert!(result.is_err());
561
562        let errors = result.unwrap_err();
563        assert!(errors
564            .iter()
565            .any(|e| e.message.contains("empty 'then' clause")));
566    }
567
568    #[test]
569    fn test_validate_structure_task_empty_id() {
570        let mut spec = create_minimal_spec();
571        let task = Task {
572            id: String::new(),
573            description: "Test task".to_string(),
574            subtasks: vec![],
575            requirements: vec![],
576            status: TaskStatus::NotStarted,
577            optional: false,
578        };
579        spec.tasks.push(task);
580
581        let result = ValidationEngine::validate_structure(&spec);
582        assert!(result.is_err());
583
584        let errors = result.unwrap_err();
585        assert!(errors.iter().any(|e| e.message.contains("empty ID")));
586    }
587
588    #[test]
589    fn test_validate_structure_task_empty_description() {
590        let mut spec = create_minimal_spec();
591        let task = Task {
592            id: "1".to_string(),
593            description: String::new(),
594            subtasks: vec![],
595            requirements: vec![],
596            status: TaskStatus::NotStarted,
597            optional: false,
598        };
599        spec.tasks.push(task);
600
601        let result = ValidationEngine::validate_structure(&spec);
602        assert!(result.is_err());
603
604        let errors = result.unwrap_err();
605        assert!(errors
606            .iter()
607            .any(|e| e.message.contains("empty description")));
608    }
609
610    #[test]
611    fn test_validate_structure_reports_file_path_and_line() {
612        let mut spec = create_minimal_spec();
613        spec.id = String::new();
614
615        let result = ValidationEngine::validate_structure(&spec);
616        assert!(result.is_err());
617
618        let errors = result.unwrap_err();
619        assert!(!errors.is_empty());
620
621        let error = &errors[0];
622        assert!(error.path.contains("spec:"));
623        assert!(error.line > 0);
624        assert!(error.column > 0);
625    }
626
627    #[test]
628    fn test_validate_structure_error_severity() {
629        let mut spec = create_minimal_spec();
630        let mut req = create_valid_requirement();
631        req.user_story = String::new();
632        spec.requirements.push(req);
633
634        let result = ValidationEngine::validate_structure(&spec);
635        assert!(result.is_err());
636
637        let errors = result.unwrap_err();
638        let warning_errors: Vec<_> = errors
639            .iter()
640            .filter(|e| e.severity == Severity::Warning)
641            .collect();
642        assert!(!warning_errors.is_empty());
643    }
644
645    // ============================================================================
646    // EARS Compliance Tests
647    // ============================================================================
648
649    #[test]
650    fn test_validate_ears_compliance_valid_event_driven() {
651        let mut spec = create_minimal_spec();
652        spec.requirements.push(create_valid_requirement());
653
654        let result = ValidationEngine::validate_ears_compliance(&spec);
655        // Should pass or have only info-level issues
656        if let Err(errors) = result {
657            assert!(errors.iter().all(|e| e.severity != Severity::Error));
658        }
659    }
660
661    #[test]
662    fn test_validate_ears_compliance_ubiquitous_pattern() {
663        let mut spec = create_minimal_spec();
664        let mut req = create_valid_requirement();
665        req.acceptance_criteria[0].when = String::new();
666        req.acceptance_criteria[0].then = "THE system SHALL validate input".to_string();
667        spec.requirements.push(req);
668
669        let result = ValidationEngine::validate_ears_compliance(&spec);
670        // Should pass or have only info-level issues
671        if let Err(errors) = result {
672            assert!(errors.iter().all(|e| e.severity != Severity::Error));
673        }
674    }
675
676    #[test]
677    fn test_validate_ears_compliance_non_compliant() {
678        let mut spec = create_minimal_spec();
679        let mut req = create_valid_requirement();
680        req.acceptance_criteria[0].when = "something happens".to_string();
681        req.acceptance_criteria[0].then = "something else happens".to_string();
682        spec.requirements.push(req);
683
684        let result = ValidationEngine::validate_ears_compliance(&spec);
685        assert!(result.is_err());
686
687        let errors = result.unwrap_err();
688        assert!(errors.iter().any(|e| e.message.contains("EARS pattern")));
689    }
690
691    // ============================================================================
692    // INCOSE Rules Tests
693    // ============================================================================
694
695    #[test]
696    fn test_validate_incose_rules_valid_requirement() {
697        let mut spec = create_minimal_spec();
698        spec.requirements.push(create_valid_requirement());
699
700        let result = ValidationEngine::validate_incose_rules(&spec);
701        // Should pass or have only info-level issues
702        if let Err(errors) = result {
703            assert!(errors.iter().all(|e| e.severity != Severity::Error));
704        }
705    }
706
707    #[test]
708    fn test_validate_incose_rules_passive_voice() {
709        let mut spec = create_minimal_spec();
710        let mut req = create_valid_requirement();
711        req.user_story = "Tasks should be created by the system".to_string();
712        spec.requirements.push(req);
713
714        let result = ValidationEngine::validate_incose_rules(&spec);
715        assert!(result.is_err());
716
717        let errors = result.unwrap_err();
718        assert!(errors.iter().any(|e| e.message.contains("passive voice")));
719    }
720
721    #[test]
722    fn test_validate_incose_rules_vague_terms() {
723        let mut spec = create_minimal_spec();
724        let mut req = create_valid_requirement();
725        req.user_story = "As a user, I want to quickly create tasks".to_string();
726        spec.requirements.push(req);
727
728        let result = ValidationEngine::validate_incose_rules(&spec);
729        assert!(result.is_err());
730
731        let errors = result.unwrap_err();
732        assert!(errors.iter().any(|e| e.message.contains("vague terms")));
733    }
734
735    #[test]
736    fn test_validate_incose_rules_negative_statement() {
737        let mut spec = create_minimal_spec();
738        let mut req = create_valid_requirement();
739        req.acceptance_criteria[0].then = "THE system SHALL NOT delete tasks".to_string();
740        spec.requirements.push(req);
741
742        let result = ValidationEngine::validate_incose_rules(&spec);
743        assert!(result.is_err());
744
745        let errors = result.unwrap_err();
746        assert!(errors
747            .iter()
748            .any(|e| e.message.contains("negative statements")));
749    }
750
751    #[test]
752    fn test_validate_incose_rules_pronouns() {
753        let mut spec = create_minimal_spec();
754        let mut req = create_valid_requirement();
755        req.acceptance_criteria[0].then = "THE system SHALL process it".to_string();
756        spec.requirements.push(req);
757
758        let result = ValidationEngine::validate_incose_rules(&spec);
759        assert!(result.is_err());
760
761        let errors = result.unwrap_err();
762        assert!(errors.iter().any(|e| e.message.contains("pronouns")));
763    }
764
765    // ============================================================================
766    // Full Validation Tests
767    // ============================================================================
768
769    #[test]
770    fn test_validate_combines_all_checks() {
771        let mut spec = create_minimal_spec();
772        spec.requirements.push(create_valid_requirement());
773
774        let result = ValidationEngine::validate(&spec);
775        // Should pass or have only info-level issues
776        if let Err(SpecError::ValidationFailed(errors)) = result {
777            assert!(errors.iter().all(|e| e.severity != Severity::Error));
778        }
779    }
780
781    #[test]
782    fn test_validate_returns_all_errors() {
783        let mut spec = create_minimal_spec();
784        spec.id = String::new();
785        spec.name = String::new();
786
787        let result = ValidationEngine::validate(&spec);
788        assert!(result.is_err());
789
790        if let Err(SpecError::ValidationFailed(errors)) = result {
791            assert!(errors.len() >= 2);
792        }
793    }
794}