easement 0.1.1

Architecture quality gate for polyglot codebases
use std::collections::BTreeSet;
use std::path::Path;

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

use crate::check::{Key, Violation};

const HEADER: &str = "# Legalized architecture violations. Generated by `easement baseline`.\n\
# Fix them and regenerate: the gate fails on new violations and on stale entries.\n";

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Baseline {
    pub violations: BTreeSet<Key>,
}

impl Baseline {
    pub fn load(path: &Path) -> Result<Option<Self>> {
        if !path.exists() {
            return Ok(None);
        }
        let text = std::fs::read_to_string(path)
            .with_context(|| format!("cannot read baseline {}", path.display()))?;
        let baseline = serde_norway::from_str(&text)
            .with_context(|| format!("invalid baseline {}", path.display()))?;
        Ok(Some(baseline))
    }

    pub fn from_violations(violations: &[Violation]) -> Self {
        Self {
            violations: violations.iter().map(Violation::key).collect(),
        }
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        let body = serde_norway::to_string(self)?;
        std::fs::write(path, format!("{HEADER}{body}"))
            .with_context(|| format!("cannot write baseline {}", path.display()))
    }
}

/// Result of judging the current violations against a baseline.
pub struct Verdict {
    /// Violations not covered by the baseline.
    pub new: Vec<Violation>,
    /// Baseline entries that no longer occur: the debt was paid, the baseline must shrink.
    pub stale: Vec<Key>,
    pub legalized: usize,
}

impl Verdict {
    /// `violations` are the ones being judged (all of them, or only those in a
    /// diff); `current` are the keys of every violation in the project, which
    /// decide whether a baseline entry is stale.
    pub fn judge(
        violations: Vec<Violation>,
        current: &BTreeSet<Key>,
        baseline: Option<&Baseline>,
    ) -> Self {
        let Some(baseline) = baseline else {
            return Self {
                new: violations,
                stale: Vec::new(),
                legalized: 0,
            };
        };
        let stale = baseline.violations.difference(current).cloned().collect();
        let (legal, new): (Vec<_>, Vec<_>) = violations
            .into_iter()
            .partition(|v| baseline.violations.contains(&v.key()));
        Self {
            new,
            stale,
            legalized: legal.len(),
        }
    }

    pub fn passed(&self) -> bool {
        self.new.is_empty() && self.stale.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn v(file: &str, to: &str) -> Violation {
        Violation {
            rule: crate::check::RuleId::Dependency,
            file: file.into(),
            line: 1,
            message: String::new(),
            because: None,
            from: "a".into(),
            to: to.into(),
            target: format!("{to}/x"),
            specifier: String::new(),
            evidence: Vec::new(),
        }
    }

    #[test]
    fn ratchet_fails_on_new_and_stale() {
        let baseline = Baseline::from_violations(&[v("f1", "b"), v("f2", "b")]);
        let found = vec![v("f1", "b"), v("f3", "c")];
        let current = found.iter().map(Violation::key).collect();
        let verdict = Verdict::judge(found, &current, Some(&baseline));
        assert_eq!(verdict.legalized, 1);
        assert_eq!(verdict.new.len(), 1);
        assert_eq!(verdict.new[0].file, "f3");
        assert_eq!(verdict.stale.len(), 1);
        assert_eq!(verdict.stale[0].file, "f2");
        assert!(!verdict.passed());
    }
}