#![deny(missing_docs)]
use serde_json::Value;
pub fn pretty_json(document: &Value) -> String {
serde_json::to_string_pretty(document).expect("a serde_json::Value is always serializable")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Severity {
#[default]
Enforce,
Warn,
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Severity::Enforce => "enforce",
Severity::Warn => "warn",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BoundaryKind {
Crate,
Module,
Semantic,
Runtime,
}
impl BoundaryKind {
pub fn as_str(&self) -> &'static str {
match self {
BoundaryKind::Crate => "crate",
BoundaryKind::Module => "module",
BoundaryKind::Semantic => "semantic",
BoundaryKind::Runtime => "runtime",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Violation {
pub kind: BoundaryKind,
pub target: String,
pub rule: String,
pub finding: String,
pub reason: String,
pub severity: Severity,
pub baselined: bool,
pub file: Option<String>,
}
impl Violation {
pub fn new(
kind: BoundaryKind,
target: String,
rule: String,
finding: String,
reason: String,
severity: Severity,
) -> Self {
Violation {
kind,
target,
rule,
finding,
reason,
severity,
baselined: false,
file: None,
}
}
pub fn with_file(mut self, file: Option<String>) -> Self {
self.file = file;
self
}
pub fn id(&self) -> ViolationId {
ViolationId {
target: self.target.clone(),
rule: self.rule.clone(),
finding: self.finding.clone(),
}
}
pub fn to_json(&self) -> Value {
serde_json::json!({
"kind": self.kind.as_str(),
"target": self.target,
"rule": self.rule,
"finding": self.finding,
"reason": self.reason,
"severity": self.severity.as_str(),
"baselined": self.baselined,
"file": self.file,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Report {
pub violations: Vec<Violation>,
}
impl Report {
pub fn empty() -> Self {
Report {
violations: Vec::new(),
}
}
pub fn new(violations: Vec<Violation>) -> Self {
Report { violations }
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ViolationId {
pub target: String,
pub rule: String,
pub finding: String,
}
#[derive(Debug, Default)]
pub struct Baseline {
entries: Vec<ViolationId>,
}
impl Baseline {
pub fn of(report: &Report) -> Self {
let mut entries: Vec<ViolationId> = report.violations.iter().map(Violation::id).collect();
entries.sort();
entries.dedup();
Baseline { entries }
}
pub fn contains(&self, violation: &Violation) -> bool {
let id = violation.id();
self.entries.iter().any(|entry| entry == &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| id == *entry))
.collect()
}
pub fn to_json(&self) -> String {
let violations: Vec<Value> = self
.entries
.iter()
.map(|entry| {
serde_json::json!({
"target": entry.target,
"rule": entry.rule,
"finding": entry.finding,
})
})
.collect();
let doc = serde_json::json!({ "version": 1, "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())?;
match doc["version"].as_i64() {
Some(1) => {}
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 item in array {
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}`"))
};
entries.push(ViolationId {
target: field("target")?,
rule: field("rule")?,
finding: field("finding")?,
});
}
entries.sort();
entries.dedup();
Ok(Baseline { entries })
}
}
pub fn apply_baseline(report: &mut Report, baseline: &Baseline) {
for violation in &mut report.violations {
if baseline.contains(violation) {
violation.baselined = true;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Outcome {
Clean,
Violations(Report),
ConstitutionError(String),
}
impl Outcome {
pub fn exit_code(&self) -> u8 {
match self {
Outcome::Clean => 0,
Outcome::Violations(report) => {
if report.violations.iter().any(|violation| {
violation.severity == Severity::Enforce && !violation.baselined
}) {
1
} else {
0
}
}
Outcome::ConstitutionError(_) => 2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn boundary_kind_labels_cover_every_dimension() {
assert_eq!(BoundaryKind::Crate.as_str(), "crate");
assert_eq!(BoundaryKind::Module.as_str(), "module");
assert_eq!(BoundaryKind::Semantic.as_str(), "semantic");
assert_eq!(BoundaryKind::Runtime.as_str(), "runtime");
}
fn sample_violation() -> Violation {
Violation::new(
BoundaryKind::Module,
"crate::kernel".to_string(),
"must not import".to_string(),
"crate::projection".to_string(),
"the kernel must not depend on a projection".to_string(),
Severity::Enforce,
)
}
#[test]
fn to_json_emits_the_file_key_in_both_states() {
let without = sample_violation();
assert_eq!(without.to_json()["file"], Value::Null);
let with = sample_violation().with_file(Some("src/kernel.rs".to_string()));
assert_eq!(
with.to_json()["file"],
Value::String("src/kernel.rs".to_string())
);
}
#[test]
fn file_is_not_part_of_the_baseline_identity() {
let without = sample_violation();
let with = sample_violation().with_file(Some("src/kernel.rs".to_string()));
assert_eq!(without.id(), with.id());
}
}