1use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, HashMap, HashSet};
29use std::fmt;
30use std::fs;
31use std::io;
32use std::path::{Path, PathBuf};
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct SpecRequirement {
37 pub section: String,
39 pub description: String,
41 pub category: Option<String>,
43 pub priority: u8,
45}
46
47impl SpecRequirement {
48 pub fn new(section: impl Into<String>, description: impl Into<String>) -> Self {
50 Self {
51 section: section.into(),
52 description: description.into(),
53 category: None,
54 priority: 1,
55 }
56 }
57
58 pub fn with_category(mut self, category: impl Into<String>) -> Self {
60 self.category = Some(category.into());
61 self
62 }
63
64 pub fn with_priority(mut self, priority: u8) -> Self {
66 self.priority = priority;
67 self
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct TraceabilityEntry {
74 pub spec_section: String,
76 pub requirement: String,
78 pub test_name: String,
80 pub test_file: PathBuf,
82 pub test_line: u32,
84 pub tags: Vec<String>,
86}
87
88impl TraceabilityEntry {
89 pub fn new(
91 spec_section: impl Into<String>,
92 requirement: impl Into<String>,
93 test_name: impl Into<String>,
94 test_file: impl Into<PathBuf>,
95 test_line: u32,
96 ) -> Self {
97 Self {
98 spec_section: spec_section.into(),
99 requirement: requirement.into(),
100 test_name: test_name.into(),
101 test_file: test_file.into(),
102 test_line,
103 tags: Vec::new(),
104 }
105 }
106
107 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
109 self.tags = tags;
110 self
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ScanWarning {
117 pub file: PathBuf,
119 pub line: u32,
121 pub message: String,
123}
124
125impl ScanWarning {
126 fn new(file: PathBuf, line: u32, message: impl Into<String>) -> Self {
127 Self {
128 file,
129 line,
130 message: message.into(),
131 }
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct TraceabilityScan {
138 pub entries: Vec<TraceabilityEntry>,
140 pub warnings: Vec<ScanWarning>,
142}
143
144#[derive(Debug)]
146pub struct TraceabilityScanError {
147 pub path: PathBuf,
149 pub source: io::Error,
151}
152
153impl fmt::Display for TraceabilityScanError {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 write!(
156 f,
157 "traceability scan failed for {}: {}",
158 self.path.display(),
159 self.source
160 )
161 }
162}
163
164impl std::error::Error for TraceabilityScanError {}
165
166pub fn scan_conformance_attributes(
170 paths: &[PathBuf],
171) -> Result<TraceabilityScan, TraceabilityScanError> {
172 let mut files = Vec::new();
173 for path in paths {
174 collect_rs_files(path, &mut files)?;
175 }
176
177 let mut entries = Vec::new();
178 let mut warnings = Vec::new();
179 for file in files {
180 let scan = scan_file_for_conformance(&file)?;
181 entries.extend(scan.entries);
182 warnings.extend(scan.warnings);
183 }
184
185 Ok(TraceabilityScan { entries, warnings })
186}
187
188pub fn requirements_from_entries(entries: &[TraceabilityEntry]) -> Vec<SpecRequirement> {
190 let mut by_section: BTreeMap<String, String> = BTreeMap::new();
191 for entry in entries {
192 by_section
193 .entry(entry.spec_section.clone())
194 .or_insert_with(|| entry.requirement.clone());
195 }
196 by_section
197 .into_iter()
198 .map(|(section, description)| SpecRequirement::new(section, description))
199 .collect()
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct TraceabilityMatrix {
205 pub requirements: Vec<SpecRequirement>,
207 pub entries: Vec<TraceabilityEntry>,
209 #[serde(skip)]
211 coverage_cache: HashMap<String, Vec<String>>,
212}
213
214impl TraceabilityMatrix {
215 pub fn new(requirements: Vec<SpecRequirement>) -> Self {
217 Self {
218 requirements,
219 entries: Vec::new(),
220 coverage_cache: HashMap::new(),
221 }
222 }
223
224 pub fn from_entries(
226 requirements: Vec<SpecRequirement>,
227 entries: Vec<TraceabilityEntry>,
228 ) -> Self {
229 let mut matrix = TraceabilityMatrix::new(requirements);
230 for entry in entries {
231 matrix.add_entry(entry);
232 }
233 matrix
234 }
235
236 pub fn empty() -> Self {
238 Self::new(Vec::new())
239 }
240
241 pub fn add_requirement(&mut self, requirement: SpecRequirement) {
243 self.requirements.push(requirement);
244 self.invalidate_cache();
245 }
246
247 pub fn add_test_mapping(
249 &mut self,
250 spec_section: impl Into<String>,
251 test_name: impl Into<String>,
252 test_file: impl Into<PathBuf>,
253 test_line: u32,
254 ) {
255 let section = spec_section.into();
256 let requirement = self
257 .requirements
258 .iter()
259 .find(|r| r.section == section)
260 .map(|r| r.description.clone())
261 .unwrap_or_default();
262
263 self.entries.push(TraceabilityEntry::new(
264 section,
265 requirement,
266 test_name,
267 test_file,
268 test_line,
269 ));
270 self.invalidate_cache();
271 }
272
273 pub fn add_entry(&mut self, entry: TraceabilityEntry) {
275 self.entries.push(entry);
276 self.invalidate_cache();
277 }
278
279 fn build_cache(&mut self) {
281 self.coverage_cache.clear();
282 for entry in &self.entries {
283 self.coverage_cache
284 .entry(entry.spec_section.clone())
285 .or_default()
286 .push(entry.test_name.clone());
287 }
288 }
289
290 fn invalidate_cache(&mut self) {
292 self.coverage_cache.clear();
293 }
294
295 fn ensure_cache(&mut self) {
297 if self.coverage_cache.is_empty() && !self.entries.is_empty() {
298 self.build_cache();
299 }
300 }
301
302 pub fn covered_sections(&mut self) -> HashSet<String> {
304 self.ensure_cache();
305 self.coverage_cache.keys().cloned().collect()
306 }
307
308 pub fn missing_sections(&mut self) -> Vec<String> {
310 self.ensure_cache();
311 self.requirements
312 .iter()
313 .filter(|r| !self.coverage_cache.contains_key(&r.section))
314 .map(|r| r.section.clone())
315 .collect()
316 }
317
318 pub fn tests_for_section(&mut self, section: &str) -> Vec<&TraceabilityEntry> {
320 self.entries
321 .iter()
322 .filter(|e| e.spec_section == section)
323 .collect()
324 }
325
326 pub fn coverage_percentage(&mut self) -> f64 {
328 if self.requirements.is_empty() {
329 return 100.0;
330 }
331 let covered = self.covered_sections();
332 (covered.len() as f64 / self.requirements.len() as f64) * 100.0
333 }
334
335 pub fn coverage_stats(&mut self) -> CoverageStats {
337 self.ensure_cache();
338 let covered = self.covered_sections();
339 CoverageStats {
340 total_requirements: self.requirements.len(),
341 covered_requirements: covered.len(),
342 missing_requirements: self.requirements.len() - covered.len(),
343 total_tests: self.entries.len(),
344 coverage_percentage: self.coverage_percentage(),
345 }
346 }
347
348 pub fn to_markdown(&mut self) -> String {
350 self.ensure_cache();
351 let mut output = String::new();
352
353 output.push_str("# Specification Traceability Matrix\n\n");
355
356 let stats = self.coverage_stats();
358 output.push_str("## Summary\n\n");
359 output.push_str(&format!(
360 "- **Total Requirements:** {}\n",
361 stats.total_requirements
362 ));
363 output.push_str(&format!(
364 "- **Covered Requirements:** {}\n",
365 stats.covered_requirements
366 ));
367 output.push_str(&format!(
368 "- **Missing Requirements:** {}\n",
369 stats.missing_requirements
370 ));
371 output.push_str(&format!("- **Total Tests:** {}\n", stats.total_tests));
372 output.push_str(&format!(
373 "- **Coverage:** {:.1}%\n\n",
374 stats.coverage_percentage
375 ));
376
377 output.push_str("## Coverage Matrix\n\n");
379 output.push_str("| Section | Requirement | Tests | Status |\n");
380 output.push_str("|---------|-------------|-------|--------|\n");
381
382 for req in &self.requirements {
383 let tests = self
384 .coverage_cache
385 .get(&req.section)
386 .map_or_else(|| "-".to_string(), |t| t.join(", "));
387 let status = if self.coverage_cache.contains_key(&req.section) {
388 "Covered"
389 } else {
390 "**MISSING**"
391 };
392 output.push_str(&format!(
393 "| {} | {} | {} | {} |\n",
394 req.section, req.description, tests, status
395 ));
396 }
397
398 let missing = self.missing_sections();
400 if !missing.is_empty() {
401 output.push_str("\n## Missing Coverage\n\n");
402 output.push_str("The following specification sections have no test coverage:\n\n");
403 for section in &missing {
404 if let Some(req) = self.requirements.iter().find(|r| r.section == *section) {
405 output.push_str(&format!("- **{}**: {}\n", section, req.description));
406 } else {
407 output.push_str(&format!("- **{}**\n", section));
408 }
409 }
410 }
411
412 output.push_str("\n## Test Details\n\n");
414 output.push_str("| Test | File | Line | Covers |\n");
415 output.push_str("|------|------|------|--------|\n");
416
417 for entry in &self.entries {
418 output.push_str(&format!(
419 "| {} | {} | {} | {} |\n",
420 entry.test_name,
421 entry.test_file.display(),
422 entry.test_line,
423 entry.spec_section
424 ));
425 }
426
427 output
428 }
429
430 pub fn to_json(&self) -> Result<String, serde_json::Error> {
432 serde_json::to_string_pretty(self)
433 }
434
435 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
437 serde_json::from_str(json)
438 }
439
440 pub fn meets_threshold(&mut self, threshold_percent: f64) -> bool {
442 self.coverage_percentage() >= threshold_percent
443 }
444
445 pub fn ci_report(&mut self) -> CiReport {
447 let stats = self.coverage_stats();
448 let missing = self.missing_sections();
449 CiReport {
450 passed: missing.is_empty(),
451 coverage_percentage: stats.coverage_percentage,
452 total_requirements: stats.total_requirements,
453 covered_requirements: stats.covered_requirements,
454 missing_sections: missing,
455 }
456 }
457}
458
459impl Default for TraceabilityMatrix {
460 fn default() -> Self {
461 Self::empty()
462 }
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct CoverageStats {
468 pub total_requirements: usize,
470 pub covered_requirements: usize,
472 pub missing_requirements: usize,
474 pub total_tests: usize,
476 pub coverage_percentage: f64,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct CiReport {
483 pub passed: bool,
485 pub coverage_percentage: f64,
487 pub total_requirements: usize,
489 pub covered_requirements: usize,
491 pub missing_sections: Vec<String>,
493}
494
495impl fmt::Display for CiReport {
496 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
497 writeln!(
498 f,
499 "Coverage: {:.1}% ({}/{} requirements)",
500 self.coverage_percentage, self.covered_requirements, self.total_requirements
501 )?;
502 if !self.missing_sections.is_empty() {
503 writeln!(f, "Missing: {}", self.missing_sections.join(", "))?;
504 }
505 if self.passed {
506 writeln!(f, "Status: PASSED")
507 } else {
508 writeln!(f, "Status: FAILED")
509 }
510 }
511}
512
513#[derive(Debug, Default)]
515pub struct TraceabilityMatrixBuilder {
516 requirements: Vec<SpecRequirement>,
517 entries: Vec<TraceabilityEntry>,
518}
519
520impl TraceabilityMatrixBuilder {
521 pub fn new() -> Self {
523 Self::default()
524 }
525
526 pub fn requirement(mut self, section: &str, description: &str) -> Self {
528 self.requirements
529 .push(SpecRequirement::new(section, description));
530 self
531 }
532
533 pub fn requirement_with_category(
535 mut self,
536 section: &str,
537 description: &str,
538 category: &str,
539 ) -> Self {
540 self.requirements
541 .push(SpecRequirement::new(section, description).with_category(category));
542 self
543 }
544
545 pub fn test(
547 mut self,
548 spec_section: &str,
549 test_name: &str,
550 test_file: &str,
551 test_line: u32,
552 ) -> Self {
553 let requirement = self
554 .requirements
555 .iter()
556 .find(|r| r.section == spec_section)
557 .map(|r| r.description.clone())
558 .unwrap_or_default();
559
560 self.entries.push(TraceabilityEntry::new(
561 spec_section,
562 requirement,
563 test_name,
564 test_file,
565 test_line,
566 ));
567 self
568 }
569
570 pub fn build(self) -> TraceabilityMatrix {
572 let mut matrix = TraceabilityMatrix::new(self.requirements);
573 matrix.entries = self.entries;
574 matrix
575 }
576}
577
578#[macro_export]
589macro_rules! trace_entries {
590 ($(($section:expr, $test:expr, $file:expr, $line:expr)),* $(,)?) => {
591 vec![
592 $(
593 $crate::traceability::TraceabilityEntry::new(
594 $section,
595 "", $test,
597 $file,
598 $line,
599 ),
600 )*
601 ]
602 };
603}
604
605fn collect_rs_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<(), TraceabilityScanError> {
606 if path.is_dir() {
607 let entries = fs::read_dir(path).map_err(|err| TraceabilityScanError {
608 path: path.to_path_buf(),
609 source: err,
610 })?;
611 for entry in entries {
612 let entry = entry.map_err(|err| TraceabilityScanError {
613 path: path.to_path_buf(),
614 source: err,
615 })?;
616 let entry_path = entry.path();
617 collect_rs_files(&entry_path, out)?;
618 }
619 return Ok(());
620 }
621
622 if path
623 .extension()
624 .and_then(|ext| ext.to_str())
625 .is_some_and(|ext| ext == "rs")
626 {
627 out.push(path.to_path_buf());
628 }
629
630 Ok(())
631}
632
633fn scan_file_for_conformance(path: &Path) -> Result<TraceabilityScan, TraceabilityScanError> {
634 let content = fs::read_to_string(path).map_err(|err| TraceabilityScanError {
635 path: path.to_path_buf(),
636 source: err,
637 })?;
638
639 let lines: Vec<&str> = content.lines().collect();
640 let mut pending = Vec::new();
641 let mut entries = Vec::new();
642 let mut warnings = Vec::new();
643
644 let mut index = 0usize;
645 while index < lines.len() {
646 let line = lines[index];
647 let trimmed = line.trim_start();
648
649 if trimmed.starts_with("#[conformance") {
650 let mut attr = trimmed.to_string();
651 let start_line = index + 1;
652 while !attr.contains(']') && index + 1 < lines.len() {
653 index += 1;
654 attr.push('\n');
655 attr.push_str(lines[index]);
656 }
657 match parse_conformance_attribute(&attr) {
658 Ok(args) => pending.push(args),
659 Err(message) => warnings.push(ScanWarning::new(
660 path.to_path_buf(),
661 start_line as u32,
662 message,
663 )),
664 }
665 index += 1;
666 continue;
667 }
668
669 if let Some(name) = parse_fn_name(trimmed)
670 && !pending.is_empty()
671 {
672 let line_number = (index + 1) as u32;
673 for args in std::mem::take(&mut pending) {
674 entries.push(TraceabilityEntry::new(
675 args.spec,
676 args.requirement,
677 name.clone(),
678 path.to_path_buf(),
679 line_number,
680 ));
681 }
682 }
683
684 index += 1;
685 }
686
687 if !pending.is_empty() {
688 for args in pending {
689 warnings.push(ScanWarning::new(
690 path.to_path_buf(),
691 0,
692 format!(
693 "conformance attribute for spec '{}' was not followed by a test function",
694 args.spec
695 ),
696 ));
697 }
698 }
699
700 Ok(TraceabilityScan { entries, warnings })
701}
702
703#[derive(Debug, Clone)]
704struct ConformanceArgs {
705 spec: String,
706 requirement: String,
707}
708
709fn parse_conformance_attribute(input: &str) -> Result<ConformanceArgs, String> {
710 let start = input
711 .find("conformance")
712 .ok_or_else(|| "missing conformance attribute".to_string())?;
713 let after = &input[start..];
714 let open = after
715 .find('(')
716 .ok_or_else(|| "conformance attribute missing '('".to_string())?;
717 let close = after
718 .rfind(')')
719 .ok_or_else(|| "conformance attribute missing ')'".to_string())?;
720 if close <= open {
721 return Err("conformance attribute has malformed arguments".to_string());
722 }
723 let args = &after[open + 1..close];
724 parse_conformance_args(args)
725}
726
727fn parse_conformance_args(input: &str) -> Result<ConformanceArgs, String> {
728 let mut spec = None;
729 let mut requirement = None;
730
731 for part in split_args(input) {
732 let part = part.trim();
733 if part.is_empty() {
734 continue;
735 }
736 let (key, value) = split_key_value(part)?;
737 let value = parse_string_literal(value)?;
738 match key {
739 "spec" => spec = Some(value),
740 "requirement" => requirement = Some(value),
741 other => {
742 return Err(format!(
743 "conformance attribute has unknown key '{other}', expected 'spec' or 'requirement'"
744 ));
745 }
746 }
747 }
748
749 let spec = spec.ok_or_else(|| "conformance attribute missing 'spec'".to_string())?;
750 let requirement =
751 requirement.ok_or_else(|| "conformance attribute missing 'requirement'".to_string())?;
752
753 Ok(ConformanceArgs { spec, requirement })
754}
755
756fn split_args(input: &str) -> Vec<String> {
757 let mut parts = Vec::new();
758 let mut current = String::new();
759 let mut in_string = false;
760 let mut escape = false;
761
762 for ch in input.chars() {
763 if in_string {
764 current.push(ch);
765 if escape {
766 escape = false;
767 continue;
768 }
769 if ch == '\\' {
770 escape = true;
771 } else if ch == '"' {
772 in_string = false;
773 }
774 continue;
775 }
776
777 match ch {
778 '"' => {
779 in_string = true;
780 current.push(ch);
781 }
782 ',' => {
783 parts.push(current);
784 current = String::new();
785 }
786 _ => current.push(ch),
787 }
788 }
789
790 if !current.trim().is_empty() {
791 parts.push(current);
792 }
793
794 parts
795}
796
797fn split_key_value(input: &str) -> Result<(&str, &str), String> {
798 let mut iter = input.splitn(2, '=');
799 let key = iter
800 .next()
801 .map(str::trim)
802 .filter(|s| !s.is_empty())
803 .ok_or_else(|| "conformance attribute expects key = \"value\" pairs".to_string())?;
804 let value = iter
805 .next()
806 .map(str::trim)
807 .filter(|s| !s.is_empty())
808 .ok_or_else(|| format!("conformance attribute missing value for '{key}'"))?;
809 Ok((key, value))
810}
811
812fn parse_string_literal(input: &str) -> Result<String, String> {
813 let trimmed = input.trim();
814 if !trimmed.starts_with('"') || !trimmed.ends_with('"') {
815 return Err(format!(
816 "conformance attribute values must be string literals, got: {trimmed}"
817 ));
818 }
819 let inner = &trimmed[1..trimmed.len() - 1];
820 let mut out = String::new();
821 let mut chars = inner.chars();
822 while let Some(ch) = chars.next() {
823 if ch == '\\' {
824 let next = chars.next().ok_or_else(|| {
825 "conformance attribute contains dangling escape sequence".to_string()
826 })?;
827 match next {
828 '\\' => out.push('\\'),
829 '"' => out.push('"'),
830 'n' => out.push('\n'),
831 'r' => out.push('\r'),
832 't' => out.push('\t'),
833 other => {
834 return Err(format!(
835 "conformance attribute contains unsupported escape: \\{other}"
836 ));
837 }
838 }
839 } else {
840 out.push(ch);
841 }
842 }
843 Ok(out)
844}
845
846fn parse_fn_name(line: &str) -> Option<String> {
847 let trimmed = line.trim_start();
848 if trimmed.starts_with("//")
849 || trimmed.starts_with("/*")
850 || trimmed.starts_with('*')
851 || trimmed.starts_with('#')
852 {
853 return None;
854 }
855
856 let mut saw_fn = false;
857 for token in trimmed.split_whitespace() {
858 if saw_fn {
859 let name = token
860 .chars()
861 .take_while(|ch| ch.is_alphanumeric() || *ch == '_')
862 .collect::<String>();
863 if name.is_empty() {
864 return None;
865 }
866 return Some(name);
867 }
868 if token == "fn" {
869 saw_fn = true;
870 }
871 }
872
873 None
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn test_spec_requirement_new() {
882 let req = SpecRequirement::new("3.2.1", "Region close waits for children");
883 assert_eq!(req.section, "3.2.1");
884 assert_eq!(req.description, "Region close waits for children");
885 assert!(req.category.is_none());
886 assert_eq!(req.priority, 1);
887 }
888
889 #[test]
890 fn test_spec_requirement_with_category() {
891 let req = SpecRequirement::new("3.2.1", "Test")
892 .with_category("regions")
893 .with_priority(5);
894 assert_eq!(req.category, Some("regions".to_string()));
895 assert_eq!(req.priority, 5);
896 }
897
898 #[test]
899 fn test_traceability_entry_new() {
900 let entry = TraceabilityEntry::new("3.2.1", "Requirement", "test_foo", "tests/foo.rs", 42);
901 assert_eq!(entry.spec_section, "3.2.1");
902 assert_eq!(entry.test_name, "test_foo");
903 assert_eq!(entry.test_file, PathBuf::from("tests/foo.rs"));
904 assert_eq!(entry.test_line, 42);
905 }
906
907 #[test]
908 fn test_empty_matrix_coverage() {
909 let mut matrix = TraceabilityMatrix::empty();
910 assert_eq!(matrix.coverage_percentage(), 100.0);
911 }
912
913 #[test]
914 fn test_matrix_with_requirements_no_tests() {
915 let mut matrix = TraceabilityMatrix::new(vec![
916 SpecRequirement::new("3.2.1", "Req 1"),
917 SpecRequirement::new("3.2.2", "Req 2"),
918 ]);
919 assert_eq!(matrix.coverage_percentage(), 0.0);
920 assert_eq!(matrix.missing_sections().len(), 2);
921 }
922
923 #[test]
924 fn test_matrix_partial_coverage() {
925 let mut matrix = TraceabilityMatrix::new(vec![
926 SpecRequirement::new("3.2.1", "Req 1"),
927 SpecRequirement::new("3.2.2", "Req 2"),
928 ]);
929 matrix.add_test_mapping("3.2.1", "test_req1", "tests/test.rs", 10);
930
931 assert_eq!(matrix.coverage_percentage(), 50.0);
932 assert_eq!(matrix.missing_sections(), vec!["3.2.2".to_string()]);
933 }
934
935 #[test]
936 fn test_matrix_full_coverage() {
937 let mut matrix = TraceabilityMatrix::new(vec![
938 SpecRequirement::new("3.2.1", "Req 1"),
939 SpecRequirement::new("3.2.2", "Req 2"),
940 ]);
941 matrix.add_test_mapping("3.2.1", "test_req1", "tests/test.rs", 10);
942 matrix.add_test_mapping("3.2.2", "test_req2", "tests/test.rs", 20);
943
944 assert_eq!(matrix.coverage_percentage(), 100.0);
945 assert!(matrix.missing_sections().is_empty());
946 }
947
948 #[test]
949 fn test_coverage_stats() {
950 let mut matrix = TraceabilityMatrix::new(vec![
951 SpecRequirement::new("3.2.1", "Req 1"),
952 SpecRequirement::new("3.2.2", "Req 2"),
953 SpecRequirement::new("3.2.3", "Req 3"),
954 ]);
955 matrix.add_test_mapping("3.2.1", "test_req1", "tests/test.rs", 10);
956 matrix.add_test_mapping("3.2.1", "test_req1_extra", "tests/test.rs", 15);
957 matrix.add_test_mapping("3.2.2", "test_req2", "tests/test.rs", 20);
958
959 let stats = matrix.coverage_stats();
960 assert_eq!(stats.total_requirements, 3);
961 assert_eq!(stats.covered_requirements, 2);
962 assert_eq!(stats.missing_requirements, 1);
963 assert_eq!(stats.total_tests, 3);
964 assert!((stats.coverage_percentage - 66.666).abs() < 0.1);
965 }
966
967 #[test]
968 fn test_meets_threshold() {
969 let mut matrix = TraceabilityMatrix::new(vec![
970 SpecRequirement::new("3.2.1", "Req 1"),
971 SpecRequirement::new("3.2.2", "Req 2"),
972 ]);
973 matrix.add_test_mapping("3.2.1", "test_req1", "tests/test.rs", 10);
974
975 assert!(matrix.meets_threshold(50.0));
976 assert!(!matrix.meets_threshold(51.0));
977 }
978
979 #[test]
980 fn test_builder() {
981 let matrix = TraceabilityMatrixBuilder::new()
982 .requirement("3.2.1", "Req 1")
983 .requirement("3.2.2", "Req 2")
984 .test("3.2.1", "test_req1", "tests/test.rs", 10)
985 .build();
986
987 assert_eq!(matrix.requirements.len(), 2);
988 assert_eq!(matrix.entries.len(), 1);
989 }
990
991 #[test]
992 fn test_markdown_output() {
993 let mut matrix = TraceabilityMatrixBuilder::new()
994 .requirement("3.2.1", "Region close waits")
995 .requirement("3.2.2", "No orphan tasks")
996 .test("3.2.1", "test_region_close", "tests/region.rs", 42)
997 .build();
998
999 let md = matrix.to_markdown();
1000 assert!(md.contains("# Specification Traceability Matrix"));
1001 assert!(md.contains("3.2.1"));
1002 assert!(md.contains("Region close waits"));
1003 assert!(md.contains("test_region_close"));
1004 assert!(md.contains("MISSING"));
1005 }
1006
1007 #[test]
1008 fn test_json_roundtrip() {
1009 let matrix = TraceabilityMatrixBuilder::new()
1010 .requirement("3.2.1", "Req 1")
1011 .test("3.2.1", "test_req1", "tests/test.rs", 10)
1012 .build();
1013
1014 let json = matrix.to_json().unwrap();
1015 let loaded = TraceabilityMatrix::from_json(&json).unwrap();
1016
1017 assert_eq!(matrix.requirements.len(), loaded.requirements.len());
1018 assert_eq!(matrix.entries.len(), loaded.entries.len());
1019 }
1020
1021 #[test]
1022 fn test_ci_report() {
1023 let mut matrix = TraceabilityMatrixBuilder::new()
1024 .requirement("3.2.1", "Req 1")
1025 .requirement("3.2.2", "Req 2")
1026 .test("3.2.1", "test_req1", "tests/test.rs", 10)
1027 .build();
1028
1029 let report = matrix.ci_report();
1030 assert!(!report.passed);
1031 assert_eq!(report.missing_sections, vec!["3.2.2".to_string()]);
1032
1033 matrix.add_test_mapping("3.2.2", "test_req2", "tests/test.rs", 20);
1034 let report = matrix.ci_report();
1035 assert!(report.passed);
1036 assert!(report.missing_sections.is_empty());
1037 }
1038
1039 #[test]
1040 fn test_scan_conformance_attributes_basic() {
1041 let dir = tempfile::tempdir().unwrap();
1042 let file = dir.path().join("example.rs");
1043 let contents = concat!(
1044 "#[conformance(spec = \"3.2.1\", requirement = \"Region close waits\")]\n",
1045 "#[test]\n",
1046 "fn test_region_close() {}\n"
1047 );
1048 std::fs::write(&file, contents).unwrap();
1049
1050 let scan = scan_conformance_attributes(std::slice::from_ref(&file)).unwrap();
1051 assert!(scan.warnings.is_empty());
1052 assert_eq!(scan.entries.len(), 1);
1053 let entry = &scan.entries[0];
1054 assert_eq!(entry.spec_section, "3.2.1");
1055 assert_eq!(entry.requirement, "Region close waits");
1056 assert_eq!(entry.test_name, "test_region_close");
1057 assert_eq!(entry.test_line, 3);
1058 }
1059
1060 #[test]
1061 fn test_scan_multiple_conformance_attributes() {
1062 let dir = tempfile::tempdir().unwrap();
1063 let file = dir.path().join("example.rs");
1064 let contents = concat!(
1065 "#[conformance(spec = \"3.2.1\", requirement = \"Region close waits\")]\n",
1066 "#[conformance(spec = \"3.2.2\", requirement = \"No orphan tasks\")]\n",
1067 "#[test]\n",
1068 "fn test_region_close() {}\n"
1069 );
1070 std::fs::write(&file, contents).unwrap();
1071
1072 let scan = scan_conformance_attributes(std::slice::from_ref(&file)).unwrap();
1073 assert!(scan.warnings.is_empty());
1074 assert_eq!(scan.entries.len(), 2);
1075 assert!(
1076 scan.entries
1077 .iter()
1078 .any(|entry| entry.spec_section == "3.2.1")
1079 );
1080 assert!(
1081 scan.entries
1082 .iter()
1083 .any(|entry| entry.spec_section == "3.2.2")
1084 );
1085 }
1086}