Skip to main content

asupersync_conformance/
traceability.rs

1//! Spec-to-test traceability matrix generation.
2//!
3//! This module provides tools for tracking which specification requirements
4//! are covered by which tests, generating coverage reports, and identifying
5//! gaps in test coverage.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use conformance::traceability::{TraceabilityMatrix, TraceabilityEntry, SpecRequirement};
11//!
12//! // Define specification requirements
13//! let requirements = vec![
14//!     SpecRequirement::new("3.2.1", "Region close waits for all children"),
15//!     SpecRequirement::new("3.2.2", "Orphan tasks are prevented"),
16//! ];
17//!
18//! // Create matrix with test mappings
19//! let mut matrix = TraceabilityMatrix::new(requirements);
20//! matrix.add_test_mapping("3.2.1", "test_region_close_waits", "tests/region.rs", 42);
21//!
22//! // Generate reports
23//! println!("Coverage: {:.1}%", matrix.coverage_percentage());
24//! println!("{}", matrix.to_markdown());
25//! ```
26
27use 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/// A specification requirement that should be covered by tests.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct SpecRequirement {
37    /// Section identifier (e.g., "3.2.1").
38    pub section: String,
39    /// Human-readable requirement description.
40    pub description: String,
41    /// Optional category for grouping.
42    pub category: Option<String>,
43    /// Priority level (higher = more important).
44    pub priority: u8,
45}
46
47impl SpecRequirement {
48    /// Create a new specification requirement.
49    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    /// Set the category.
59    pub fn with_category(mut self, category: impl Into<String>) -> Self {
60        self.category = Some(category.into());
61        self
62    }
63
64    /// Set the priority.
65    pub fn with_priority(mut self, priority: u8) -> Self {
66        self.priority = priority;
67        self
68    }
69}
70
71/// An entry linking a test to a specification requirement.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct TraceabilityEntry {
74    /// The specification section this test covers.
75    pub spec_section: String,
76    /// The requirement description.
77    pub requirement: String,
78    /// Name of the test function.
79    pub test_name: String,
80    /// Path to the test file.
81    pub test_file: PathBuf,
82    /// Line number in the test file.
83    pub test_line: u32,
84    /// Optional tags for filtering.
85    pub tags: Vec<String>,
86}
87
88impl TraceabilityEntry {
89    /// Create a new traceability entry.
90    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    /// Add tags to this entry.
108    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
109        self.tags = tags;
110        self
111    }
112}
113
114/// Warning emitted while scanning for traceability metadata.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ScanWarning {
117    /// File that triggered the warning.
118    pub file: PathBuf,
119    /// Line number in the file (1-based).
120    pub line: u32,
121    /// Warning message.
122    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/// Result of scanning source files for `#[conformance]` attributes.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct TraceabilityScan {
138    /// Discovered traceability entries.
139    pub entries: Vec<TraceabilityEntry>,
140    /// Non-fatal warnings encountered during scanning.
141    pub warnings: Vec<ScanWarning>,
142}
143
144/// Errors that prevent traceability scanning from completing.
145#[derive(Debug)]
146pub struct TraceabilityScanError {
147    /// Path that triggered the error.
148    pub path: PathBuf,
149    /// Underlying I/O error.
150    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
166/// Scan a list of paths for `#[conformance]` attributes.
167///
168/// Directories are walked recursively and `.rs` files are scanned.
169pub 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
188/// Derive requirements from traceability entries.
189pub 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/// A matrix tracking specification requirements and their test coverage.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct TraceabilityMatrix {
205    /// All specification requirements.
206    pub requirements: Vec<SpecRequirement>,
207    /// Entries mapping tests to requirements.
208    pub entries: Vec<TraceabilityEntry>,
209    /// Cached coverage data (section -> test names).
210    #[serde(skip)]
211    coverage_cache: HashMap<String, Vec<String>>,
212}
213
214impl TraceabilityMatrix {
215    /// Create a new traceability matrix with the given requirements.
216    pub fn new(requirements: Vec<SpecRequirement>) -> Self {
217        Self {
218            requirements,
219            entries: Vec::new(),
220            coverage_cache: HashMap::new(),
221        }
222    }
223
224    /// Create a traceability matrix from requirements and entries.
225    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    /// Create an empty matrix.
237    pub fn empty() -> Self {
238        Self::new(Vec::new())
239    }
240
241    /// Add a specification requirement.
242    pub fn add_requirement(&mut self, requirement: SpecRequirement) {
243        self.requirements.push(requirement);
244        self.invalidate_cache();
245    }
246
247    /// Add a test mapping.
248    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    /// Add a complete traceability entry.
274    pub fn add_entry(&mut self, entry: TraceabilityEntry) {
275        self.entries.push(entry);
276        self.invalidate_cache();
277    }
278
279    /// Build the coverage cache.
280    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    /// Invalidate the coverage cache.
291    fn invalidate_cache(&mut self) {
292        self.coverage_cache.clear();
293    }
294
295    /// Ensure the cache is populated.
296    fn ensure_cache(&mut self) {
297        if self.coverage_cache.is_empty() && !self.entries.is_empty() {
298            self.build_cache();
299        }
300    }
301
302    /// Get the sections that are covered by at least one test.
303    pub fn covered_sections(&mut self) -> HashSet<String> {
304        self.ensure_cache();
305        self.coverage_cache.keys().cloned().collect()
306    }
307
308    /// Get the sections that have no test coverage.
309    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    /// Get tests covering a specific section.
319    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    /// Calculate coverage percentage.
327    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    /// Get coverage statistics.
336    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    /// Generate a markdown report.
349    pub fn to_markdown(&mut self) -> String {
350        self.ensure_cache();
351        let mut output = String::new();
352
353        // Header
354        output.push_str("# Specification Traceability Matrix\n\n");
355
356        // Summary
357        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        // Coverage Matrix
378        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        // Missing sections
399        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        // Test Details
413        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    /// Generate a JSON report.
431    pub fn to_json(&self) -> Result<String, serde_json::Error> {
432        serde_json::to_string_pretty(self)
433    }
434
435    /// Load from JSON.
436    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
437        serde_json::from_str(json)
438    }
439
440    /// Check if coverage meets a threshold.
441    pub fn meets_threshold(&mut self, threshold_percent: f64) -> bool {
442        self.coverage_percentage() >= threshold_percent
443    }
444
445    /// Get a coverage report suitable for CI.
446    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/// Coverage statistics.
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct CoverageStats {
468    /// Total number of requirements.
469    pub total_requirements: usize,
470    /// Number of requirements with at least one test.
471    pub covered_requirements: usize,
472    /// Number of requirements without any test.
473    pub missing_requirements: usize,
474    /// Total number of test entries.
475    pub total_tests: usize,
476    /// Coverage percentage (0-100).
477    pub coverage_percentage: f64,
478}
479
480/// CI-friendly report.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct CiReport {
483    /// Whether all requirements are covered.
484    pub passed: bool,
485    /// Coverage percentage.
486    pub coverage_percentage: f64,
487    /// Total requirements.
488    pub total_requirements: usize,
489    /// Covered requirements.
490    pub covered_requirements: usize,
491    /// List of missing sections.
492    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/// Builder for creating a TraceabilityMatrix from test metadata.
514#[derive(Debug, Default)]
515pub struct TraceabilityMatrixBuilder {
516    requirements: Vec<SpecRequirement>,
517    entries: Vec<TraceabilityEntry>,
518}
519
520impl TraceabilityMatrixBuilder {
521    /// Create a new builder.
522    pub fn new() -> Self {
523        Self::default()
524    }
525
526    /// Add a requirement.
527    pub fn requirement(mut self, section: &str, description: &str) -> Self {
528        self.requirements
529            .push(SpecRequirement::new(section, description));
530        self
531    }
532
533    /// Add a requirement with category.
534    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    /// Add a test mapping.
546    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    /// Build the matrix.
571    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 for defining traceability entries inline.
579///
580/// # Example
581///
582/// ```ignore
583/// let entries = trace_entries![
584///     ("3.2.1", "test_region_close", "tests/region.rs", 42),
585///     ("3.2.2", "test_no_orphans", "tests/region.rs", 100),
586/// ];
587/// ```
588#[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                    "", // Requirement filled in by matrix
596                    $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}