use serde_json::Value;
use std::cmp::Ordering;
use crate::{Finding, FindingKey, Report, Violation, pretty_json};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ViolationId {
pub target: String,
pub rule: String,
pub finding: String,
pub(crate) finding_key: Option<FindingKey>,
}
impl ViolationId {
pub fn new(target: impl Into<String>, rule: impl Into<String>, finding: Finding) -> Self {
Self {
target: target.into(),
rule: rule.into(),
finding: finding.text,
finding_key: Some(finding.key),
}
}
pub(crate) fn legacy(target: String, rule: String, finding: String) -> Self {
Self {
target,
rule,
finding,
finding_key: None,
}
}
pub fn finding_key(&self) -> Option<&FindingKey> {
self.finding_key.as_ref()
}
fn identity_cmp(&self, other: &Self) -> Ordering {
match (&self.finding_key, &other.finding_key) {
(Some(left), Some(right)) => {
(&self.target, &self.rule, left).cmp(&(&other.target, &other.rule, right))
}
(None, None) => (&self.target, &self.rule, &self.finding).cmp(&(
&other.target,
&other.rule,
&other.finding,
)),
(None, Some(_)) => Ordering::Less,
(Some(_), None) => Ordering::Greater,
}
}
}
impl PartialEq for ViolationId {
fn eq(&self, other: &Self) -> bool {
self.identity_cmp(other) == Ordering::Equal
}
}
impl Eq for ViolationId {}
impl PartialOrd for ViolationId {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ViolationId {
fn cmp(&self, other: &Self) -> Ordering {
self.identity_cmp(other)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BaselineEntry {
pub id: ViolationId,
pub owner: Option<String>,
pub tracker: Option<String>,
}
fn sort_dedup_by_id(entries: &mut Vec<BaselineEntry>) {
entries.sort_by(|a, b| a.id.cmp(&b.id));
entries.dedup_by(|a, b| a.id == b.id);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum BaselineFormat {
V1,
#[default]
V2,
}
#[derive(Debug, Default)]
pub struct Baseline {
entries: Vec<BaselineEntry>,
format: BaselineFormat,
}
impl Baseline {
pub fn of(report: &Report) -> Self {
Self::of_preserving(report, &Baseline::default())
}
pub fn of_preserving(report: &Report, previous: &Baseline) -> Self {
let mut entries: Vec<BaselineEntry> = report
.violations
.iter()
.map(|violation| {
let id = violation.id();
let prior = previous
.entries
.iter()
.find(|entry| baseline_id_matches(&entry.id, &id));
BaselineEntry {
owner: prior.and_then(|entry| entry.owner.clone()),
tracker: prior.and_then(|entry| entry.tracker.clone()),
id,
}
})
.collect();
sort_dedup_by_id(&mut entries);
Baseline {
entries,
format: BaselineFormat::V2,
}
}
pub fn entries(&self) -> impl Iterator<Item = &BaselineEntry> {
self.entries.iter()
}
pub fn contains(&self, violation: &Violation) -> bool {
let id = violation.id();
self.entries
.iter()
.any(|entry| baseline_id_matches(&entry.id, &id))
}
pub fn stale(&self, report: &Report) -> Vec<&ViolationId> {
let current: Vec<ViolationId> = report.violations.iter().map(Violation::id).collect();
self.entries
.iter()
.filter(|entry| !current.iter().any(|id| baseline_id_matches(&entry.id, id)))
.map(|entry| &entry.id)
.collect()
}
pub fn to_json(&self) -> String {
let violations: Vec<Value> = self
.entries
.iter()
.map(|entry| {
let mut object = serde_json::json!({
"target": entry.id.target,
"rule": entry.id.rule,
"finding": entry.id.finding,
});
if self.format == BaselineFormat::V2 {
object["finding_key"] =
entry.id.finding_key().map(FindingKey::to_json).expect(
"a version-2 baseline entry must carry a structured finding key",
);
}
if let Some(owner) = &entry.owner {
object["owner"] = serde_json::json!(owner);
}
if let Some(tracker) = &entry.tracker {
object["tracker"] = serde_json::json!(tracker);
}
object
})
.collect();
let version = match self.format {
BaselineFormat::V1 => 1,
BaselineFormat::V2 => 2,
};
let doc = serde_json::json!({ "version": version, "violations": violations });
pretty_json(&doc)
}
pub fn from_json(text: &str) -> Result<Self, String> {
let doc: Value = serde_json::from_str(text).map_err(|err| err.to_string())?;
let format = match doc["version"].as_i64() {
Some(1) => BaselineFormat::V1,
Some(2) => BaselineFormat::V2,
Some(other) => return Err(format!("unsupported baseline version {other}")),
None => return Err("baseline is missing a numeric `version`".to_string()),
};
let array = doc["violations"]
.as_array()
.ok_or_else(|| "baseline `violations` must be an array".to_string())?;
let mut entries = Vec::with_capacity(array.len());
for (index, item) in array.iter().enumerate() {
let field = |name: &str| -> Result<String, String> {
item[name]
.as_str()
.map(str::to_string)
.ok_or_else(|| format!("baseline entry is missing string `{name}`"))
};
let optional = |name: &str| -> Result<Option<String>, String> {
match item.get(name) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(_) => Err(format!(
"baseline entry {index} `{name}` must be a string or null"
)),
}
};
let target = field("target")?;
let rule = field("rule")?;
let finding = field("finding")?;
let id = match format {
BaselineFormat::V1 => ViolationId::legacy(target, rule, finding),
BaselineFormat::V2 => ViolationId {
target,
rule,
finding,
finding_key: Some(FindingKey::from_json(&item["finding_key"])?),
},
};
entries.push(BaselineEntry {
id,
owner: optional("owner")?,
tracker: optional("tracker")?,
});
}
sort_dedup_by_id(&mut entries);
Ok(Baseline { entries, format })
}
}
fn baseline_id_matches(baseline: &ViolationId, current: &ViolationId) -> bool {
match baseline.finding_key() {
Some(_) => baseline == current,
None => {
baseline.target == current.target
&& baseline.rule == current.rule
&& baseline.finding == current.finding
}
}
}
pub fn apply_baseline(report: &mut Report, baseline: &Baseline) {
for violation in &mut report.violations {
if baseline.contains(violation) {
violation.baselined = true;
}
}
}