use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::{Component, Path};
use super::{Evidence, Test, Trace, order, places};
use crate::document::Document;
use crate::hash::DocumentHashes;
use crate::identity::Key;
use crate::lint::Severity;
use crate::wire::wire_names;
use crate::yaml::Position;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TraceRule {
Tr001,
Tr002,
Tr003,
Tr004,
Tr005,
}
wire_names!(TraceRule {
Tr001 => "TR-001",
Tr002 => "TR-002",
Tr003 => "TR-003",
Tr004 => "TR-004",
Tr005 => "TR-005",
});
impl TraceRule {
#[must_use]
pub const fn severity(self) -> Severity {
match self {
Self::Tr001 => Severity::Info,
Self::Tr002 | Self::Tr003 | Self::Tr004 | Self::Tr005 => Severity::Warning,
}
}
#[must_use]
pub const fn asks_review(self) -> bool {
matches!(self, Self::Tr002 | Self::Tr003)
}
#[must_use]
pub const fn refuses_accept(self) -> bool {
matches!(self, Self::Tr004 | Self::Tr005)
}
}
impl fmt::Display for TraceRule {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceFinding {
pub rule: TraceRule,
pub node: Key,
pub test: Option<Test>,
pub link: Option<Position>,
pub message: String,
}
pub trait TestFiles {
fn read(&self, path: &str) -> TestFile;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestFile {
Text(String),
Missing,
NotText,
Unreadable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reconciled {
pub findings: Vec<TraceFinding>,
pub in_scope: usize,
pub linked: usize,
}
impl TraceFinding {
fn new(rule: TraceRule, node: Key, link: Option<Position>, message: impl Into<String>) -> Self {
Self {
rule,
node,
test: None,
link,
message: message.into(),
}
}
#[must_use]
pub const fn severity(&self) -> Severity {
self.rule.severity()
}
}
pub fn reconcile(
document: &Document,
hashes: &DocumentHashes,
trace: &Trace,
files: &impl TestFiles,
) -> Reconciled {
let places = places(document);
let mut read = HashMap::new();
let mut findings = Vec::new();
let mut linked = HashSet::new();
for link in &trace.links {
let finding =
|rule, message: &str| TraceFinding::new(rule, link.node, link.position, message);
let Some(place) = places.get(&link.node).filter(|place| place.in_scope) else {
findings.push(finding(
TraceRule::Tr005,
if places.contains_key(&link.node) {
"the node is not in scope: only acceptance criteria and proved Constraints \
and Quality Attributes are"
} else {
"no node of the tree carries this key"
},
));
continue;
};
linked.insert(link.node);
match link.reviewed {
None => findings.push(finding(TraceRule::Tr002, "the link has not been reviewed")),
Some(reviewed) if reviewed != hashes.nodes[&place.id] => findings.push(finding(
TraceRule::Tr003,
"the node changed after the link was reviewed",
)),
Some(_) => {}
}
let tests = match &link.evidence {
Evidence::Tests(tests) => tests.as_slice(),
Evidence::Unverified(_) => &[],
};
for test in tests {
if let Some(wrong) = missing(test, &mut read, files) {
findings.push(TraceFinding {
test: Some(test.clone()),
..finding(TraceRule::Tr004, &wrong)
});
}
}
}
let mut in_scope = 0;
for (key, _) in places.iter().filter(|(_, place)| place.in_scope) {
in_scope += 1;
if !linked.contains(key) {
let message = "no link names this node";
findings.push(TraceFinding::new(TraceRule::Tr001, *key, None, message));
}
}
findings.sort_by_key(|finding| (order(&places, finding.node), finding.rule));
Reconciled {
findings,
in_scope,
linked: linked.len(),
}
}
fn missing<'a>(
test: &'a Test,
read: &mut HashMap<&'a str, TestFile>,
files: &impl TestFiles,
) -> Option<String> {
let file = test.file.as_str();
if !inside(file) {
return Some(format!(
"the file `{file}` lies outside the directory that holds `spec/`"
));
}
match read.entry(file).or_insert_with(|| files.read(file)) {
TestFile::Text(held) if occurs_whole(held, &test.name) => None,
TestFile::Text(_) => Some(format!(
"`{}` does not occur in `{file}` as a whole word",
test.name
)),
TestFile::Missing => Some(format!("the file `{file}` does not exist")),
TestFile::NotText => Some(format!("the file `{file}` is not UTF-8 text")),
TestFile::Unreadable(why) => Some(format!("the file `{file}` cannot be read: {why}")),
}
}
fn inside(path: &str) -> bool {
Path::new(path)
.components()
.all(|part| matches!(part, Component::Normal(_) | Component::CurDir))
}
fn occurs_whole(text: &str, name: &str) -> bool {
let word = |character: char| character.is_alphanumeric() || character == '_';
text.match_indices(name).any(|(at, _)| {
let before = text[..at].chars().next_back();
let after = text[at + name.len()..].chars().next();
!before.is_some_and(word) && !after.is_some_and(word)
})
}