use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{DateTime, Finding, FindingId, Report, RuleId, Severity};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecisionStatus {
Open,
Accepted,
Rejected,
}
impl DecisionStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Accepted => "accepted",
Self::Rejected => "rejected",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DecisionBasis {
pub rule_id: RuleId,
pub message: String,
pub severity: Severity,
pub evidence: usize,
pub inexact_evidence: usize,
}
impl DecisionBasis {
#[must_use]
pub fn of(finding: &Finding) -> Self {
Self {
rule_id: finding.rule_id.clone(),
message: finding.message.clone(),
severity: finding.severity.clone(),
evidence: finding.evidence.len(),
inexact_evidence: finding.evidence.iter().filter(|e| !e.exact).count(),
}
}
#[must_use]
pub fn changes(&self, finding: &Finding) -> Vec<DecisionChange> {
let now = Self::of(finding);
let mut changes = Vec::new();
let mut differ = |facet, decided: String, now: String| {
if decided != now {
changes.push(DecisionChange {
facet,
decided,
now,
});
}
};
differ(
ChangedFacet::Rule,
self.rule_id.to_string(),
now.rule_id.to_string(),
);
differ(ChangedFacet::Message, self.message.clone(), now.message);
differ(
ChangedFacet::Severity,
severity(&self.severity).to_owned(),
severity(&now.severity).to_owned(),
);
differ(
ChangedFacet::Evidence,
self.evidence.to_string(),
now.evidence.to_string(),
);
differ(
ChangedFacet::InexactEvidence,
self.inexact_evidence.to_string(),
now.inexact_evidence.to_string(),
);
changes
}
}
fn severity(severity: &Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Decision {
pub finding: FindingId,
pub status: DecisionStatus,
pub author: String,
pub date: DateTime,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub comment: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub basis: Option<DecisionBasis>,
}
impl Decision {
pub fn new(
finding: FindingId,
status: DecisionStatus,
author: impl Into<String>,
date: DateTime,
) -> Result<Self, DecisionError> {
let author = author.into();
if author.trim().is_empty() {
return Err(DecisionError::BlankAuthor(finding));
}
Ok(Self {
finding,
status,
author,
date,
comment: String::new(),
basis: None,
})
}
#[must_use]
pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
self.comment = comment.into();
self
}
#[must_use]
pub fn with_basis(mut self, finding: &Finding) -> Self {
self.basis = Some(DecisionBasis::of(finding));
self
}
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum DecisionError {
#[error("two decisions name finding {0}")]
Duplicate(FindingId),
#[error("the decision about finding {0} has a blank author")]
BlankAuthor(FindingId),
#[error("finding of rule {0} has no identity; identify the report's findings first")]
Unidentified(RuleId),
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "DecisionsWire", into = "DecisionsWire")]
pub struct Decisions {
decisions: Vec<Decision>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct DecisionsWire {
decisions: Vec<Decision>,
}
impl TryFrom<DecisionsWire> for Decisions {
type Error = DecisionError;
fn try_from(wire: DecisionsWire) -> Result<Self, DecisionError> {
Self::new(wire.decisions)
}
}
impl From<Decisions> for DecisionsWire {
fn from(decisions: Decisions) -> Self {
Self {
decisions: decisions.decisions,
}
}
}
impl Decisions {
pub fn new(decisions: impl IntoIterator<Item = Decision>) -> Result<Self, DecisionError> {
let mut decisions: Vec<Decision> = decisions.into_iter().collect();
decisions.sort_by_key(|decision| decision.finding);
for pair in decisions.windows(2) {
if pair[0].finding == pair[1].finding {
return Err(DecisionError::Duplicate(pair[0].finding));
}
}
if let Some(blank) = decisions.iter().find(|d| d.author.trim().is_empty()) {
return Err(DecisionError::BlankAuthor(blank.finding));
}
Ok(Self { decisions })
}
#[must_use]
pub fn decisions(&self) -> &[Decision] {
&self.decisions
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.decisions.is_empty()
}
#[must_use]
pub fn get(&self, finding: FindingId) -> Option<&Decision> {
self.decisions
.binary_search_by_key(&finding, |decision| decision.finding)
.ok()
.map(|at| &self.decisions[at])
}
pub fn record(&mut self, decision: Decision) -> Result<Option<Decision>, DecisionError> {
if decision.author.trim().is_empty() {
return Err(DecisionError::BlankAuthor(decision.finding));
}
match self
.decisions
.binary_search_by_key(&decision.finding, |existing| existing.finding)
{
Ok(at) => Ok(Some(std::mem::replace(&mut self.decisions[at], decision))),
Err(at) => {
self.decisions.insert(at, decision);
Ok(None)
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceCheck {
Unchanged,
Changed,
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangedFacet {
Rule,
Message,
Severity,
Evidence,
InexactEvidence,
}
impl ChangedFacet {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Rule => "rule",
Self::Message => "message",
Self::Severity => "severity",
Self::Evidence => "evidence",
Self::InexactEvidence => "inexact_evidence",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DecisionChange {
pub facet: ChangedFacet,
pub decided: String,
pub now: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FindingDecision {
pub status: DecisionStatus,
pub author: String,
pub date: DateTime,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub comment: String,
pub evidence: EvidenceCheck,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<DecisionChange>,
}
impl FindingDecision {
#[must_use]
pub fn carried(decision: &Decision, finding: &Finding) -> Self {
let (evidence, changes) = match &decision.basis {
None => (EvidenceCheck::Unknown, Vec::new()),
Some(basis) => {
let changes = basis.changes(finding);
if changes.is_empty() {
(EvidenceCheck::Unchanged, changes)
} else {
(EvidenceCheck::Changed, changes)
}
}
};
Self {
status: decision.status,
author: decision.author.clone(),
date: decision.date,
comment: decision.comment.clone(),
evidence,
changes,
}
}
}
impl Report {
pub fn apply_decisions(&mut self, decisions: &Decisions) -> Result<(), DecisionError> {
if !decisions.is_empty()
&& let Some(unidentified) = self.findings.iter().find(|f| f.id.is_none())
{
return Err(DecisionError::Unidentified(unidentified.rule_id.clone()));
}
let mut matched = vec![false; decisions.decisions().len()];
for finding in &mut self.findings {
let at = finding.id.and_then(|id| {
decisions
.decisions()
.binary_search_by_key(&id, |decision| decision.finding)
.ok()
});
finding.decision = at.map(|at| {
matched[at] = true;
FindingDecision::carried(&decisions.decisions()[at], finding)
});
}
self.stale_decisions = decisions
.decisions()
.iter()
.zip(matched)
.filter(|(_, matched)| !matched)
.map(|(decision, _)| decision.clone())
.collect();
Ok(())
}
}