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;
#[derive(Debug, Clone, Copy)]
pub enum Format {
Markdown,
#[cfg(feature = "json")]
Json,
}
#[derive(Debug)]
pub struct Matrix {
requirements: Vec<&'static Requirement>,
traces: Vec<&'static TestTrace>,
}
impl Matrix {
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
}
pub fn traces_for(&self, requirement_id: &str) -> Vec<&'static TestTrace> {
self.traces
.iter()
.filter(|t| t.requirement_id == requirement_id)
.copied()
.collect()
}
pub fn untraced(&self) -> Vec<&'static Requirement> {
self.requirements
.iter()
.filter(|req| !self.traces.iter().any(|t| t.requirement_id == req.id))
.copied()
.collect()
}
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()
}
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
}
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,
);
}
}
pub fn render(&self, format: Format) -> String {
match format {
Format::Markdown => self.render_markdown(),
#[cfg(feature = "json")]
Format::Json => self.render_json(),
}
}
pub fn write(&self, path: impl AsRef<Path>, format: Format) -> io::Result<()> {
std::fs::write(path, self.render(format))
}
pub fn print(&self) {
println!("{}", self.render_tabled());
}
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,
}
}
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::to_string_pretty(&matrix).expect("traceability matrix JSON serialization")
}
}