qa 0.1.1

Requirements traceability for safety-critical Rust software
Documentation
use crate::RequirementType::*;

crate::requirements! {
    pub REQ_MATRIX_GENERATION: Functional {
        description: "Traceability matrix must be auto-generated from registered data",
    }

    pub REQ_GAP_ANALYSIS: Functional {
        description: "Untraced requirements and orphan traces must be detectable",
    }
}

use crate::{REQUIREMENTS, Requirement, TRACES, TestTrace};
use std::collections::BTreeMap;
use std::io;
use std::path::Path;

/// Output format for the traceability matrix.
#[derive(Debug, Clone, Copy)]
pub enum Format {
    Markdown,
    #[cfg(feature = "json")]
    Json,
}

/// Collected traceability data: all requirements and their linked tests.
#[derive(Debug)]
pub struct Matrix {
    requirements: Vec<&'static Requirement>,
    traces: Vec<&'static TestTrace>,
}

impl Matrix {
    /// Collect all registered requirements and test traces.
    pub fn collect() -> Self {
        Self {
            requirements: REQUIREMENTS.iter().collect(),
            traces: TRACES.iter().collect(),
        }
    }

    pub fn requirements(&self) -> &[&'static Requirement] {
        &self.requirements
    }

    pub fn traces(&self) -> &[&'static TestTrace] {
        &self.traces
    }

    /// All traces linked to a given requirement ID.
    pub fn traces_for(&self, requirement_id: &str) -> Vec<&'static TestTrace> {
        self.traces
            .iter()
            .filter(|t| t.requirement_id == requirement_id)
            .copied()
            .collect()
    }

    /// Requirements with no linked tests.
    pub fn untraced(&self) -> Vec<&'static Requirement> {
        self.requirements
            .iter()
            .filter(|req| !self.traces.iter().any(|t| t.requirement_id == req.id))
            .copied()
            .collect()
    }

    /// Traces referencing requirement IDs not found in the registry.
    pub fn orphan_traces(&self) -> Vec<&'static TestTrace> {
        self.traces
            .iter()
            .filter(|t| !self.requirements.iter().any(|r| r.id == t.requirement_id))
            .copied()
            .collect()
    }

    /// Fraction of requirements with at least one linked test.
    pub fn coverage(&self) -> f64 {
        if self.requirements.is_empty() {
            return 1.0;
        }
        let traced = self
            .requirements
            .iter()
            .filter(|req| self.traces.iter().any(|t| t.requirement_id == req.id))
            .count();
        traced as f64 / self.requirements.len() as f64
    }

    /// Panics listing all untraced requirements.
    pub fn assert_complete(&self) {
        let untraced = self.untraced();
        if !untraced.is_empty() {
            let listing: String = untraced
                .iter()
                .map(|r| format!("  - {} ({}:{})", r.id, r.file, r.line))
                .collect::<Vec<_>>()
                .join("\n");
            panic!(
                "{} of {} requirements have no linked tests:\n{}",
                untraced.len(),
                self.requirements.len(),
                listing,
            );
        }
    }

    /// Render the matrix as a string in the given format.
    pub fn render(&self, format: Format) -> String {
        match format {
            Format::Markdown => self.render_markdown(),
            #[cfg(feature = "json")]
            Format::Json => self.render_json(),
        }
    }

    /// Write the matrix to a file.
    pub fn write(&self, path: impl AsRef<Path>, format: Format) -> io::Result<()> {
        std::fs::write(path, self.render(format))
    }

    /// Print the matrix as a terminal table via `tabled`.
    pub fn print(&self) {
        println!("{}", self.render_tabled());
    }

    /// Filter requirements by a metadata key-value pair.
    ///
    /// Returns a new `Matrix` containing only requirements where
    /// `req.get(key) == Some(value)`, along with their traces.
    pub fn filter_by(&self, key: &str, value: &str) -> Matrix {
        let requirements: Vec<_> = self
            .requirements
            .iter()
            .filter(|r| r.get(key) == Some(value))
            .copied()
            .collect();
        let req_ids: Vec<_> = requirements.iter().map(|r| r.id).collect();
        let traces = self
            .traces
            .iter()
            .filter(|t| req_ids.contains(&t.requirement_id))
            .copied()
            .collect();
        Matrix {
            requirements,
            traces,
        }
    }

    /// Group requirements by a metadata key.
    ///
    /// Returns a map from metadata values to sub-matrices.
    /// Requirements missing the key are grouped under `"(none)"`.
    pub fn group_by(&self, key: &str) -> BTreeMap<&'static str, Matrix> {
        let mut groups: BTreeMap<&'static str, Vec<&'static Requirement>> = BTreeMap::new();
        for req in &self.requirements {
            let group_key = req.get(key).unwrap_or("(none)");
            groups.entry(group_key).or_default().push(req);
        }
        groups
            .into_iter()
            .map(|(group_key, requirements)| {
                let req_ids: Vec<_> = requirements.iter().map(|r| r.id).collect();
                let traces = self
                    .traces
                    .iter()
                    .filter(|t| req_ids.contains(&t.requirement_id))
                    .copied()
                    .collect();
                (
                    group_key,
                    Matrix {
                        requirements,
                        traces,
                    },
                )
            })
            .collect()
    }

    fn render_markdown(&self) -> String {
        let mut out = String::from("# Traceability Matrix\n\n");

        let untraced_count = self.untraced().len();
        let total = self.requirements.len();
        out.push_str(&format!(
            "**Coverage**: {}/{} requirements traced ({:.0}%)\n\n",
            total - untraced_count,
            total,
            self.coverage() * 100.0,
        ));

        out.push_str("| Requirement | Type | Description | Tests | Source |\n");
        out.push_str("|---|---|---|---|---|\n");

        for req in &self.requirements {
            let tests = self.traces_for(req.id);
            let test_list = if tests.is_empty() {
                "**NONE**".to_string()
            } else {
                tests
                    .iter()
                    .map(|t| format!("`{}::{}`", t.test_module, t.test_name))
                    .collect::<Vec<_>>()
                    .join(", ")
            };
            let source = req.get("source").unwrap_or("-");
            out.push_str(&format!(
                "| `{}` | {} | {} | {} | {} |\n",
                req.id, req.kind, req.description, test_list, source,
            ));
        }

        let orphans = self.orphan_traces();
        if !orphans.is_empty() {
            out.push_str("\n## Orphan Traces\n\n");
            out.push_str("Tests referencing unknown requirement IDs:\n\n");
            for t in &orphans {
                out.push_str(&format!(
                    "- `{}::{}` traces `{}` ({}:{})\n",
                    t.test_module, t.test_name, t.requirement_id, t.file, t.line,
                ));
            }
        }

        out
    }

    fn render_tabled(&self) -> String {
        use tabled::{Table, Tabled};

        #[derive(Tabled)]
        struct Row {
            #[tabled(rename = "Requirement")]
            id: &'static str,
            #[tabled(rename = "Type")]
            kind: String,
            #[tabled(rename = "Description")]
            description: &'static str,
            #[tabled(rename = "Tests")]
            tests: String,
            #[tabled(rename = "Source")]
            source: &'static str,
        }

        let rows: Vec<Row> = self
            .requirements
            .iter()
            .map(|req| {
                let tests = self.traces_for(req.id);
                let test_list = if tests.is_empty() {
                    "NONE".to_string()
                } else {
                    tests
                        .iter()
                        .map(|t| format!("{}::{}", t.test_module, t.test_name))
                        .collect::<Vec<_>>()
                        .join("\n")
                };
                Row {
                    id: req.id,
                    kind: req.kind.to_string(),
                    description: req.description,
                    tests: test_list,
                    source: req.get("source").unwrap_or("-"),
                }
            })
            .collect();

        let mut table_str = Table::new(&rows).to_string();

        let orphans = self.orphan_traces();
        if !orphans.is_empty() {
            table_str.push_str("\n\nOrphan Traces:\n");
            for t in &orphans {
                table_str.push_str(&format!(
                    "  {}::{} -> {} ({}:{})\n",
                    t.test_module, t.test_name, t.requirement_id, t.file, t.line,
                ));
            }
        }

        table_str
    }

    #[cfg(feature = "json")]
    fn render_json(&self) -> String {
        use serde::Serialize;

        #[derive(Serialize)]
        struct JsonMatrix<'a> {
            coverage: f64,
            requirements: Vec<JsonRequirement<'a>>,
            orphan_traces: Vec<JsonTrace<'a>>,
        }

        #[derive(Serialize)]
        struct JsonRequirement<'a> {
            id: &'a str,
            kind: String,
            description: &'a str,
            file: &'a str,
            line: u32,
            metadata: &'a [(&'a str, &'a str)],
            tests: Vec<JsonTrace<'a>>,
        }

        #[derive(Serialize)]
        struct JsonTrace<'a> {
            test_name: &'a str,
            test_module: &'a str,
            file: &'a str,
            line: u32,
        }

        let matrix = JsonMatrix {
            coverage: self.coverage(),
            requirements: self
                .requirements
                .iter()
                .map(|req| {
                    let tests = self.traces_for(req.id);
                    JsonRequirement {
                        id: req.id,
                        kind: req.kind.to_string(),
                        description: req.description,
                        file: req.file,
                        line: req.line,
                        metadata: req.metadata,
                        tests: tests
                            .iter()
                            .map(|t| JsonTrace {
                                test_name: t.test_name,
                                test_module: t.test_module,
                                file: t.file,
                                line: t.line,
                            })
                            .collect(),
                    }
                })
                .collect(),
            orphan_traces: self
                .orphan_traces()
                .iter()
                .map(|t| JsonTrace {
                    test_name: t.test_name,
                    test_module: t.test_module,
                    file: t.file,
                    line: t.line,
                })
                .collect(),
        };

        // serde_json serialization of this structure cannot fail:
        // no map keys that aren't strings, coverage is always in [0.0, 1.0]
        serde_json::to_string_pretty(&matrix).expect("traceability matrix JSON serialization")
    }
}