use super::finding::{Finding, Severity};
use crate::corpus::Corpus;
use crate::workspace::owners::{PathMatch, matcher};
use crate::workspace::vcs;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
pub const SCHEMA: &str = "spec.baseline.v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
pub schema: String,
pub measured_on: String,
pub counts: BTreeMap<String, usize>,
}
#[derive(Debug, Default)]
pub struct Held {
pub findings: Vec<Finding>,
pub known: usize,
pub lowerable: Vec<(String, usize, usize)>,
}
fn key(f: &Finding) -> String {
format!("{}\t{}", f.rule, f.path)
}
fn counted(f: &Finding, strict: &[PathMatch]) -> bool {
f.severity == Severity::Error && !strict.iter().any(|m| m.is_match(&f.path))
}
pub fn matchers(patterns: &[String]) -> Vec<PathMatch> {
patterns.iter().filter_map(|p| matcher(p)).collect()
}
pub fn load_file(root: &Path, rel: &str) -> Result<Option<Baseline>> {
let path = root.join(rel);
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&path).with_context(|| format!("cannot read {rel}"))?;
let b: Baseline = serde_json::from_str(&text).with_context(|| format!("{rel} is not a {SCHEMA} file"))?;
if b.schema != SCHEMA {
bail!("{rel} has schema {}, expected {SCHEMA}", b.schema);
}
match vcs::is_ancestor_of_head(root, &b.measured_on) {
Some(true) => Ok(Some(b)),
Some(false) => {
bail!("{rel} was measured on {}, which is not in HEAD's history; measure it again", b.measured_on)
}
None => bail!("{rel} names {}, which git does not know", b.measured_on),
}
}
pub fn hold_with(b: &Baseline, findings: Vec<Finding>, strict: &[PathMatch]) -> Held {
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
let mut held = Held::default();
for f in findings {
if !counted(&f, strict) {
held.findings.push(f);
continue;
}
let k = key(&f);
let n = seen.entry(k.clone()).or_default();
*n += 1;
if *n <= b.counts.get(&k).copied().unwrap_or(0) {
held.known += 1;
} else {
held.findings.push(f);
}
}
for (k, &allowed) in &b.counts {
let now = seen.get(k).copied().unwrap_or(0);
if now < allowed {
held.lowerable.push((k.clone(), allowed, now));
}
}
held
}
pub fn measure_with(
root: &Path,
findings: &[Finding],
strict: &[PathMatch],
measured: impl Fn(&str) -> bool,
) -> Result<Baseline> {
let head = vcs::head(root).ok_or_else(|| anyhow!("a baseline needs a git repository with a commit"))?;
let changed = vcs::changed_since(root, "HEAD").unwrap_or_default();
let dirty: Vec<&str> = changed.iter().map(String::as_str).filter(|p| measured(p)).take(10).collect();
if !dirty.is_empty() {
bail!("commit these files first, the baseline names HEAD: {}", dirty.join(", "));
}
let mut counts = BTreeMap::new();
for f in findings.iter().filter(|f| counted(f, strict)) {
*counts.entry(key(f)).or_default() += 1;
}
Ok(Baseline { schema: SCHEMA.into(), measured_on: head, counts })
}
pub fn write_file(root: &Path, rel: &str, b: &Baseline) -> Result<()> {
let path = root.join(rel);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, serde_json::to_string_pretty(b)? + "\n")?;
Ok(())
}
pub fn load(c: &Corpus) -> Result<Option<Baseline>> {
match &c.cfg.raw.check.baseline {
Some(rel) => load_file(&c.cfg.root, rel),
None => Ok(None),
}
}
pub fn hold(c: &Corpus, b: &Baseline, findings: Vec<Finding>) -> Held {
hold_with(b, findings, &matchers(&c.cfg.raw.check.strict))
}
pub fn measure(c: &Corpus, findings: &[Finding]) -> Result<Baseline> {
let strict = matchers(&c.cfg.raw.check.strict);
measure_with(&c.cfg.root, findings, &strict, |p| c.docs.iter().any(|d| d.path == p))
}
pub fn write(c: &Corpus, b: &Baseline) -> Result<String> {
let rel = c.cfg.raw.check.baseline.clone().ok_or_else(|| anyhow!("set [check] baseline in spec.toml first"))?;
write_file(&c.cfg.root, &rel, b)?;
Ok(rel)
}