fluidattacks-core 0.15.0

Fluid Attacks Core Library
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::Deserialize;

#[derive(Deserialize)]
struct Report {
    data: Vec<ReportData>,
}

#[derive(Deserialize)]
struct ReportData {
    files: Vec<FileCoverage>,
}

#[derive(Deserialize)]
struct FileCoverage {
    filename: String,
    summary: FileSummary,
}

#[derive(Deserialize)]
struct FileSummary {
    lines: LinesSummary,
}

#[derive(Deserialize)]
struct LinesSummary {
    count: u32,
    covered: u32,
}

struct ModuleStats {
    total: u32,
    covered: u32,
}

impl ModuleStats {
    fn percent(&self) -> f64 {
        if self.total == 0 {
            return 100.0;
        }
        f64::from(self.covered) / f64::from(self.total) * 100.0
    }
}

enum ModuleStatus {
    New,
    Same,
    Increased { from: u64 },
    Decreased { from: u64 },
}

fn module_dir(filename: &str, src_dir: &Path) -> Option<PathBuf> {
    let rel = Path::new(filename).strip_prefix(src_dir).ok()?;
    let segment = rel.components().next()?.as_os_str().to_str()?;
    if Path::new(segment)
        .extension()
        .is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
    {
        Some(src_dir.to_path_buf())
    } else {
        Some(src_dir.join(segment))
    }
}

fn module_name(mod_path: &Path, src_dir: &Path) -> String {
    let raw = mod_path
        .strip_prefix(src_dir)
        .ok()
        .map_or_else(String::new, |p| p.to_string_lossy().into_owned());
    if raw.is_empty() {
        String::from("(root)")
    } else {
        raw
    }
}

fn floor_percent_int(covered: u32, total: u32) -> u64 {
    if total == 0 {
        return 100;
    }
    u64::from(covered)
        .saturating_mul(100)
        .checked_div(u64::from(total))
        .unwrap_or(100)
}

fn read_summary(path: &Path) -> Result<BTreeMap<String, u64>> {
    if !path.exists() {
        return Ok(BTreeMap::new());
    }
    let text =
        fs::read_to_string(path).with_context(|| format!("reading summary {}", path.display()))?;
    serde_json::from_str(&text).context("parsing summary JSON")
}

fn write_summary(summary: &BTreeMap<String, u64>, path: &Path) -> Result<()> {
    let json = serde_json::to_string_pretty(summary).context("serializing summary")?;
    fs::write(path, json).with_context(|| format!("writing summary to {}", path.display()))
}

fn aggregate_modules(report: Report, src_dir: &Path) -> BTreeMap<PathBuf, ModuleStats> {
    let mut modules: BTreeMap<PathBuf, ModuleStats> = BTreeMap::new();
    for file in report.data.into_iter().flat_map(|d| d.files) {
        let Some(dir) = module_dir(&file.filename, src_dir) else {
            continue;
        };
        let entry = modules.entry(dir).or_insert(ModuleStats {
            total: 0,
            covered: 0,
        });
        entry.total = entry.total.saturating_add(file.summary.lines.count);
        entry.covered = entry.covered.saturating_add(file.summary.lines.covered);
    }
    modules
}

fn compare_modules(
    modules: BTreeMap<PathBuf, ModuleStats>,
    baseline: &BTreeMap<String, u64>,
    src_dir: &Path,
) -> BTreeMap<String, (ModuleStats, u64, ModuleStatus)> {
    modules
        .into_iter()
        .map(|(mod_path, stats)| {
            let name = module_name(&mod_path, src_dir);
            let current = floor_percent_int(stats.covered, stats.total);
            let status = match baseline.get(&name).copied() {
                Option::None => ModuleStatus::New,
                Some(b) if current < b => ModuleStatus::Decreased { from: b },
                Some(b) if current > b => ModuleStatus::Increased { from: b },
                Some(_) => ModuleStatus::Same,
            };
            (name, (stats, current, status))
        })
        .collect()
}

fn update_baseline(
    results: &BTreeMap<String, (ModuleStats, u64, ModuleStatus)>,
    baseline: &mut BTreeMap<String, u64>,
    path: &Path,
) -> Result<()> {
    for (name, (_, current, status)) in results {
        match status {
            ModuleStatus::New | ModuleStatus::Same | ModuleStatus::Increased { .. } => {
                baseline.insert(name.clone(), *current);
            }
            ModuleStatus::Decreased { .. } => {}
        }
    }
    write_summary(baseline, path)
}

fn print_table(
    results: &BTreeMap<String, (ModuleStats, u64, ModuleStatus)>,
) -> Result<(Vec<String>, Vec<String>, Vec<String>)> {
    let stdout = std::io::stdout();
    let mut out = stdout.lock();
    let mut new_modules: Vec<String> = Vec::new();
    let mut decreased: Vec<String> = Vec::new();
    let mut increased: Vec<String> = Vec::new();

    writeln!(
        out,
        "{:<25} {:>10} {:>10}  Status",
        "Module", "Coverage", "Baseline"
    )?;
    writeln!(out, "{}", "-".repeat(60))?;

    for (name, (stats, current, status)) in results {
        let (base_str, status_str): (String, &str) = match status {
            ModuleStatus::New => {
                new_modules.push(name.clone());
                (String::from(""), "new — commit")
            }
            ModuleStatus::Same => (format!("{current}"), "ok"),
            ModuleStatus::Increased { from } => {
                increased.push(name.clone());
                (format!("{from}"), "↑ commit")
            }
            ModuleStatus::Decreased { from } => {
                decreased.push(name.clone());
                (format!("{from}"), "FAIL ↓")
            }
        };
        writeln!(
            out,
            "{:<25} {:>9.1}% {:>10}  {}",
            name,
            stats.percent(),
            base_str,
            status_str,
        )?;
    }

    writeln!(out, "{}", "-".repeat(60))?;
    Ok((new_modules, decreased, increased))
}

fn report_errors(new_modules: &[String], decreased: &[String], increased: &[String]) -> Result<()> {
    let mut errors: Vec<String> = Vec::new();
    if !new_modules.is_empty() {
        errors.push(format!(
            "New modules detected: {}. Commit the new summary to your change",
            new_modules.join(", ")
        ));
    }
    if !decreased.is_empty() {
        errors.push(format!(
            "Module coverage was lowered on modules: {}. Please add tests",
            decreased.join(", ")
        ));
    }
    if !increased.is_empty() {
        errors.push(format!(
            "Module coverage increased on modules: {}. Commit the new summary to your change",
            increased.join(", ")
        ));
    }
    if errors.is_empty() {
        return Ok(());
    }
    let stderr = std::io::stderr();
    let mut err = stderr.lock();
    for error in &errors {
        writeln!(err, "{error}")?;
    }
    drop(err);
    anyhow::bail!("coverage check failed")
}

pub fn run(bin_name: &str, src_dir: &Path, summary_path: &Path) -> Result<()> {
    let tmp = tempfile::NamedTempFile::new().context("creating temp file for coverage report")?;
    let tmp_path = tmp.path().to_path_buf();
    let tmp_str = tmp_path.to_str().context("non-UTF-8 temp path")?;

    let status = std::process::Command::new("cargo")
        .args([
            "llvm-cov",
            "--bin",
            bin_name,
            "--all-features",
            "--json",
            "--output-path",
            tmp_str,
        ])
        .status()
        .context("spawning cargo llvm-cov")?;

    anyhow::ensure!(status.success(), "cargo llvm-cov exited with failure");

    let content =
        fs::read_to_string(&tmp_path).context("reading coverage report from temp file")?;
    let report: Report = serde_json::from_str(&content).context("parsing coverage report")?;

    let src_dir = src_dir
        .canonicalize()
        .with_context(|| format!("resolving src dir: {}", src_dir.display()))?;

    let modules = aggregate_modules(report, &src_dir);
    let mut baseline = read_summary(summary_path)?;
    let results = compare_modules(modules, &baseline, &src_dir);

    update_baseline(&results, &mut baseline, summary_path)?;

    let (new_modules, decreased, increased) = print_table(&results)?;
    report_errors(&new_modules, &decreased, &increased)
}