1use crate::error::{Severity, SpecError, ValidationError};
4use crate::models::Spec;
5use regex::Regex;
6
7pub struct ValidationEngine;
9
10impl ValidationEngine {
11 pub fn validate(spec: &Spec) -> Result<(), SpecError> {
13 let mut all_errors = Vec::new();
14
15 if let Err(errors) = Self::validate_structure(spec) {
17 all_errors.extend(errors);
18 }
19
20 if let Err(errors) = Self::validate_ears_compliance(spec) {
22 all_errors.extend(errors);
23 }
24
25 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 pub fn validate_structure(spec: &Spec) -> Result<(), Vec<ValidationError>> {
39 let mut errors = Vec::new();
40 let path = format!("spec:{}", spec.id);
41
42 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 #[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 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 #[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 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}